diff --git a/CursorLang.Tests/AppTests.cs b/CursorLang.Tests/AppTests.cs index 100bd20..11ad39f 100644 --- a/CursorLang.Tests/AppTests.cs +++ b/CursorLang.Tests/AppTests.cs @@ -18,6 +18,9 @@ public sealed class AppTests [InlineData(typeof(ThemeService))] [InlineData(typeof(IThemeService))] [InlineData(typeof(MainWindowPlacement))] + [InlineData(typeof(MainWindowPresenter))] + [InlineData(typeof(TrayIcon))] + [InlineData(typeof(ITrayIcon))] [InlineData(typeof(ILocalizationService))] [InlineData(typeof(IStartupService))] [InlineData(typeof(IUpdateService))] @@ -100,6 +103,15 @@ public sealed class AppTests Assert.NotNull(window.ImplementationFactory); } + [Fact] + public void The_tray_icon_and_its_interface_are_one_icon() + { + ServiceDescriptor tray = Describe() + .Single(descriptor => descriptor.ServiceType == typeof(ITrayIcon)); + + Assert.NotNull(tray.ImplementationFactory); + } + [Fact] public void The_theme_and_its_interface_are_one_service() { diff --git a/CursorLang.Tests/EndToEndTests.cs b/CursorLang.Tests/EndToEndTests.cs index a5d3bde..c8e3631 100644 --- a/CursorLang.Tests/EndToEndTests.cs +++ b/CursorLang.Tests/EndToEndTests.cs @@ -3,6 +3,7 @@ using System.IO; using System.Reflection; using System.Runtime.InteropServices; using System.Text; +using CursorLang.Services; namespace CursorLang.Tests; @@ -25,6 +26,9 @@ public sealed class EndToEndTests private static readonly TimeSpan StartTimeout = TimeSpan.FromSeconds(30); private static readonly TimeSpan ExitTimeout = TimeSpan.FromSeconds(15); + /// How long "the application went on working" is worth watching for. + private static readonly TimeSpan StayTimeout = TimeSpan.FromSeconds(3); + [Fact] public void The_application_starts_and_shows_the_settings_window() { @@ -34,6 +38,22 @@ public sealed class EndToEndTests Assert.False(launch.Process.HasExited); } + /// + /// Started by Windows itself, the application goes straight to the tray: the + /// user asked for it to be there, not for a window to greet them. + /// + [Fact] + public void A_launch_by_Windows_shows_no_window() + { + using Launch launch = Launch.Start(StartupLaunch.Argument); + + // Nothing is expected to appear, so the wait is for the whole time + Assert.False(launch.Process.WaitForExit(StayTimeout), "the application ended by itself"); + + launch.Process.Refresh(); + Assert.Equal(IntPtr.Zero, launch.Process.MainWindowHandle); + } + // A second run raises no second window but shows the window of the running one [Fact] public void The_second_run_ends_by_itself() @@ -50,15 +70,19 @@ public sealed class EndToEndTests Assert.False(launch.Process.HasExited); } + // The way out of the application is the tray menu alone: the close button of + // the window merely puts the window away [Fact] - public void Closing_the_window_ends_the_application() + public void Closing_the_window_leaves_the_application_in_the_tray() { using Launch launch = Launch.Start(); launch.WaitForWindow(); Assert.True(launch.Process.CloseMainWindow(), "the window did not accept the request to close"); - Assert.True(launch.Process.WaitForExit(ExitTimeout), "the application did not end after the window closed"); - Assert.Equal(0, launch.Process.ExitCode); + Assert.False(launch.Process.WaitForExit(StayTimeout), "the application ended together with its window"); + + launch.Process.Refresh(); + Assert.Equal(IntPtr.Zero, launch.Process.MainWindowHandle); } /// A started application that shuts down together with the check. @@ -79,7 +103,7 @@ public sealed class EndToEndTests internal Process Process { get; } /// Starts the application first — making sure the place is free. - internal static Launch Start() + internal static Launch Start(params string[] arguments) { if (!HasInteractiveDesktop()) { @@ -91,11 +115,11 @@ public sealed class EndToEndTests Assert.Skip("The application is already running — this check keeps out of someone else's run"); } - return new Launch(StartProcess()); + return new Launch(StartProcess(arguments)); } - /// Starts the application the way the user does. - internal static Process StartProcess() + /// Starts the application the way the user — or Windows — does. + internal static Process StartProcess(params string[] arguments) { string path = ExecutablePath(); @@ -104,7 +128,14 @@ public sealed class EndToEndTests Assert.Skip($"The application is not built: {path}"); } - return Process.Start(new ProcessStartInfo(path) { UseShellExecute = true })!; + var start = new ProcessStartInfo(path) { UseShellExecute = true }; + + foreach (string argument in arguments) + { + start.ArgumentList.Add(argument); + } + + return Process.Start(start)!; } /// @@ -171,12 +202,11 @@ public sealed class EndToEndTests { if (!Process.HasExited) { - // The polite way first — that way the app gets to save its settings - if (!Process.CloseMainWindow() || !Process.WaitForExit(ExitTimeout)) - { - Process.Kill(entireProcessTree: true); - Process.WaitForExit(ExitTimeout); - } + // Asking the window to close would only put it away into the + // tray, and the exit lives in a menu no test can reach. The + // settings are saved as they change, so nothing is lost here + Process.Kill(entireProcessTree: true); + Process.WaitForExit(ExitTimeout); } } catch (InvalidOperationException) diff --git a/CursorLang.Tests/Services/MainWindowPresenterTests.cs b/CursorLang.Tests/Services/MainWindowPresenterTests.cs new file mode 100644 index 0000000..95afd88 --- /dev/null +++ b/CursorLang.Tests/Services/MainWindowPresenterTests.cs @@ -0,0 +1,158 @@ +using System.Windows; +using CursorLang.Services; +using CursorLang.Tests.Infrastructure; + +namespace CursorLang.Tests.Services; + +/// +/// The fate of the settings window: it comes and goes, and the application +/// outlives it either way. +/// +/// +/// The window has to be alive for real — showing and hiding are the point here — +/// so it is kept fully transparent and off the taskbar. +/// +public sealed class MainWindowPresenterTests +{ + [Fact] + public void The_close_button_hides_the_window_instead_of_closing_it() + { + Sta.Run(() => + { + var presenter = new MainWindowPresenter(new MainWindowPlacement()); + + Open(presenter, window => + { + window.Close(); + + Assert.False(window.IsVisible); + Assert.False(presenter.IsShown); + + // The window is hidden rather than closed: a closed one would + // refuse to be shown again + presenter.Show(); + Assert.True(window.IsVisible); + }); + }); + } + + [Fact] + public void The_minimise_button_hides_the_window_as_well() + { + Sta.Run(() => + { + var presenter = new MainWindowPresenter(new MainWindowPlacement()); + + Open(presenter, window => + { + window.WindowState = WindowState.Minimized; + + Assert.False(window.IsVisible); + + // And the window is left in the state the next show needs: + // a minimised one would come back as an icon on the taskbar + Assert.Equal(WindowState.Normal, window.WindowState); + }); + }); + } + + [Fact] + public void A_hidden_window_comes_back_on_request() + { + Sta.Run(() => + { + var presenter = new MainWindowPresenter(new MainWindowPlacement()); + + Open(presenter, window => + { + presenter.Hide(); + Assert.False(presenter.IsShown); + + presenter.Show(); + + Assert.True(window.IsVisible); + Assert.True(presenter.IsShown); + Assert.Equal(WindowState.Normal, window.WindowState); + }); + }); + } + + // On the way out of the application the window closes for real: otherwise + // the exit from the tray menu would run into the very same refusal + [Fact] + public void The_window_closes_once_the_application_is_quitting() + { + Sta.Run(() => + { + var presenter = new MainWindowPresenter(new MainWindowPlacement()); + + Open(presenter, window => + { + presenter.AllowClose(); + window.Close(); + + Assert.Throws(window.Show); + }); + }); + } + + // Without a notification area there is no way back to a hidden window, + // so the window goes back to closing when told to close + [Fact] + public void A_window_let_go_of_behaves_the_usual_way() + { + Sta.Run(() => + { + var presenter = new MainWindowPresenter(new MainWindowPlacement()); + + Open(presenter, window => + { + presenter.Detach(); + window.Close(); + + Assert.Throws(window.Show); + }); + + // Letting go twice is what happens when the application quits + // right after: it is no reason to fail + presenter.Detach(); + }); + } + + [Fact] + public void With_no_window_attached_nothing_happens() + { + var presenter = new MainWindowPresenter(new MainWindowPlacement()); + + presenter.Show(); + presenter.Hide(); + + Assert.False(presenter.IsShown); + } + + private static void Open(MainWindowPresenter presenter, Action check) + { + var window = new Window + { + // The window is needed alive, but not in sight + Opacity = 0, + ShowInTaskbar = false, + ShowActivated = false, + Width = 100, + Height = 100, + }; + + presenter.Attach(window); + + try + { + window.Show(); + check(window); + } + finally + { + presenter.AllowClose(); + window.Close(); + } + } +} diff --git a/CursorLang.Tests/Services/StartupLaunchTests.cs b/CursorLang.Tests/Services/StartupLaunchTests.cs new file mode 100644 index 0000000..e6353b9 --- /dev/null +++ b/CursorLang.Tests/Services/StartupLaunchTests.cs @@ -0,0 +1,60 @@ +using CursorLang.Services; + +namespace CursorLang.Tests.Services; + +/// +/// Telling a launch by Windows apart from a launch by the user: the first one goes +/// to the tray without a window, the second one is what the window is for. +/// +public sealed class StartupLaunchTests +{ + [Fact] + public void A_launch_by_the_user_carries_no_argument() + { + Assert.False(StartupLaunch.HasArgument([])); + Assert.False(StartupLaunch.IsAutomatic([])); + } + + [Fact] + public void The_startup_entry_says_so_in_the_command_line() + { + Assert.True(StartupLaunch.HasArgument([StartupLaunch.Argument])); + Assert.True(StartupLaunch.IsAutomatic([StartupLaunch.Argument])); + } + + // The argument does not have to come first: Windows may put its own + // alongside it one day + [Fact] + public void The_argument_is_looked_for_among_the_others() + { + Assert.True(StartupLaunch.HasArgument(["--whatever", StartupLaunch.Argument])); + } + + [Fact] + public void The_case_of_the_argument_does_not_matter() + { + Assert.True(StartupLaunch.HasArgument([StartupLaunch.Argument.ToUpperInvariant()])); + } + + [Fact] + public void Anything_else_is_a_launch_by_the_user() + { + Assert.False(StartupLaunch.HasArgument(["--startupp", "startup", "-startup"])); + } + + /// + /// The command written into the registry carries the argument: that is the whole + /// point of the argument. + /// + [Fact] + public void The_startup_entry_is_written_with_the_argument() + { + string? command = RegistryStartup.GetCommand(); + + Assert.NotNull(command); + Assert.EndsWith(StartupLaunch.Argument, command, StringComparison.Ordinal); + + // And the path itself stays quoted: it has spaces in it more often than not + Assert.StartsWith("\"", command, StringComparison.Ordinal); + } +} diff --git a/CursorLang.Tests/Services/TrayIconTests.cs b/CursorLang.Tests/Services/TrayIconTests.cs new file mode 100644 index 0000000..c676c17 --- /dev/null +++ b/CursorLang.Tests/Services/TrayIconTests.cs @@ -0,0 +1,213 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using CursorLang.Interop; +using CursorLang.Services; +using CursorLang.Tests.Infrastructure; + +namespace CursorLang.Tests.Services; + +/// +/// The icon in the notification area and its menu — the whole interface of an +/// application that works in the background. +/// +/// +/// The icon needs a window of its own, so the checks run on the interface thread. +/// Whether the tray accepts the icon is up to Windows: there is no notification area +/// in a session without a desktop, and such a check skips itself. +/// +public sealed class TrayIconTests +{ + [Fact] + public void The_icon_ends_up_in_the_notification_area() + { + Sta.Run(() => + { + using var tray = new TrayIcon(new FakeLocalizationService()); + + if (!tray.Install()) + { + Assert.Skip("Windows did not take the icon — there is no notification area here"); + } + + // Asking twice changes nothing: a second icon of the same + // application in the tray is not what anyone wants + Assert.True(tray.Install()); + }); + } + + [Fact] + public void The_icon_is_taken_away_on_the_way_out() + { + Sta.Run(() => + { + var tray = new TrayIcon(new FakeLocalizationService()); + tray.Install(); + + tray.Dispose(); + + // A second disposal is what the container does after the application + // has already shut the tray down itself + tray.Dispose(); + }); + } + + [Fact] + public void The_menu_offers_the_settings_and_the_way_out() + { + Sta.Run(() => + { + using var tray = new TrayIcon(new FakeLocalizationService()); + + List items = [.. tray.Menu.Items.OfType()]; + + Assert.Equal(2, items.Count); + Assert.Equal("en:TrayMenuSettings", items[0].Header); + Assert.Equal("en:TrayMenuExit", items[1].Header); + + // The way out is set apart from the rest: it is the one point of no return + Assert.Single(tray.Menu.Items.OfType()); + }); + } + + [Fact] + public void The_menu_follows_the_language_chosen_in_the_settings() + { + Sta.Run(() => + { + var localization = new FakeLocalizationService(); + using var tray = new TrayIcon(localization); + + MenuItem settings = tray.Menu.Items.OfType().First(); + Assert.Equal("en:TrayMenuSettings", settings.Header); + + localization.CurrentLanguage = "ru"; + + Assert.Equal("ru:TrayMenuSettings", settings.Header); + }); + } + + [Fact] + public void The_settings_point_of_the_menu_asks_for_the_window() + { + Sta.Run(() => + { + using var tray = new TrayIcon(new FakeLocalizationService()); + int opened = 0; + int exits = 0; + + tray.OpenRequested += (_, _) => opened++; + tray.ExitRequested += (_, _) => exits++; + + Click(tray.Menu.Items.OfType().First()); + + Assert.Equal(1, opened); + Assert.Equal(0, exits); + }); + } + + [Fact] + public void The_exit_point_of_the_menu_asks_for_the_way_out() + { + Sta.Run(() => + { + using var tray = new TrayIcon(new FakeLocalizationService()); + int opened = 0; + int exits = 0; + + tray.OpenRequested += (_, _) => opened++; + tray.ExitRequested += (_, _) => exits++; + + Click(tray.Menu.Items.OfType().Last()); + + Assert.Equal(1, exits); + Assert.Equal(0, opened); + }); + } + + // The menu belongs to no window and hangs on no element tree: WPF has to + // raise it all the same + [Fact] + public void The_menu_shows_up_on_request() + { + Sta.Run(() => + { + using var tray = new TrayIcon(new FakeLocalizationService()); + tray.Install(); + + try + { + tray.ShowMenu(); + + Assert.True(tray.Menu.IsOpen); + Assert.Equal(PlacementMode.MousePoint, tray.Menu.Placement); + } + finally + { + // An open menu holds the mouse: the checks that follow would + // never see a click of their own + tray.Menu.IsOpen = false; + } + }); + } + + /// + /// The line between the points of the menu is drawn by the theme of the app. + /// + /// + /// WPF gives a separator inside a menu a style of its own, found by a key rather + /// than by the type: a style by type never reaches it. Left with the system one, + /// the line comes out indented from the left, where the icons of a system menu + /// would go, and stretched past the right edge of the menu. + /// + [Fact] + public void The_line_between_the_points_of_the_menu_keeps_to_the_theme() + { + Sta.Run(() => + { + var themed = Application.Current.TryFindResource(MenuItem.SeparatorStyleKey) as Style; + + Assert.NotNull(themed); + Assert.Equal(typeof(Separator), themed.TargetType); + + using var tray = new TrayIcon(new FakeLocalizationService()); + + try + { + tray.ShowMenu(); + + Separator line = tray.Menu.Items.OfType().Single(); + + // The style has reached the line: it is the theme drawing it, + // margins and all + Assert.Same(themed, line.Style); + Assert.True(line.ActualWidth > 0); + } + finally + { + tray.Menu.IsOpen = false; + } + }); + } + + // The image comes from the executable itself; the icon Windows keeps for an + // application without one is the last resort. An empty spot in the tray is not + // an option either way + [Fact] + public void There_is_an_icon_to_show() + { + IntPtr icon = TrayIconNative.LoadApplicationIcon(); + + Assert.NotEqual(IntPtr.Zero, icon); + + TrayIconNative.ReleaseIcon(icon); + } + + [Fact] + public void Explorer_restarting_is_a_message_of_its_own() + { + Assert.NotEqual(0, TrayIconNative.TaskbarCreatedMessage); + } + + private static void Click(MenuItem item) => item.RaiseEvent(new RoutedEventArgs(MenuItem.ClickEvent)); +} diff --git a/CursorLang.Tests/Views/MainWindowTests.cs b/CursorLang.Tests/Views/MainWindowTests.cs index 30d6f41..b8c0dde 100644 --- a/CursorLang.Tests/Views/MainWindowTests.cs +++ b/CursorLang.Tests/Views/MainWindowTests.cs @@ -265,6 +265,28 @@ public sealed class MainWindowTests }); } + // The application lives in the tray, and the settings window is a guest on the + // screen: its close button puts it away rather than ends anything + [Fact] + public void The_window_hooks_up_to_the_tray() + { + Sta.Run(() => + { + using SettingsViewModel viewModel = CreateViewModel(); + + Open(viewModel, window => + { + window.Close(); + + Assert.False(window.IsVisible); + + // A closed window would refuse this + window.Show(); + Assert.True(window.IsVisible); + }); + }); + } + [Fact] public void The_window_hooks_up_to_the_placement() { @@ -305,7 +327,9 @@ public sealed class MainWindowTests MainWindowPlacement placement, Action check) { - var window = new MainWindow(viewModel, theme, placement) + var presenter = new MainWindowPresenter(placement); + + var window = new MainWindow(viewModel, theme, placement, presenter) { // The window is needed alive, but not in sight Opacity = 0, @@ -322,6 +346,9 @@ public sealed class MainWindowTests } finally { + // The window belongs to the tray now and refuses to close until + // the application is on its way out + presenter.AllowClose(); window.Close(); } } diff --git a/CursorLang/App.xaml b/CursorLang/App.xaml index ce2d06e..6045535 100644 --- a/CursorLang/App.xaml +++ b/CursorLang/App.xaml @@ -1,7 +1,7 @@ + ShutdownMode="OnExplicitShutdown"> diff --git a/CursorLang/App.xaml.cs b/CursorLang/App.xaml.cs index c598aa1..d289e09 100644 --- a/CursorLang/App.xaml.cs +++ b/CursorLang/App.xaml.cs @@ -11,14 +11,17 @@ public partial class App : Application { private ServiceProvider? _services; private SingleInstanceGate? _instanceGate; - private MainWindowPlacement? _placement; + private MainWindowPresenter? _presenter; + private ITrayIcon? _tray; protected override void OnStartup(StartupEventArgs e) { base.OnStartup(e); + bool automatic = StartupLaunch.IsAutomatic(e.Args); + var gate = new SingleInstanceGate(); - if (!gate.TryAcquire()) + if (!gate.TryAcquire(showRunningInstance: !automatic)) { gate.Dispose(); Shutdown(); @@ -34,9 +37,26 @@ public partial class App : Application _services.GetRequiredService(); - _placement = _services.GetRequiredService(); + _presenter = _services.GetRequiredService(); + MainWindow = _services.GetRequiredService(); - MainWindow.Show(); + + _tray = _services.GetRequiredService(); + _tray.OpenRequested += OnOpenRequested; + _tray.ExitRequested += OnExitRequested; + + bool hasTray = _tray.Install(); + + if (!automatic || !hasTray) + { + _presenter.Show(); + } + + if (!hasTray) + { + _presenter.Detach(); + ShutdownMode = ShutdownMode.OnMainWindowClose; + } _ = _services.GetRequiredService().InitializeAsync(); _ = _services.GetRequiredService().StartAsync(); @@ -46,6 +66,12 @@ public partial class App : Application protected override void OnExit(ExitEventArgs e) { + if (_tray is not null) + { + _tray.OpenRequested -= OnOpenRequested; + _tray.ExitRequested -= OnExitRequested; + } + _services?.Dispose(); if (_instanceGate is not null) @@ -57,21 +83,14 @@ public partial class App : Application base.OnExit(e); } - private void OnActivationRequested(object? sender, EventArgs e) + private void OnActivationRequested(object? sender, EventArgs e) => _presenter?.Show(); + + private void OnOpenRequested(object? sender, EventArgs e) => _presenter?.Show(); + + private void OnExitRequested(object? sender, EventArgs e) { - if (MainWindow is not { IsVisible: true }) - { - return; - } - - if (MainWindow.WindowState == WindowState.Minimized) - { - MainWindow.WindowState = WindowState.Normal; - } - - _placement?.Apply(MainWindow); - - MainWindow.Activate(); + _presenter?.AllowClose(); + Shutdown(); } internal static void ConfigureServices(IServiceCollection services) @@ -86,6 +105,10 @@ public partial class App : Application services.AddSingleton(provider => provider.GetRequiredService()); services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(); + services.AddSingleton(provider => provider.GetRequiredService()); services.AddSingleton(); services.AddSingleton(); diff --git a/CursorLang/Interop/TrayIconNative.cs b/CursorLang/Interop/TrayIconNative.cs new file mode 100644 index 0000000..f965a25 --- /dev/null +++ b/CursorLang/Interop/TrayIconNative.cs @@ -0,0 +1,226 @@ +using System.Runtime.InteropServices; + +namespace CursorLang.Interop; + +/// +/// Win32 API for the notification area: the icon itself, the messages it sends +/// and the icon image taken from the executable. +/// +/// +/// The icon is asked for at version 4 of the protocol. It is the only version that +/// reports a request for the context menu as such — by the keyboard as well as by +/// the right button — and passes the point of the click along with it. The +/// notification then arrives in the low word of lParam, and the point in +/// wParam, which is the opposite of the earlier versions. +/// +internal static class TrayIconNative +{ + /// The message the icon sends to its window. WM_APP is free for the app. + internal const int CallbackMessage = 0x8000 + 1; + + /// The user chose the icon: a click of the left button or Enter on it. + internal const int SelectNotification = 0x0400; + + /// The same by the space bar — Windows tells the two apart. + internal const int KeySelectNotification = 0x0403; + + /// The context menu is asked for: the right button or the menu key. + internal const int ContextMenuNotification = 0x007B; + + private const int NIM_ADD = 0x00000000; + private const int NIM_MODIFY = 0x00000001; + private const int NIM_DELETE = 0x00000002; + private const int NIM_SETVERSION = 0x00000004; + + private const uint NIF_MESSAGE = 0x00000001; + private const uint NIF_ICON = 0x00000002; + private const uint NIF_TIP = 0x00000004; + private const uint NIF_SHOWTIP = 0x00000080; + + private const uint NotifyIconVersion4 = 4; + + private const uint IMAGE_ICON = 1; + private const uint LR_DEFAULTCOLOR = 0x00000000; + + private const int SM_CXSMICON = 49; + private const int SM_CYSMICON = 50; + + /// The resource the .NET build puts the application icon under. + private const int ApplicationIconResource = 32512; + + /// + /// Explorer says it has restarted this way. The icons of every application are + /// gone by then and have to be put back. + /// + internal static int TaskbarCreatedMessage { get; } = RegisterWindowMessage("TaskbarCreated"); + + /// Puts the icon into the notification area. + internal static bool Add(IntPtr window, int id, IntPtr icon, string tooltip) + { + NotifyIconData data = Describe(window, id, icon, tooltip); + + if (!Shell_NotifyIcon(NIM_ADD, ref data)) + { + return false; + } + + // The version is asked for after the icon is added and applies to it alone + data.uVersion = NotifyIconVersion4; + Shell_NotifyIcon(NIM_SETVERSION, ref data); + + return true; + } + + /// Replaces the image and the tooltip of an icon already there. + internal static bool Modify(IntPtr window, int id, IntPtr icon, string tooltip) + { + NotifyIconData data = Describe(window, id, icon, tooltip); + return Shell_NotifyIcon(NIM_MODIFY, ref data); + } + + /// Takes the icon away. A forgotten icon stays in the tray until hovered. + internal static void Remove(IntPtr window, int id) + { + var data = new NotifyIconData + { + cbSize = Marshal.SizeOf(), + hWnd = window, + uID = (uint)id, + }; + + Shell_NotifyIcon(NIM_DELETE, ref data); + } + + /// + /// The icon of the application at the size the tray asks for. + /// + /// + /// The image comes from the executable itself, so the tray shows what the user + /// sees in Explorer. The build puts the icon under the standard resource; should + /// it end up elsewhere, the first icon of the file is taken, and failing that — + /// the icon Windows gives to an application without one. An icon is needed + /// either way: without it the tray shows an empty spot. + /// + internal static IntPtr LoadApplicationIcon() + { + int width = GetSystemMetrics(SM_CXSMICON); + int height = GetSystemMetrics(SM_CYSMICON); + + IntPtr icon = LoadImage( + GetModuleHandle(null), ApplicationIconResource, IMAGE_ICON, width, height, LR_DEFAULTCOLOR); + + if (icon == IntPtr.Zero && Environment.ProcessPath is { Length: > 0 } path) + { + icon = ExtractIconEx(path, 0, out IntPtr large, out IntPtr small, 1) > 0 ? small : IntPtr.Zero; + + if (large != IntPtr.Zero) + { + DestroyIcon(large); + } + } + + return icon != IntPtr.Zero ? icon : LoadIcon(IntPtr.Zero, ApplicationIconResource); + } + + /// Releases an icon loaded by . + internal static void ReleaseIcon(IntPtr icon) + { + if (icon != IntPtr.Zero) + { + DestroyIcon(icon); + } + } + + /// The notification the icon has sent: it sits in the low word of lParam. + internal static int NotificationOf(IntPtr lParam) => (int)(lParam.ToInt64() & 0xFFFF); + + /// + /// Brings the window to the foreground. + /// + /// + /// Windows takes a menu down when its owner window loses the foreground. A tray + /// icon belongs to a window that is never shown, so the foreground has to be + /// asked for by hand — otherwise the menu stays on screen after the user has + /// clicked past it. + /// + internal static void BringToForeground(IntPtr window) => SetForegroundWindow(window); + + private static NotifyIconData Describe(IntPtr window, int id, IntPtr icon, string tooltip) => new() + { + cbSize = Marshal.SizeOf(), + hWnd = window, + uID = (uint)id, + uFlags = NIF_MESSAGE | NIF_ICON | NIF_TIP | NIF_SHOWTIP, + uCallbackMessage = CallbackMessage, + hIcon = icon, + szTip = tooltip, + }; + + [DllImport("shell32.dll", CharSet = CharSet.Unicode)] + private static extern bool Shell_NotifyIcon(int dwMessage, ref NotifyIconData lpData); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern int RegisterWindowMessage(string lpString); + + [DllImport("user32.dll")] + private static extern IntPtr LoadImage(IntPtr hInst, IntPtr name, uint type, int cx, int cy, uint fuLoad); + + private static IntPtr LoadImage(IntPtr hInst, int resource, uint type, int cx, int cy, uint fuLoad) => + LoadImage(hInst, new IntPtr(resource), type, cx, cy, fuLoad); + + [DllImport("user32.dll")] + private static extern IntPtr LoadIcon(IntPtr hInstance, IntPtr lpIconName); + + private static IntPtr LoadIcon(IntPtr hInstance, int resource) => + LoadIcon(hInstance, new IntPtr(resource)); + + [DllImport("shell32.dll", CharSet = CharSet.Unicode)] + private static extern int ExtractIconEx(string lpszFile, int nIconIndex, + out IntPtr phiconLarge, out IntPtr phiconSmall, int nIcons); + + [DllImport("user32.dll")] + private static extern bool DestroyIcon(IntPtr hIcon); + + [DllImport("user32.dll")] + private static extern int GetSystemMetrics(int nIndex); + + [DllImport("user32.dll")] + private static extern bool SetForegroundWindow(IntPtr hWnd); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] + private static extern IntPtr GetModuleHandle(string? lpModuleName); + + /// + /// NOTIFYICONDATAW. The whole structure is described even though only its first + /// half is used: Windows reads its size and refuses one it does not know. + /// + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct NotifyIconData + { + public int cbSize; + public IntPtr hWnd; + public uint uID; + public uint uFlags; + public int uCallbackMessage; + public IntPtr hIcon; + + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)] + public string szTip; + + public uint dwState; + public uint dwStateMask; + + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)] + public string szInfo; + + /// A timeout in the older versions and the protocol version here. + public uint uVersion; + + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)] + public string szInfoTitle; + + public uint dwInfoFlags; + public Guid guidItem; + public IntPtr hBalloonIcon; + } +} diff --git a/CursorLang/Resources/Strings.resx b/CursorLang/Resources/Strings.resx index 3ea4ae9..bf66334 100644 --- a/CursorLang/Resources/Strings.resx +++ b/CursorLang/Resources/Strings.resx @@ -61,6 +61,12 @@ CursorLang — Settings + + Settings + + + Exit + Interface diff --git a/CursorLang/Resources/Strings.ru.resx b/CursorLang/Resources/Strings.ru.resx index fe4be62..f1d044b 100644 --- a/CursorLang/Resources/Strings.ru.resx +++ b/CursorLang/Resources/Strings.ru.resx @@ -61,6 +61,12 @@ CursorLang — Настройки + + Настройки + + + Выход + Интерфейс diff --git a/CursorLang/Services/ITrayIcon.cs b/CursorLang/Services/ITrayIcon.cs new file mode 100644 index 0000000..df8bd5e --- /dev/null +++ b/CursorLang/Services/ITrayIcon.cs @@ -0,0 +1,20 @@ +namespace CursorLang.Services; + +/// +/// The icon in the notification area — the only face of an application that works +/// in the background. +/// +public interface ITrayIcon +{ + /// The user asks for the settings window: the icon or its menu. + event EventHandler? OpenRequested; + + /// The user asks the application to quit. There is no other way out. + event EventHandler? ExitRequested; + + /// + /// Puts the icon into the notification area. Returns false when Windows + /// refuses to take it. + /// + bool Install(); +} diff --git a/CursorLang/Services/MainWindowPresenter.cs b/CursorLang/Services/MainWindowPresenter.cs new file mode 100644 index 0000000..014b705 --- /dev/null +++ b/CursorLang/Services/MainWindowPresenter.cs @@ -0,0 +1,126 @@ +using System.ComponentModel; +using System.Windows; + +namespace CursorLang.Services; + +/// +/// Keeps the settings window between the screen and the tray. +/// +/// +/// The application works in the background, and the settings window is a guest on +/// the screen rather than the application itself. Both buttons in its title bar +/// therefore mean the same thing — put the window away — and neither ends the +/// application: that is what the tray menu is for. +/// +/// The window is hidden rather than closed. Closing it would take its handle with +/// it, and everything hung on the window — the theme, the place it was left in, the +/// computed layout — would have to be built anew on every show. +/// +public sealed class MainWindowPresenter +{ + private readonly MainWindowPlacement _placement; + + private Window? _window; + + // The application is quitting: the window may close for real now + private bool _isExiting; + + public MainWindowPresenter(MainWindowPlacement placement) => _placement = placement; + + /// Whether the window is on the screen right now. + public bool IsShown => _window is { IsVisible: true }; + + /// Takes over the fate of the window: the close button and the minimise one. + public void Attach(Window window) + { + _window = window; + + window.Closing += OnClosing; + window.StateChanged += OnStateChanged; + } + + /// + /// Shows the window and brings it forward — from the tray as well as from a + /// second launch of the application. + /// + public void Show() + { + if (_window is null) + { + return; + } + + _window.Show(); + + // A window put away while minimised is restored first: only then does it + // have bounds to be placed by + if (_window.WindowState == WindowState.Minimized) + { + _window.WindowState = WindowState.Normal; + } + + _placement.Apply(_window); + _window.Activate(); + } + + /// Puts the window away into the tray. + public void Hide() + { + if (_window is null) + { + return; + } + + _window.Hide(); + + // The state is straightened out after the window is out of sight: the next + // show is to bring back a window, not an icon on the taskbar + _window.WindowState = WindowState.Normal; + } + + /// + /// Lets the window close. Called on the way out through the tray menu — up to + /// that moment closing means hiding. + /// + public void AllowClose() => _isExiting = true; + + /// + /// Gives the window back its usual behaviour: the close button closes, and the + /// minimise button minimises. + /// + /// + /// The last resort for a session with no notification area in it. Without the + /// icon there is no way back to a hidden window and no way out of the + /// application — the window has to take both duties back. + /// + public void Detach() + { + if (_window is null) + { + return; + } + + _window.Closing -= OnClosing; + _window.StateChanged -= OnStateChanged; + _window = null; + } + + private void OnClosing(object? sender, CancelEventArgs e) + { + if (_isExiting) + { + return; + } + + e.Cancel = true; + Hide(); + } + + private void OnStateChanged(object? sender, EventArgs e) + { + if (_window is { WindowState: WindowState.Minimized }) + { + Hide(); + } + } +} diff --git a/CursorLang/Services/RegistryStartup.cs b/CursorLang/Services/RegistryStartup.cs index 3fcf2e9..17996dc 100644 --- a/CursorLang/Services/RegistryStartup.cs +++ b/CursorLang/Services/RegistryStartup.cs @@ -115,6 +115,13 @@ internal sealed class RegistryStartup /// What Windows is to run. null — the path of the running program is /// unknown, and there is nothing to write down. /// - private static string? GetCommand() => - Environment.ProcessPath is { Length: > 0 } path ? $"\"{path}\"" : null; + /// + /// The argument is how the application recognises a launch of this kind and goes + /// straight to the tray: see . The user starting the + /// application themselves passes no such thing and gets the window. + /// + internal static string? GetCommand() => + Environment.ProcessPath is { Length: > 0 } path + ? $"\"{path}\" {StartupLaunch.Argument}" + : null; } diff --git a/CursorLang/Services/SingleInstanceGate.cs b/CursorLang/Services/SingleInstanceGate.cs index 09d29c1..3fd8d3d 100644 --- a/CursorLang/Services/SingleInstanceGate.cs +++ b/CursorLang/Services/SingleInstanceGate.cs @@ -51,7 +51,17 @@ public sealed class SingleInstanceGate : IDisposable /// Takes the single-instance slot. When the application is already running, asks /// it to show itself and returns false — the caller is left to exit. /// - public bool TryAcquire() + public bool TryAcquire() => TryAcquire(showRunningInstance: true); + + /// + /// The same, with a say in what is to happen to the application already running. + /// + /// + /// Whether the running application is to be brought up. A launch by Windows + /// itself passes false: it was not asked for a window, and the + /// application already in the tray is answer enough. + /// + public bool TryAcquire(bool showRunningInstance) { _mutex = new Mutex(initiallyOwned: false, _mutexName); @@ -73,8 +83,12 @@ public sealed class SingleInstanceGate : IDisposable if (!_isOwner) { - ForegroundPermissionNative.GrantToAnyProcess(); - _activationRequest.Set(); + if (showRunningInstance) + { + ForegroundPermissionNative.GrantToAnyProcess(); + _activationRequest.Set(); + } + return false; } diff --git a/CursorLang/Services/StartupLaunch.cs b/CursorLang/Services/StartupLaunch.cs new file mode 100644 index 0000000..b7b0fba --- /dev/null +++ b/CursorLang/Services/StartupLaunch.cs @@ -0,0 +1,54 @@ +using System.Runtime.InteropServices; +using CursorLang.Interop; +using Windows.ApplicationModel; +using Windows.ApplicationModel.Activation; + +namespace CursorLang.Services; + +/// +/// Whether Windows started the application by itself. +/// +/// +/// A launch of its own accord ends up in the tray without a window: the user asked +/// for the application to be there when they sign in, not for a window to greet them +/// every morning. A launch by the user is another matter — the window is what they +/// clicked for. +/// +/// The two builds tell the launches apart differently. A build in a folder is +/// started from the registry, and the command written there carries an argument of +/// its own — see . A package has no say in its command +/// line, and Windows is asked about the activation instead. +/// +internal static class StartupLaunch +{ + /// What the registry entry adds to the path of the application. + internal const string Argument = "--startup"; + + /// Whether this launch is the doing of Windows rather than of the user. + internal static bool IsAutomatic(IReadOnlyList arguments) => + HasArgument(arguments) || IsStartupActivation(); + + /// The command line says the launch comes from the startup entry. + internal static bool HasArgument(IReadOnlyList arguments) => + arguments.Any(argument => string.Equals(argument, Argument, StringComparison.OrdinalIgnoreCase)); + + private static bool IsStartupActivation() + { + if (!PackageIdentityNative.IsPackaged) + { + return false; + } + + try + { + return AppInstance.GetActivatedEventArgs() is { Kind: ActivationKind.StartupTask }; + } + catch (Exception e) when (e is COMException or InvalidOperationException or NotSupportedException) + { + // Windows has nothing to say about the activation. A window shown when it + // was not asked for is a smaller mishap than an application that hides + // when the user has just started it + return false; + } + } +} diff --git a/CursorLang/Services/TrayIcon.cs b/CursorLang/Services/TrayIcon.cs new file mode 100644 index 0000000..fa44440 --- /dev/null +++ b/CursorLang/Services/TrayIcon.cs @@ -0,0 +1,181 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Data; +using System.Windows.Interop; +using CursorLang.Interop; + +namespace CursorLang.Services; + +/// +/// The icon in the notification area: a way to bring the settings window back and +/// the only way to quit the application. +/// +/// +/// The icon belongs to a window rather than to a process, and the application has no +/// window that is always there — the settings window is hidden most of the time. +/// So a window of its own is created here and never shown: it exists to receive the +/// messages of the icon. +/// +/// The menu is a WPF one rather than a system one on purpose: that way it obeys the +/// theme and the language chosen in the settings, like the rest of the interface. +/// +public sealed class TrayIcon : ITrayIcon, IDisposable +{ + /// Windows shows it under the pointer. The name of the app says enough. + private const string Tooltip = "CursorLang"; + + /// Distinguishes the icon among those of the same window; we have one. + private const int IconId = 1; + + private readonly ILocalizationService _localization; + + private HwndSource? _source; + private ContextMenu? _menu; + private IntPtr _icon; + private bool _isInstalled; + + public TrayIcon(ILocalizationService localization) => _localization = localization; + + public event EventHandler? OpenRequested; + + public event EventHandler? ExitRequested; + + public bool Install() + { + if (_isInstalled) + { + return true; + } + + _source ??= CreateMessageWindow(); + _icon = TrayIconNative.LoadApplicationIcon(); + + _isInstalled = TrayIconNative.Add(_source.Handle, IconId, _icon, Tooltip); + + return _isInstalled; + } + + public void Dispose() + { + if (_source is null) + { + return; + } + + if (_isInstalled) + { + TrayIconNative.Remove(_source.Handle, IconId); + _isInstalled = false; + } + + TrayIconNative.ReleaseIcon(_icon); + _icon = IntPtr.Zero; + + _source.RemoveHook(OnMessage); + _source.Dispose(); + _source = null; + } + + /// + /// The menu of the icon. Built on the first ask: the application starts more + /// often than the user reaches for the menu. + /// + internal ContextMenu Menu => _menu ??= CreateMenu(); + + private HwndSource CreateMessageWindow() + { + // A window with no WS_VISIBLE shows nowhere, yet is a window in every other + // way. A message-only window would do as well were it not for the news of + // Explorer restarting: that one is broadcast, and broadcasts pass such + // windows by + var parameters = new HwndSourceParameters("CursorLang tray") + { + WindowStyle = unchecked((int)0x80000000), // WS_POPUP + Width = 0, + Height = 0, + }; + + var source = new HwndSource(parameters); + source.AddHook(OnMessage); + + return source; + } + + private IntPtr OnMessage(IntPtr window, int message, IntPtr wParam, IntPtr lParam, ref bool handled) + { + // Explorer has restarted and taken every icon down with it + if (message == TrayIconNative.TaskbarCreatedMessage && _isInstalled) + { + TrayIconNative.Add(window, IconId, _icon, Tooltip); + handled = true; + + return IntPtr.Zero; + } + + if (message != TrayIconNative.CallbackMessage) + { + return IntPtr.Zero; + } + + switch (TrayIconNative.NotificationOf(lParam)) + { + case TrayIconNative.SelectNotification: + case TrayIconNative.KeySelectNotification: + OpenRequested?.Invoke(this, EventArgs.Empty); + handled = true; + break; + + case TrayIconNative.ContextMenuNotification: + ShowMenu(); + handled = true; + break; + } + + return IntPtr.Zero; + } + + /// + /// Raises the menu of the icon where the pointer is. + /// + internal void ShowMenu() + { + if (_source is not null) + { + TrayIconNative.BringToForeground(_source.Handle); + } + + // The menu goes where the pointer is. Windows does report the point of the + // click, but in physical pixels, while WPF places a popup in its own units — + // and the two only agree on a monitor scaled at 100% + Menu.Placement = PlacementMode.MousePoint; + Menu.IsOpen = true; + } + + private ContextMenu CreateMenu() + { + var menu = new ContextMenu(); + + menu.Items.Add(Item("TrayMenuSettings", (_, _) => OpenRequested?.Invoke(this, EventArgs.Empty))); + menu.Items.Add(new Separator()); + menu.Items.Add(Item("TrayMenuExit", (_, _) => ExitRequested?.Invoke(this, EventArgs.Empty))); + + return menu; + } + + // The caption is bound rather than assigned: the language is changed in the + // settings without a restart, and the menu is built once + private MenuItem Item(string key, RoutedEventHandler onClick) + { + var item = new MenuItem(); + + item.SetBinding(HeaderedItemsControl.HeaderProperty, new Binding($"[{key}]") + { + Source = _localization, + }); + + item.Click += onClick; + + return item; + } +} diff --git a/CursorLang/Themes/Controls.xaml b/CursorLang/Themes/Controls.xaml index 2379e1c..6cdb1ef 100644 --- a/CursorLang/Themes/Controls.xaml +++ b/CursorLang/Themes/Controls.xaml @@ -455,6 +455,66 @@ + + + + + + + +