diff --git a/App.xaml.cs b/App.xaml.cs index 1a2cab7..a49c39f 100644 --- a/App.xaml.cs +++ b/App.xaml.cs @@ -413,7 +413,7 @@ await mainWindow.ViewModel.Personalize.Dialogs.ShowErrorAsync( } while (!_cleanupStarted && _pendingConnections.TryDequeue(out var pending)) { - mainWindow.ShowForJumpList(); + mainWindow.ShowFullWindow(); await mainWindow.ViewModel.ConnectFromJumpListAsync(pending.Request, pending.UseDefaultMode); } } diff --git a/Helpers/ActiveAcrylicBackdrop.cs b/Helpers/ActiveAcrylicBackdrop.cs new file mode 100644 index 0000000..b156ac5 --- /dev/null +++ b/Helpers/ActiveAcrylicBackdrop.cs @@ -0,0 +1,67 @@ +using Microsoft.UI.Composition; +using Microsoft.UI.Composition.SystemBackdrops; +using Microsoft.UI.Xaml.Media; +using Windows.UI.ViewManagement; + +namespace XrayUI.Helpers +{ + /// + /// Desktop acrylic that keeps its blur while the window is inactive and takes its light/dark + /// from the window's own content. The stock DesktopAcrylicBackdrop does neither: it drops to a + /// flat fallback color the moment its window loses activation — which is exactly how the tray + /// panel's slide-out begins — and it can disagree with an app theme forced against the system's. + /// + internal sealed partial class ActiveAcrylicBackdrop : SystemBackdrop + { + private DesktopAcrylicController? _controller; + private SystemBackdropConfiguration? _configuration; + + protected override void OnTargetConnected(ICompositionSupportsSystemBackdrop connectedTarget, XamlRoot xamlRoot) + { + base.OnTargetConnected(connectedTarget, xamlRoot); + if (!DesktopAcrylicController.IsSupported()) return; + + _configuration = new SystemBackdropConfiguration + { + IsInputActive = true, + IsHighContrast = new AccessibilitySettings().HighContrast, + Theme = ToBackdropTheme((xamlRoot.Content as FrameworkElement)?.ActualTheme), + }; + // Base rather than Default: the more opaque kind, so text stays legible over whatever + // happens to be behind the panel. + _controller = new DesktopAcrylicController { Kind = DesktopAcrylicKind.Base }; + _controller.SetSystemBackdropConfiguration(_configuration); + _controller.AddSystemBackdropTarget(connectedTarget); + } + + protected override void OnTargetDisconnected(ICompositionSupportsSystemBackdrop disconnectedTarget) + { + base.OnTargetDisconnected(disconnectedTarget); + if (_controller is null) return; + + _controller.RemoveSystemBackdropTarget(disconnectedTarget); + _controller.Dispose(); + _controller = null; + _configuration = null; + } + + // The base implementation would install the default configuration, bringing back the + // activation tracking this class exists to drop; only the theme is meant to follow. + protected override void OnDefaultSystemBackdropConfigurationChanged(ICompositionSupportsSystemBackdrop target, XamlRoot xamlRoot) + => SyncTheme((xamlRoot.Content as FrameworkElement)?.ActualTheme); + + /// Re-reads the theme for a window whose content theme changed while hidden. + public void SyncTheme(ElementTheme? actualTheme) + { + if (_configuration is not null) + _configuration.Theme = ToBackdropTheme(actualTheme); + } + + private static SystemBackdropTheme ToBackdropTheme(ElementTheme? theme) => theme switch + { + ElementTheme.Dark => SystemBackdropTheme.Dark, + ElementTheme.Light => SystemBackdropTheme.Light, + _ => SystemBackdropTheme.Default, + }; + } +} diff --git a/Helpers/L.cs b/Helpers/L.cs index 0ef17c2..6ec9367 100644 --- a/Helpers/L.cs +++ b/Helpers/L.cs @@ -271,6 +271,7 @@ public static class L public static string Tray_Exit => Loc.GetString("Tray_Exit"); // Tray_TooltipRunning is parameterized ("XrayUI - {0}") — use Loc.Format at the call site. public static string Tray_TooltipIdle => Loc.GetString("Tray_TooltipIdle"); + public static string TrayPanel_Connected => Loc.GetString("TrayPanel_Connected"); // ── Taskbar Jump List ────────────────────────────────────────────────── public static string JumpList_Recent => Loc.GetString("JumpList_Recent"); diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index 9503037..de26a7d 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -45,6 +45,11 @@ public sealed partial class MainWindow // Set when we parked the window off-screen at startup; cleared after // we re-center it on the first user-initiated show (tray click). private bool _needsCenterOnFirstShow; + // Opened by a left click on the tray icon; created on first use and kept. + private Views.TrayPanelWindow? _trayPanel; + // When and where the click that last opened the tray panel landed; see IsSecondTrayClick. + private long _trayClickAt; + private NativePoint _trayClickPoint; private const uint WmQueryEndSession = 0x0011; private const uint WmEndSession = 0x0016; @@ -69,7 +74,7 @@ public sealed partial class MainWindow private readonly TaskCompletionSource _initialization = new(TaskCreationOptions.RunContinuationsAsynchronously); internal Task Initialization => _initialization.Task; - internal void ShowForJumpList() + internal void ShowFullWindow() { RestoreFromTray(); SetMiniMode(false); @@ -85,7 +90,7 @@ public MainWindow(LaunchKind launchKind) var xrayService = new XrayService(); var tunService = new TunService(); var startupService = new StartupService(); - var dialogService = new DialogService(() => _initialized ? Content?.XamlRoot : null); + var dialogService = new DialogService(() => _initialized ? GetDialogXamlRoot() : null); var updateService = new UpdateService(); ViewModel = new MainViewModel(dialogService, settingsService, xrayService, tunService, startupService, updateService); @@ -230,7 +235,8 @@ private void ConfigureTray() try { trayIcon = new TrayIcon(TrayIconId, iconPath, ViewModel.TrayTooltip); - trayIcon.Selected += (_, _) => RestoreFromTray(); + trayIcon.Selected += OnTraySelected; + trayIcon.LeftDoubleClick += OnTrayLeftDoubleClick; trayIcon.ContextMenu += (_, e) => e.Flyout = BuildTrayContextMenu(); trayIcon.IsVisible = true; return trayIcon; @@ -254,6 +260,81 @@ private void ApplyConnectionIcon(bool running) _trayIcon?.SetIcon(path); } + // A click opens the tray panel and a double-click restores the window. The panel opens on + // the first click, so a double-click shows it briefly before the window takes over — the + // price of a panel that opens on the click rather than after the double-click time. + private void OnTraySelected(TrayIcon sender, TrayIconEventArgs args) + { + if (IsSecondTrayClick()) + { + CloseTrayPanelAndRestore(); + return; + } + + var panel = _trayPanel ??= CreateTrayPanel(); + + // Open but never activated, so light dismiss can't close it: the icon toggles it. + if (panel.IsOpen) + { + panel.HidePanel(); + return; + } + + // Clicking the icon while the panel is open deactivates it first, which light-dismisses + // it; the same click then arrives here and must not reopen it, or the icon could never + // close the panel. + if (Environment.TickCount64 - panel.LastHiddenAt < GetDoubleClickTime()) return; + + _trayClickAt = Environment.TickCount64; + _ = GetCursorPos(out _trayClickPoint); + panel.ShowPanel(new TrayPanelViewModel( + ViewModel, openMainWindow: CloseTrayPanelAndRestore, exitApplication: ExitApplication)); + } + + private void OnTrayLeftDoubleClick(TrayIcon sender, TrayIconEventArgs args) => CloseTrayPanelAndRestore(); + + // The panel takes activation on the first click, which can leave Windows unable to see the + // second as part of a double-click: it may arrive as a plain click, or only as the click + // that light-dismisses the panel. So a double-click is recognised here instead — a second + // click on the icon within the double-click time of the one that opened the panel. The + // opening click's record is left to expire rather than cleared on use, so every report of + // the second click restores the window: the light dismiss comes first, when the press has + // just handed the foreground to the taskbar, and can only show it; the shell's own report + // of the click follows with the right to bring it to the front (see RestoreFromTray). + private bool IsSecondTrayClick() + { + if (Environment.TickCount64 - _trayClickAt > GetDoubleClickTime()) return false; + if (!GetCursorPos(out var point)) return false; + // An icon-sized neighbourhood rather than the double-click rectangle: clicks are read + // after the fact, by which time the pointer may have drifted a few pixels. + return Math.Abs(point.X - _trayClickPoint.X) <= GetSystemMetrics(SmCxSmIcon) / 2 + && Math.Abs(point.Y - _trayClickPoint.Y) <= GetSystemMetrics(SmCySmIcon) / 2; + } + + private void CloseTrayPanelAndRestore() + { + _trayPanel?.HidePanel(); + RestoreFromTray(); + } + + private Views.TrayPanelWindow CreateTrayPanel() + { + // ConfigureTray resolved both before the tray icon, and with it this panel, existed. + var panel = new Views.TrayPanelWindow(_idleIconPath!, _runningIconPath!); + panel.LightDismissed += (_, _) => + { + if (IsSecondTrayClick()) + CloseTrayPanelAndRestore(); + }; + // Back in the tray once the panel is gone: give back what it held, as HideToTray does. + panel.Hidden += (_, _) => + { + if (_isHiddenToTray) + ReleaseUiResources(); + }; + return panel; + } + private MenuFlyout BuildTrayContextMenu() { var flyout = new MenuFlyout(); @@ -301,6 +382,21 @@ private bool HideToTray() return true; } + // Dialogs live in this window's XamlRoot. Raised while the window sits in the tray or under + // the tray panel — a connect from the panel or a hotkey that fails, the panel's TUN + // elevation prompt — one would block its caller behind a window nobody is looking at, so + // close the panel and bring the window back first, at full size: the mini window is too + // small to host a dialog. + private XamlRoot? GetDialogXamlRoot() + { + if (_isHiddenToTray || _trayPanel?.IsOpen == true) + { + _trayPanel?.HidePanel(); + ShowFullWindow(); + } + return Content?.XamlRoot; + } + internal void RestoreFromTray() { _isHiddenToTray = false; @@ -314,6 +410,13 @@ internal void RestoreFromTray() } Activate(); + + // Activate never brings the window to the front by itself: it ends in SetActiveWindow, + // which does not raise a background app, and once the window is visible calling it again + // changes nothing. In the tray we are such an app, allowed the foreground only while + // handling the shell's report of a click on the icon, so ask for it explicitly; refused, + // Windows flashes the taskbar button instead. + _ = this.SetForegroundWindow(); } private void HandleHotkeyMessage(int id) @@ -709,6 +812,26 @@ public void StopBackgroundServicesOnExit(bool fastShutdown = false) [DllImport("user32.dll")] private static extern IntPtr SendMessage(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam); + [DllImport("user32.dll")] + private static extern uint GetDoubleClickTime(); + + private const int SmCxSmIcon = 49; + private const int SmCySmIcon = 50; + + [StructLayout(LayoutKind.Sequential)] + private struct NativePoint + { + public int X; + public int Y; + } + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetCursorPos(out NativePoint point); + + [DllImport("user32.dll")] + private static extern int GetSystemMetrics(int index); + [DllImport("kernel32.dll")] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool SetProcessWorkingSetSize( diff --git a/Strings/en-US/Resources.resw b/Strings/en-US/Resources.resw index 13801a5..000a953 100644 --- a/Strings/en-US/Resources.resw +++ b/Strings/en-US/Resources.resw @@ -142,6 +142,30 @@ XrayUI - Idle + + Connected + + + Quick Access + + + Main Window + + + Smart + + + Global + + + System Proxy + + + TUN + + + Node + Edit diff --git a/Strings/zh-CN/Resources.resw b/Strings/zh-CN/Resources.resw index 120b734..2a80ee1 100644 --- a/Strings/zh-CN/Resources.resw +++ b/Strings/zh-CN/Resources.resw @@ -142,6 +142,30 @@ XrayUI - 未连接 + + 已连接 + + + 快速访问 + + + 主界面 + + + 智能分流 + + + 全局路由 + + + 系统代理 + + + TUN + + + 节点 + 编辑 diff --git a/ViewModels/ControlPanelViewModel.cs b/ViewModels/ControlPanelViewModel.cs index a0dd46d..98769f4 100644 --- a/ViewModels/ControlPanelViewModel.cs +++ b/ViewModels/ControlPanelViewModel.cs @@ -735,7 +735,7 @@ private void CleanupCurrentTunRoutesWithoutElevation() /// instead of the generated config. Which slot applies follows the TUN toggle, so this /// flips as the user switches modes. /// - private bool IsCustomConfigActive => IsTunMode ? _useTunConfigProfile : _useProxyConfigProfile; + public bool IsCustomConfigActive => IsTunMode ? _useTunConfigProfile : _useProxyConfigProfile; /// Gate for the gear-menu items a config profile takes ownership of: local /// port, routing mode, custom rules and DNS all live in the profile once it is on, so @@ -753,6 +753,7 @@ public void ApplyConfigProfileState(bool useTunProfile, bool useProxyProfile) private void NotifyConfigProfileStateChanged() { + OnPropertyChanged(nameof(IsCustomConfigActive)); OnPropertyChanged(nameof(IsBuiltInConfigEnabled)); OnPropertyChanged(nameof(RoutingModeText)); } diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs index 489ef1b..892f55c 100644 --- a/ViewModels/MainViewModel.cs +++ b/ViewModels/MainViewModel.cs @@ -41,8 +41,11 @@ public partial class MainViewModel : BaseViewModel [NotifyPropertyChangedFor(nameof(FullModeVisibility))] public partial bool IsMiniMode { get; set; } - public string ActiveServerName => - (ControlPanel.IsRunning ? _activeServer : ServerList.SelectedServer)?.Name ?? L.Main_NoSelection; + // The node the UI describes: the live one while connected, otherwise the list selection, + // whichever the start button would act on. + public ServerEntry? DisplayedServer => ControlPanel.IsRunning ? _activeServer : ServerList.SelectedServer; + + public string ActiveServerName => DisplayedServer?.Name ?? L.Main_NoSelection; // Tray icon tooltip. Uses (IsRunning || IsReapplying) so it stays in the "running" // form across a node switch — IsReapplying brackets the stop→start gap (the same @@ -433,6 +436,7 @@ private void OnServerListPropertyChanged(object? sender, PropertyChangedEventArg if (e.PropertyName == nameof(ServerListViewModel.SelectedServer)) { ServerDetail.SelectedServer = ServerList.SelectedServer; + OnPropertyChanged(nameof(DisplayedServer)); OnPropertyChanged(nameof(ActiveServerName)); OnPropertyChanged(nameof(TrayTooltip)); ControlPanel.NotifyStartStopStateChanged(); @@ -461,6 +465,7 @@ private async void OnPresetImported(object? sender, System.EventArgs e) // logically active. Clear so the UI doesn't claim a stale Active state. UpdateActiveServer(null); ServerList.IsProxyRunning = ControlPanel.IsRunning; + OnPropertyChanged(nameof(DisplayedServer)); OnPropertyChanged(nameof(ActiveServerName)); OnPropertyChanged(nameof(TrayTooltip)); @@ -536,6 +541,7 @@ private void OnControlPanelPropertyChanged(object? sender, PropertyChangedEventA if (isRunning) _ = _jumpList.RefreshAsync(ServerList.Servers, ControlPanel.ActiveServerId); ServerList.IsProxyRunning = isRunning; + OnPropertyChanged(nameof(DisplayedServer)); OnPropertyChanged(nameof(ActiveServerName)); OnPropertyChanged(nameof(TrayTooltip)); OnPropertyChanged(nameof(MiniIsRunning)); diff --git a/ViewModels/TrayPanelViewModel.cs b/ViewModels/TrayPanelViewModel.cs new file mode 100644 index 0000000..65831de --- /dev/null +++ b/ViewModels/TrayPanelViewModel.cs @@ -0,0 +1,258 @@ +using System; +using System.ComponentModel; +using System.Threading.Tasks; +using Microsoft.UI.Dispatching; +using XrayUI.Helpers; +using XrayUI.Models; + +namespace XrayUI.ViewModels +{ + /// + /// The panel a left click on the tray icon opens: connection state, routing mode and the two + /// proxy switches, reachable without bringing the main window back. + /// + /// The panel is rebuilt on every open and this VM is disposed when it closes. All of its + /// bindings go through this object instead of straight onto ControlPanelViewModel, so every + /// subscription into the long-lived view models is owned here and dropped in + /// . Bound directly, x:Bind's change trackers would stay subscribed to + /// them after each panel closed, until a later change found the panel collected. + /// + public sealed partial class TrayPanelViewModel : ObservableObject, IDisposable + { + private readonly MainViewModel _main; + private readonly Action _openMainWindow; + private readonly Action _exitApplication; + private readonly DispatcherQueue _dispatcher; + + // The node the panel describes (MainViewModel.DisplayedServer), watched for a rename or a + // protocol change. + private ServerEntry? _server; + + /// Brings the main window back the way a double-click does. + /// Quits the app, as the tray menu's exit item does. + public TrayPanelViewModel(MainViewModel main, Action openMainWindow, Action exitApplication) + { + _main = main; + _openMainWindow = openMainWindow; + _exitApplication = exitApplication; + _dispatcher = DispatcherQueue.GetForCurrentThread(); + + _main.PropertyChanged += OnMainPropertyChanged; + Panel.PropertyChanged += OnPanelPropertyChanged; + TrackServer(); + } + + private ControlPanelViewModel Panel => _main.ControlPanel; + + // ── Header ──────────────────────────────────────────────────────────── + + /// Picks the green app icon, on the same terms as the tray icon: a node switch + /// (IsReapplying) keeps it green rather than flashing it blue. + public bool ShowsRunning => _main.TrayShowsRunning; + + public bool IsConnected => Panel.IsRunning; + + private bool CanToggleConnection() => Panel.CanStartStop; + + [RelayCommand(CanExecute = nameof(CanToggleConnection))] + private Task ToggleConnectionAsync() => Panel.StartStopCommand.ExecuteAsync(null); + + [RelayCommand] + private void OpenMainWindow() => _openMainWindow(); + + [RelayCommand] + private void ExitApplication() => _exitApplication(); + + // ── Node and status ─────────────────────────────────────────────────── + + public string ServerName => _main.ActiveServerName; + + public string ProtocolText => _server?.DisplayProtocol ?? string.Empty; + + // The code, coloured in the view by ProtocolToBrushConverter as in the server list. + public string? Protocol => _server?.Protocol; + + // With no node selected there is no protocol to caption the name with, so a plain + // label takes its place and the row keeps its shape. + public Visibility ProtocolVisibility => _server is null ? Visibility.Collapsed : Visibility.Visible; + + public Visibility NodeLabelVisibility => _server is null ? Visibility.Visible : Visibility.Collapsed; + + public string StatusText => + Panel.IsReapplying ? L.ControlPanel_StatusApplying : + Panel.IsRunning ? L.TrayPanel_Connected : + L.Main_NotConnected; + + public bool IsBusy => Panel.IsReapplying; + + public Visibility ConnectedDotVisibility => + Panel.IsRunning && !Panel.IsReapplying ? Visibility.Visible : Visibility.Collapsed; + + public Visibility IdleDotVisibility => + !Panel.IsRunning && !Panel.IsReapplying ? Visibility.Visible : Visibility.Collapsed; + + // ── Routing ─────────────────────────────────────────────────────────── + + // Both false while a config profile owns routing, so neither built-in mode reads as the + // one in effect. + public bool IsSmartRouting => !Panel.IsCustomConfigActive && Panel.RoutingMode != "global"; + + public bool IsGlobalRouting => !Panel.IsCustomConfigActive && Panel.RoutingMode == "global"; + + public bool CanChangeRouting => Panel.IsBuiltInConfigEnabled; + + [RelayCommand] + private void SelectRouting(string mode) + { + if (mode == Panel.RoutingMode || !CanChangeRouting) + { + // A ToggleButton unchecks itself when clicked again; put the real state back. + NotifyRoutingChanged(); + return; + } + + Defer(() => _ = Panel.SetRoutingModeCommand.ExecuteAsync(mode)); + } + + // ── Switches ────────────────────────────────────────────────────────── + + public bool IsSystemProxyOn + { + get => Panel.IsSystemProxyEnabled; + set + { + if (value == Panel.IsSystemProxyEnabled || !CanToggleSystemProxy) return; + Defer(() => _ = Panel.SetProxyModeCommand.ExecuteAsync(value ? "system" : "manual")); + } + } + + public bool CanToggleSystemProxy => Panel.IsModeToggleEnabled; + + public bool IsTunOn + { + get => Panel.IsTunMode; + set + { + if (value == Panel.IsTunMode || !CanToggleTun) return; + // Unelevated, turning TUN on raises the elevation prompt; MainWindow closes the + // panel and brings itself back to host it. + Defer(() => Panel.IsTunMode = value); + } + } + + public bool CanToggleTun => Panel.IsTunToggleEnabled; + + // ── Change tracking ─────────────────────────────────────────────────── + + private void OnMainPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + switch (e.PropertyName) + { + case nameof(MainViewModel.ActiveServerName): + OnPropertyChanged(nameof(ServerName)); + break; + case nameof(MainViewModel.DisplayedServer): + TrackServer(); + break; + } + } + + private void OnPanelPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + switch (e.PropertyName) + { + case nameof(ControlPanelViewModel.IsRunning): + OnPropertyChanged(nameof(IsConnected)); + NotifyStatusChanged(); + break; + case nameof(ControlPanelViewModel.IsReapplying): + OnPropertyChanged(nameof(IsBusy)); + NotifyStatusChanged(); + break; + case nameof(ControlPanelViewModel.CanStartStop): + ToggleConnectionCommand.NotifyCanExecuteChanged(); + break; + case nameof(ControlPanelViewModel.RoutingMode): + case nameof(ControlPanelViewModel.IsCustomConfigActive): + NotifyRoutingChanged(); + break; + case nameof(ControlPanelViewModel.IsBuiltInConfigEnabled): + OnPropertyChanged(nameof(CanChangeRouting)); + break; + case nameof(ControlPanelViewModel.IsSystemProxyEnabled): + OnPropertyChanged(nameof(IsSystemProxyOn)); + break; + case nameof(ControlPanelViewModel.IsModeToggleEnabled): + OnPropertyChanged(nameof(CanToggleSystemProxy)); + break; + case nameof(ControlPanelViewModel.IsTunMode): + OnPropertyChanged(nameof(IsTunOn)); + break; + case nameof(ControlPanelViewModel.IsTunToggleEnabled): + OnPropertyChanged(nameof(CanToggleTun)); + break; + } + } + + private void OnServerPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + switch (e.PropertyName) + { + // ActiveServerName reads the name off this entry but is not raised on a rename. + case nameof(ServerEntry.Name): + OnPropertyChanged(nameof(ServerName)); + break; + // Also what RefreshProtocolColor raises after a palette edit. + case nameof(ServerEntry.Protocol): + OnPropertyChanged(nameof(ProtocolText)); + OnPropertyChanged(nameof(Protocol)); + break; + } + } + + private void TrackServer() + { + var server = _main.DisplayedServer; + if (ReferenceEquals(server, _server)) return; + + if (_server is not null) _server.PropertyChanged -= OnServerPropertyChanged; + _server = server; + if (_server is not null) _server.PropertyChanged += OnServerPropertyChanged; + + OnPropertyChanged(nameof(ProtocolText)); + OnPropertyChanged(nameof(Protocol)); + OnPropertyChanged(nameof(ProtocolVisibility)); + OnPropertyChanged(nameof(NodeLabelVisibility)); + } + + private void NotifyStatusChanged() + { + OnPropertyChanged(nameof(ShowsRunning)); + OnPropertyChanged(nameof(StatusText)); + OnPropertyChanged(nameof(ConnectedDotVisibility)); + OnPropertyChanged(nameof(IdleDotVisibility)); + } + + private void NotifyRoutingChanged() + { + OnPropertyChanged(nameof(IsSmartRouting)); + OnPropertyChanged(nameof(IsGlobalRouting)); + } + + /// + /// Runs a command once the binding update that requested it has unwound. The commands + /// raise the very properties the requesting control is bound to — TUN even reverts its + /// own toggle synchronously — and deferring keeps that echo from re-entering a control + /// that is still raising its own change. + /// + private void Defer(DispatcherQueueHandler action) => _dispatcher.TryEnqueue(action); + + public void Dispose() + { + _main.PropertyChanged -= OnMainPropertyChanged; + Panel.PropertyChanged -= OnPanelPropertyChanged; + if (_server is not null) _server.PropertyChanged -= OnServerPropertyChanged; + _server = null; + } + } +} diff --git a/Views/TrayPanelControl.xaml b/Views/TrayPanelControl.xaml new file mode 100644 index 0000000..243307e --- /dev/null +++ b/Views/TrayPanelControl.xaml @@ -0,0 +1,229 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Views/TrayPanelControl.xaml.cs b/Views/TrayPanelControl.xaml.cs new file mode 100644 index 0000000..56b57be --- /dev/null +++ b/Views/TrayPanelControl.xaml.cs @@ -0,0 +1,181 @@ +using System; +using System.ComponentModel; +using Microsoft.UI.Xaml.Automation; +using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Media.Animation; +using Windows.Foundation; +using Windows.UI; +using Windows.UI.ViewManagement; +using Colors = Microsoft.UI.Colors; +using XrayUI.Helpers; + +namespace XrayUI.Views +{ + public sealed partial class TrayPanelControl + { + // The connection glyph on its 24-unit grid, one point for each corner of the square. + // The triangle's tip counts twice, so the morph opens it into the square's right edge + // and the shape stays symmetric top to bottom the whole way. + private static readonly Point[] PlayGlyph = [new(6, 4.5), new(20, 12), new(20, 12), new(6, 19.5)]; + private static readonly Point[] StopGlyph = [new(4, 4), new(20, 4), new(20, 20), new(4, 20)]; + private static readonly TimeSpan GlyphMorphDuration = TimeSpan.FromMilliseconds(300); + + private ImageSource? _idleIcon; + private ImageSource? _runningIcon; + private readonly UISettings _uiSettings = new(); + private bool _isLoaded; + private bool _glyphShowsConnected; + private Storyboard? _glyphMorph; + private long _enabledToken; + + public TrayPanelViewModel ViewModel { get; } + + public TrayPanelControl(TrayPanelViewModel viewModel) + { + // Assigned before InitializeComponent so the TwoWay bindings on the switches start + // from the VM's values instead of pushing control defaults into it. + ViewModel = viewModel; + InitializeComponent(); + + ConnectionButton.Background = LocalBrush("ConnectionBackground"); + ConnectionGlyph.Stroke = LocalBrush("ConnectionForeground"); + UpdateConnectionAppearance(); + SnapConnectionGlyph(); + Loaded += OnLoaded; + Unloaded += OnUnloaded; + SetLabel(ExitButton, L.Tray_Exit); + } + + private void OnLoaded(object sender, RoutedEventArgs e) + { + if (_isLoaded) return; + _isLoaded = true; + ViewModel.PropertyChanged += OnViewModelPropertyChanged; + ActualThemeChanged += OnActualThemeChanged; + _enabledToken = ConnectionButton.RegisterPropertyChangedCallback( + IsEnabledProperty, (_, _) => UpdateGlyphOpacity()); + UpdateConnectionAppearance(); + SnapConnectionGlyph(); + } + + private void OnUnloaded(object sender, RoutedEventArgs e) + { + if (!_isLoaded) return; + _isLoaded = false; + ViewModel.PropertyChanged -= OnViewModelPropertyChanged; + ActualThemeChanged -= OnActualThemeChanged; + ConnectionButton.UnregisterPropertyChangedCallback(IsEnabledProperty, _enabledToken); + _glyphMorph?.Stop(); + } + + private void OnActualThemeChanged(FrameworkElement sender, object args) => UpdateConnectionAppearance(); + + private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName != nameof(TrayPanelViewModel.IsConnected)) return; + UpdateConnectionAppearance(); + MorphConnectionGlyph(); + } + + private void UpdateConnectionAppearance() + { + var running = ViewModel.IsConnected; + LocalBrush("ConnectionBackground").Color = running ? DangerColor("DangerAccentButtonBackground") : Colors.Transparent; + LocalBrush("ButtonBackgroundPointerOver").Color = running ? DangerColor("DangerAccentButtonBackgroundPointerOver") : LocalBrush("IdleHoverBackground").Color; + LocalBrush("ButtonBackgroundPressed").Color = running ? DangerColor("DangerAccentButtonBackgroundPressed") : LocalBrush("IdlePressedBackground").Color; + LocalBrush("ButtonBackgroundDisabled").Color = running ? DangerColor("DangerAccentButtonBackgroundDisabled") : Colors.Transparent; + LocalBrush("ConnectionForeground").Color = running ? Colors.White : LocalBrush("IdleForeground").Color; + SetLabel(ConnectionButton, running ? L.ControlPanel_Stop : L.ControlPanel_Start); + UpdateGlyphOpacity(); + } + + private SolidColorBrush LocalBrush(string key) => (SolidColorBrush)ConnectionButton.Resources[key]; + + private static Color DangerColor(string key) => ((SolidColorBrush)Application.Current.Resources[key]).Color; + + private void UpdateGlyphOpacity() => ConnectionGlyph.Opacity = ConnectionButton.IsEnabled ? 1 : 0.4; + + private void SnapConnectionGlyph() + { + _glyphMorph?.Stop(); + _glyphShowsConnected = ViewModel.IsConnected; + SetGlyphPoints(_glyphShowsConnected ? StopGlyph : PlayGlyph); + } + + private void MorphConnectionGlyph() + { + var connected = ViewModel.IsConnected; + if (connected == _glyphShowsConnected) return; + + // The new shape is set as the points' own values first. The storyboard only covers + // the way there, and FillBehavior.Stop hands the points back to those values. + var from = connected ? PlayGlyph : StopGlyph; + SnapConnectionGlyph(); + if (!_uiSettings.AnimationsEnabled) return; + + // Plays on confirmed state, including changes made outside this panel. Both + // directions ease out over the same 300 ms, so stopping is as quick as starting. + var to = connected ? StopGlyph : PlayGlyph; + DependencyObject[] targets = [GlyphFigure, GlyphSegment1, GlyphSegment2, GlyphSegment3]; + _glyphMorph = new Storyboard(); + for (var i = 0; i < to.Length; i++) + { + var point = new PointAnimationUsingKeyFrames + { + EnableDependentAnimation = true, + FillBehavior = FillBehavior.Stop, + }; + point.KeyFrames.Add(new DiscretePointKeyFrame + { + KeyTime = KeyTime.FromTimeSpan(TimeSpan.Zero), + Value = from[i], + }); + point.KeyFrames.Add(new SplinePointKeyFrame + { + KeyTime = KeyTime.FromTimeSpan(GlyphMorphDuration), + Value = to[i], + KeySpline = new KeySpline { ControlPoint1 = new(0.2, 0.75), ControlPoint2 = new(0.34, 0.94) }, + }); + Storyboard.SetTarget(point, targets[i]); + Storyboard.SetTargetProperty(point, i == 0 ? "StartPoint" : "Point"); + _glyphMorph.Children.Add(point); + } + _glyphMorph.Begin(); + } + + // Point 0 starts the figure; the others end its three line segments. Reached through + // the x:Name fields, not Segments[i]: under AOT that indexer hands back a plain + // PathSegment wrapper, and a C# cast to LineSegment throws InvalidCastException. + private void SetGlyphPoints(Point[] points) + { + GlyphFigure.StartPoint = points[0]; + GlyphSegment1.Point = points[1]; + GlyphSegment2.Point = points[2]; + GlyphSegment3.Point = points[3]; + } + + /// The tray icon pictures, decoded by the host window once it knows the DPI. + public void SetAppIcons(ImageSource? idle, ImageSource? running) + { + _idleIcon = idle; + _runningIcon = running; + Bindings.Update(); + } + + public ImageSource? AppIconFor(bool running) => running ? _runningIcon : _idleIcon; + + /// Moves keyboard focus into the panel, so Esc and Tab work as soon as it opens. + /// As pointer focus, because a click opened the panel: no focus rectangle until Tab. + public void FocusFirstControl() + { + if (!ConnectionButton.Focus(FocusState.Pointer)) + OpenMainButton.Focus(FocusState.Pointer); + } + + private static void SetLabel(Button button, string text) + { + ToolTipService.SetToolTip(button, text); + AutomationProperties.SetName(button, text); + } + } +} diff --git a/Views/TrayPanelWindow.xaml b/Views/TrayPanelWindow.xaml new file mode 100644 index 0000000..0b5cc3f --- /dev/null +++ b/Views/TrayPanelWindow.xaml @@ -0,0 +1,19 @@ + + + + + + + + + + + diff --git a/Views/TrayPanelWindow.xaml.cs b/Views/TrayPanelWindow.xaml.cs new file mode 100644 index 0000000..9cfc7ea --- /dev/null +++ b/Views/TrayPanelWindow.xaml.cs @@ -0,0 +1,364 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading.Tasks; +using Microsoft.UI.Windowing; +using Microsoft.UI.Xaml.Input; +using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Media.Imaging; +using Windows.Foundation; +using Windows.Graphics; +using Windows.Graphics.Imaging; +using WinUIEx; +using XrayUI.Helpers; + +namespace XrayUI.Views +{ + /// + /// Host for the tray panel: a chrome-free, always-on-top window that rests at the bottom-right + /// of the primary work area and slides in from behind the taskbar, the way the Windows quick + /// settings do. Created once and kept; the panel inside is rebuilt on every show and dropped on + /// hide, so a hidden panel holds no view-model subscriptions. + /// + public sealed partial class TrayPanelWindow + { + // Gap to the work-area edges, the same the Windows flyouts keep. + private const double EdgeMarginDip = 12; + private const double SlideInMs = 260; + private const double SlideOutMs = 200; + // The header icon's size in TrayPanelControl. + private const double AppIconDip = 20; + + private readonly IntPtr _hwnd; + private readonly ActiveAcrylicBackdrop _backdrop = new(); + private readonly string _idleIconPath; + private readonly string _runningIconPath; + private ImageSource? _idleIcon; + private ImageSource? _runningIcon; + private bool _iconsRequested; + private TrayPanelViewModel? _viewModel; + // Light dismiss only counts a deactivation that follows an activation of this show: a + // window that never got the foreground would otherwise close the instant it opened. + private bool _activatedSinceShow; + // Just below the display's bottom edge, where the panel waits off-screen. + private int _parkedY; + // The running slide's per-frame step; see StartSlide. + private EventHandler? _slideFrame; + + /// The tray icon shown while disconnected; the panel's header + /// shows the same picture. + /// The tray icon shown while connected. + public TrayPanelWindow(string idleIconPath, string runningIconPath) + { + _idleIconPath = idleIconPath; + _runningIconPath = runningIconPath; + InitializeComponent(); + _hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this); + + var presenter = OverlappedPresenter.Create(); + presenter.SetBorderAndTitleBar(hasBorder: true, hasTitleBar: false); + presenter.IsResizable = false; + presenter.IsMaximizable = false; + presenter.IsMinimizable = false; + presenter.IsAlwaysOnTop = true; + AppWindow.SetPresenter(presenter); + AppWindow.IsShownInSwitchers = false; + AppWindow.Closing += OnClosing; + + var cornerPreference = DwmwcpRound; + _ = DwmSetWindowAttribute(_hwnd, DwmwaWindowCornerPreference, ref cornerPreference, sizeof(int)); + + ThemeHelper.FollowAppTheme(this, WindowRoot); + // After FollowAppTheme, so the backdrop starts from the seeded theme. + SystemBackdrop = _backdrop; + + Activated += OnActivated; + } + + /// Set by and cleared by , so it + /// is already false while the panel slides away. + public bool IsOpen { get; private set; } + + /// Tick count when the panel last started hiding. + public long LastHiddenAt { get; private set; } + + /// Raised once the panel has slid away and released its content. + public event EventHandler? Hidden; + + /// Raised when losing activation is about to close the panel. + public event EventHandler? LightDismissed; + + /// Shows a fresh panel bound to , which this window + /// then owns and disposes when the panel hides. + public void ShowPanel(TrayPanelViewModel viewModel) + { + if (IsOpen) + { + viewModel.Dispose(); + return; + } + + StopSlide(); + ReleasePanel(); + + _viewModel = viewModel; + var panel = new TrayPanelControl(viewModel); + panel.SetAppIcons(_idleIcon, _runningIcon); + panel.Loaded += OnPanelLoaded; + WindowRoot.Children.Add(panel); + + IsOpen = true; + _activatedSinceShow = false; + _backdrop.SyncTheme(WindowRoot.ActualTheme); + + // Wait just below the display until the panel has measured itself; the slide starts + // from here. + var bounds = DisplayArea.Primary.OuterBounds; + _parkedY = bounds.Y + bounds.Height; + AppWindow.Move(new PointInt32(bounds.X + bounds.Width - AppWindow.Size.Width, _parkedY)); + + Activate(); + _ = this.SetForegroundWindow(); + PlaceBelowTaskbar(); + } + + public void HidePanel() + { + if (!IsOpen) return; + + IsOpen = false; + LastHiddenAt = Environment.TickCount64; + PlaceBelowTaskbar(); + StartSlide(_parkedY, SlideOutMs, decelerates: false, OnHideCompleted); + } + + private void OnPanelLoaded(object sender, RoutedEventArgs e) + { + var panel = (TrayPanelControl)sender; + panel.Loaded -= OnPanelLoaded; + if (!IsOpen || !ReferenceEquals(panel.ViewModel, _viewModel)) return; + + // Unconstrained, so the panel reports its natural size rather than the window's. + panel.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); + var scale = WindowRoot.XamlRoot.RasterizationScale; + LoadAppIcons(scale); + // Not ResizeClient: it sizes for a caption this window doesn't have and leaves the + // panel a caption's height too tall. The frame measured off the live window is exact. + var outer = AppWindow.Size; + var client = AppWindow.ClientSize; + AppWindow.Resize(new SizeInt32( + ToPixels(panel.DesiredSize.Width, scale) + outer.Width - client.Width, + ToPixels(panel.DesiredSize.Height, scale) + outer.Height - client.Height)); + + // The window rect includes an invisible resize frame; it is the visible edge that keeps + // the margin from the work area. + var (visibleRight, visibleBottom) = GetVisibleExtent(); + var work = DisplayArea.Primary.WorkArea; + var margin = ToPixels(EdgeMarginDip, scale); + AppWindow.Move(new PointInt32(work.X + work.Width - margin - visibleRight, _parkedY)); + + var restingY = work.Y + work.Height - margin - visibleBottom; + StartSlide(restingY, SlideInMs, decelerates: true, OnShowCompleted); + panel.FocusFirstControl(); + } + + private void OnShowCompleted() + { + // Clear of the taskbar now, so step back up to the top of the topmost band: tucked under + // the taskbar it would also sit under the notification area's overflow, which stays + // open when the panel is opened from an icon in there. + _ = SetWindowPos(_hwnd, HwndTopmost, 0, 0, 0, 0, SwpNoSize | SwpNoMove | SwpNoActivate); + } + + /// Offsets of the visible frame's right and bottom edges from the window's + /// top-left corner; the full window size when DWM can't say. + private (int Right, int Bottom) GetVisibleExtent() + { + var size = AppWindow.Size; + if (!GetWindowRect(_hwnd, out var window) + || DwmGetWindowAttribute(_hwnd, DwmwaExtendedFrameBounds, out var visible, Marshal.SizeOf()) != 0) + { + return (size.Width, size.Height); + } + + return (visible.Right - window.Left, visible.Bottom - window.Top); + } + + private void OnHideCompleted() + { + AppWindow.Hide(); + ReleasePanel(); + Hidden?.Invoke(this, EventArgs.Empty); + } + + private void ReleasePanel() + { + WindowRoot.Children.Clear(); + _viewModel?.Dispose(); + _viewModel = null; + } + + // ── App icon ────────────────────────────────────────────────────────── + + // Once per window, on the first show: the frame to decode depends on the DPI, which is + // only known once the window is on screen. + private async void LoadAppIcons(double scale) + { + if (_iconsRequested) return; + _iconsRequested = true; + + var pixels = ToPixels(AppIconDip, scale); + // Both started before either is awaited, so the two decodes overlap. + var idle = LoadIconFrameAsync(_idleIconPath, pixels); + var running = LoadIconFrameAsync(_runningIconPath, pixels); + _idleIcon = await idle; + _runningIcon = await running; + + if (WindowRoot.Children.Count > 0 && WindowRoot.Children[0] is TrayPanelControl panel) + panel.SetAppIcons(_idleIcon, _runningIcon); + } + + // XAML decodes an .ico through its first frame, which in both tray icons is 16×16 and + // blurs at the header's size; this picks the smallest frame that covers it instead. + private static async Task LoadIconFrameAsync(string path, int wantedPixels) + { + try + { + using var file = File.OpenRead(path); + using var stream = file.AsRandomAccessStream(); + var decoder = await BitmapDecoder.CreateAsync(stream); + + BitmapFrame? pick = null; + for (uint i = 0; i < decoder.FrameCount; i++) + { + var frame = await decoder.GetFrameAsync(i); + if (pick is null || IsBetterFrame(frame.PixelWidth, pick.PixelWidth, wantedPixels)) + pick = frame; + } + if (pick is null) return null; + + var bitmap = await pick.GetSoftwareBitmapAsync(BitmapPixelFormat.Bgra8, BitmapAlphaMode.Premultiplied); + var source = new SoftwareBitmapSource(); + await source.SetBitmapAsync(bitmap); + return source; + } + catch (Exception ex) + { + Debug.WriteLine($"[TrayPanel] Failed to load {path}: {ex.Message}"); + return null; + } + } + + // The smallest frame at least the wanted width; failing that, the largest there is. + private static bool IsBetterFrame(uint candidate, uint current, int wanted) => + candidate >= wanted + ? current < wanted || candidate < current + : current < wanted && candidate > current; + + private void OnActivated(object sender, WindowActivatedEventArgs args) + { + if (args.WindowActivationState != WindowActivationState.Deactivated) + { + _activatedSinceShow = true; + return; + } + + // Light dismiss, as the Windows flyouts do: a click anywhere else closes the panel. + if (_activatedSinceShow && IsOpen) + { + LightDismissed?.Invoke(this, EventArgs.Empty); + HidePanel(); + } + } + + // Alt+F4 hides rather than closes: the window is kept for the next show. + private void OnClosing(AppWindow sender, AppWindowClosingEventArgs args) + { + args.Cancel = true; + HidePanel(); + } + + private void OnEscapeInvoked(KeyboardAccelerator sender, KeyboardAcceleratorInvokedEventArgs args) + { + args.Handled = true; + HidePanel(); + } + + // ── Slide ───────────────────────────────────────────────────────────── + // The window itself has to move — the backdrop belongs to the HWND, so sliding only the + // content would leave the acrylic behind. Frames are driven by the XAML render loop. + + private void StartSlide(int toY, double durationMs, bool decelerates, Action completed) + { + StopSlide(); + var from = AppWindow.Position; + var clock = Stopwatch.StartNew(); + _slideFrame = (_, _) => + { + var t = Math.Min(1.0, clock.Elapsed.TotalMilliseconds / durationMs); + // Cubic ease-out to arrive, ease-in to leave: the Fluent entrance and exit curves. + var eased = decelerates ? 1 - Math.Pow(1 - t, 3) : t * t * t; + AppWindow.Move(new PointInt32(from.X, (int)Math.Round(from.Y + (toY - from.Y) * eased))); + if (t < 1.0) return; + + StopSlide(); + completed(); + }; + CompositionTarget.Rendering += _slideFrame; + } + + private void StopSlide() + { + if (_slideFrame is null) return; + CompositionTarget.Rendering -= _slideFrame; + _slideFrame = null; + } + + // Tucks the window directly under the taskbar, so it slides out from behind the taskbar + // instead of across it. Activation raises the window above everything, so this has to + // follow it. + private void PlaceBelowTaskbar() + { + var taskbar = FindWindowW("Shell_TrayWnd", null); + if (taskbar == IntPtr.Zero) return; + _ = SetWindowPos(_hwnd, taskbar, 0, 0, 0, 0, SwpNoSize | SwpNoMove | SwpNoActivate); + } + + private static int ToPixels(double dip, double scale) => (int)Math.Ceiling(dip * scale); + + private const int DwmwaExtendedFrameBounds = 9; + private const int DwmwaWindowCornerPreference = 33; + private const int DwmwcpRound = 2; + private const uint SwpNoSize = 0x0001; + private const uint SwpNoMove = 0x0002; + private const uint SwpNoActivate = 0x0010; + private static readonly IntPtr HwndTopmost = new(-1); + + [StructLayout(LayoutKind.Sequential)] + private struct NativeRect + { + public int Left; + public int Top; + public int Right; + public int Bottom; + } + + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool GetWindowRect(IntPtr hWnd, out NativeRect lpRect); + + [LibraryImport("dwmapi.dll")] + private static partial int DwmGetWindowAttribute(IntPtr hwnd, int dwAttribute, out NativeRect pvAttribute, int cbAttribute); + + [LibraryImport("user32.dll", StringMarshalling = StringMarshalling.Utf16)] + private static partial IntPtr FindWindowW(string lpClassName, string? lpWindowName); + + [LibraryImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int x, int y, int cx, int cy, uint uFlags); + + [LibraryImport("dwmapi.dll")] + private static partial int DwmSetWindowAttribute(IntPtr hwnd, int dwAttribute, ref int pvAttribute, int cbAttribute); + } +}