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
- 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 @@
+
+
+
+
+
+
+
+