diff --git a/.gitea/workflows/pull-request.yml b/.gitea/workflows/pull-request.yml
index 9786e5d..8219167 100644
--- a/.gitea/workflows/pull-request.yml
+++ b/.gitea/workflows/pull-request.yml
@@ -79,4 +79,4 @@ jobs:
--configuration Release
--no-build
--nologo
- --settings CursorLang.Tests/coverage.runsettings
+ --settings coverage.runsettings
diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml
index 26fcac1..752ab25 100644
--- a/.gitea/workflows/release.yml
+++ b/.gitea/workflows/release.yml
@@ -92,7 +92,7 @@ jobs:
--configuration Release
--no-build
--nologo
- --settings CursorLang.Tests/coverage.runsettings
+ --settings coverage.runsettings
# The package comes out as Partner Center wants it — the Store puts its own
# signature on it. The identity comes from repository variables and falls
diff --git a/CursorLang.Agent.Tests/CursorLang.Agent.Tests.csproj b/CursorLang.Agent.Tests/CursorLang.Agent.Tests.csproj
new file mode 100644
index 0000000..f9f66f9
--- /dev/null
+++ b/CursorLang.Agent.Tests/CursorLang.Agent.Tests.csproj
@@ -0,0 +1,43 @@
+
+
+
+ net10.0-windows10.0.19041.0
+ 10.0.17763.0
+ enable
+ enable
+ Exe
+ AnyCPU
+ true
+ true
+ $(NoWarn);CS1591
+ false
+ true
+ CursorLang.Agent.Tests
+ CursorLang.Agent.Tests
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <_Parameter1>CursorLangExecutable
+ <_Parameter2>$(MSBuildThisFileDirectory)..\CursorLang.Agent\bin\$(Configuration)\$(TargetFramework)\CursorLang.exe
+
+
+
+
diff --git a/CursorLang.Tests/EndToEndTests.cs b/CursorLang.Agent.Tests/EndToEndTests.cs
similarity index 50%
rename from CursorLang.Tests/EndToEndTests.cs
rename to CursorLang.Agent.Tests/EndToEndTests.cs
index c8e3631..d79e5b2 100644
--- a/CursorLang.Tests/EndToEndTests.cs
+++ b/CursorLang.Agent.Tests/EndToEndTests.cs
@@ -1,27 +1,27 @@
using System.Diagnostics;
-using System.IO;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Text;
-using CursorLang.Services;
+using System.Text.RegularExpressions;
+using CursorLang.Core.Services;
-namespace CursorLang.Tests;
+namespace CursorLang.Agent.Tests;
///
-/// The application as a whole: the start, the single instance and the exit.
+/// The application as a whole: the agent starting, the settings window it opens, the
+/// single instance and the exit.
///
///
-/// The application cannot be built inside the tests — it raises windows and
-/// takes the place of the single instance for the whole session. It is
-/// therefore started the way the user starts it: as a separate process.
+/// None of this can be built inside the tests — the agent installs a system hook and
+/// takes the place of the single instance for the whole session. It is therefore
+/// started the way the user starts it: as a separate process.
///
-/// If the application is already running in this session, the checks skip
-/// themselves: meddling with someone else's running instance is not their business.
-///
-/// They skip themselves where there is no desktop to show a window on either — on a
-/// build agent living as a Windows service, for one.
+/// If the application is already running in this session, the checks skip themselves:
+/// meddling with someone else's running instance is not their business. They skip
+/// themselves where there is no desktop to show a window on either — on a build agent
+/// living as a Windows service, for one.
///
-public sealed class EndToEndTests
+public sealed partial class EndToEndTests
{
private static readonly TimeSpan StartTimeout = TimeSpan.FromSeconds(30);
private static readonly TimeSpan ExitTimeout = TimeSpan.FromSeconds(15);
@@ -29,12 +29,48 @@ public sealed class EndToEndTests
/// How long "the application went on working" is worth watching for.
private static readonly TimeSpan StayTimeout = TimeSpan.FromSeconds(3);
+ ///
+ /// The whole point of the background process, as a test rather than as a promise.
+ ///
+ ///
+ /// The agent must not load the WPF rendering stack. It is checked on the running
+ /// process, not on its references: a reference costs nothing, a load costs the
+ /// hundred megabytes the split exists to avoid.
+ ///
+ /// UI Automation and the assemblies behind it — WindowsBase and PresentationCore —
+ /// are deliberately not in the pattern: the caret fallback pulls them in on purpose
+ /// and only when it runs. What must never appear is the renderer itself.
+ ///
[Fact]
- public void The_application_starts_and_shows_the_settings_window()
+ public void The_agent_runs_without_the_rendering_stack()
+ {
+ using Launch launch = Launch.Start(StartupLaunch.Argument);
+
+ Assert.False(launch.Process.WaitForExit(StayTimeout), "the agent ended by itself");
+ launch.Process.Refresh();
+
+ var loaded = launch.Process.Modules
+ .Cast()
+ .Select(module => module.ModuleName)
+ .Where(name => RenderingStack().IsMatch(name))
+ .ToList();
+
+ Assert.True(loaded.Count == 0, $"the agent loaded {string.Join(", ", loaded)}");
+ }
+
+ [GeneratedRegex("PresentationFramework|wpfgfx|milcore|PresentationNative", RegexOptions.IgnoreCase)]
+ private static partial Regex RenderingStack();
+
+ ///
+ /// Started by the user, the application shows the settings window — which lives in
+ /// a process of its own and is started by the agent.
+ ///
+ [Fact]
+ public void A_launch_by_the_user_opens_the_settings_window()
{
using Launch launch = Launch.Start();
- Assert.NotEqual(IntPtr.Zero, launch.WaitForWindow());
+ Assert.NotEqual(IntPtr.Zero, launch.WaitForSettingsWindow());
Assert.False(launch.Process.HasExited);
}
@@ -48,18 +84,16 @@ public sealed class EndToEndTests
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);
+ Assert.False(launch.Process.WaitForExit(StayTimeout), "the agent ended by itself");
+ Assert.Empty(Launch.SettingsProcesses());
}
- // A second run raises no second window but shows the window of the running one
+ // A second run raises no second agent but asks the running one for the window
[Fact]
public void The_second_run_ends_by_itself()
{
- using Launch launch = Launch.Start();
- launch.WaitForWindow();
+ using Launch launch = Launch.Start(StartupLaunch.Argument);
+ Assert.False(launch.Process.WaitForExit(StayTimeout), "the agent ended by itself");
using Process second = Launch.StartProcess();
@@ -70,19 +104,33 @@ 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
+ ///
+ /// Closing the settings window ends that process and leaves the agent alone.
+ ///
+ ///
+ /// This is the behaviour the split was for. The window used to hide itself into the
+ /// tray, because closing it would have thrown away a visual tree the background half
+ /// was still using; now there is nothing shared to throw away, and the memory the
+ /// window took goes back to the system.
+ ///
[Fact]
- public void Closing_the_window_leaves_the_application_in_the_tray()
+ public void Closing_the_settings_window_leaves_the_agent_running()
{
using Launch launch = Launch.Start();
- launch.WaitForWindow();
+ launch.WaitForSettingsWindow();
- Assert.True(launch.Process.CloseMainWindow(), "the window did not accept the request to close");
- Assert.False(launch.Process.WaitForExit(StayTimeout), "the application ended together with its window");
+ Process settings = Launch.SettingsProcesses().Single();
+ try
+ {
+ Assert.True(settings.CloseMainWindow(), "the window did not accept the request to close");
+ Assert.True(settings.WaitForExit(ExitTimeout), "the settings window outlived its own closing");
+ }
+ finally
+ {
+ settings.Dispose();
+ }
- launch.Process.Refresh();
- Assert.Equal(IntPtr.Zero, launch.Process.MainWindowHandle);
+ Assert.False(launch.Process.HasExited);
}
/// A started application that shuts down together with the check.
@@ -102,7 +150,7 @@ public sealed class EndToEndTests
internal Process Process { get; }
- /// Starts the application first — making sure the place is free.
+ /// Starts the agent first — making sure the place is free.
internal static Launch Start(params string[] arguments)
{
if (!HasInteractiveDesktop())
@@ -110,7 +158,7 @@ public sealed class EndToEndTests
Assert.Skip("There is no interactive desktop here — the application has nowhere to show its window");
}
- if (Process.GetProcessesByName("CursorLang").Length > 0)
+ if (Process.GetProcessesByName("CursorLang").Length > 0 || SettingsProcesses().Length > 0)
{
Assert.Skip("The application is already running — this check keeps out of someone else's run");
}
@@ -118,7 +166,7 @@ public sealed class EndToEndTests
return new Launch(StartProcess(arguments));
}
- /// Starts the application the way the user — or Windows — does.
+ /// Starts the agent the way the user — or Windows — does.
internal static Process StartProcess(params string[] arguments)
{
string path = ExecutablePath();
@@ -138,11 +186,14 @@ public sealed class EndToEndTests
return Process.Start(start)!;
}
+ /// The settings window processes running right now, if any.
+ internal static Process[] SettingsProcesses() => Process.GetProcessesByName("CursorLang.Settings");
+
///
- /// Waits for the settings window: by the time it appears the application
- /// has raised its whole cast.
+ /// Waits for the settings window. It belongs to another process now, so the
+ /// wait is for that process to appear and put a window on the screen.
///
- internal IntPtr WaitForWindow()
+ internal IntPtr WaitForSettingsWindow()
{
DateTime deadline = DateTime.UtcNow + StartTimeout;
@@ -152,12 +203,19 @@ public sealed class EndToEndTests
if (Process.HasExited)
{
- Assert.Fail($"The application exited while starting with code {Process.ExitCode}");
+ Assert.Fail($"The agent exited while starting with code {Process.ExitCode}");
}
- if (Process.MainWindowHandle != IntPtr.Zero)
+ foreach (Process settings in SettingsProcesses())
{
- return Process.MainWindowHandle;
+ settings.Refresh();
+ IntPtr window = settings.MainWindowHandle;
+ settings.Dispose();
+
+ if (window != IntPtr.Zero)
+ {
+ return window;
+ }
}
Thread.Sleep(100);
@@ -197,16 +255,25 @@ public sealed class EndToEndTests
}
public void Dispose()
+ {
+ foreach (Process settings in SettingsProcesses())
+ {
+ Kill(settings);
+ }
+
+ Kill(Process);
+ }
+
+ // The exit lives in a tray menu no test can reach, and the settings are saved
+ // as they change, so nothing is lost by ending the processes outright
+ private static void Kill(Process process)
{
try
{
- if (!Process.HasExited)
+ if (!process.HasExited)
{
- // 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);
+ process.Kill(entireProcessTree: true);
+ process.WaitForExit(ExitTimeout);
}
}
catch (InvalidOperationException)
@@ -215,7 +282,7 @@ public sealed class EndToEndTests
}
finally
{
- Process.Dispose();
+ process.Dispose();
}
}
}
diff --git a/CursorLang.Tests/Services/CapsLockHotkeyServiceTests.cs b/CursorLang.Agent.Tests/Services/CapsLockHotkeyServiceTests.cs
similarity index 61%
rename from CursorLang.Tests/Services/CapsLockHotkeyServiceTests.cs
rename to CursorLang.Agent.Tests/Services/CapsLockHotkeyServiceTests.cs
index 3840daf..8d9660b 100644
--- a/CursorLang.Tests/Services/CapsLockHotkeyServiceTests.cs
+++ b/CursorLang.Agent.Tests/Services/CapsLockHotkeyServiceTests.cs
@@ -1,9 +1,9 @@
using System.Collections.Concurrent;
-using CursorLang.Models;
-using CursorLang.Services;
-using CursorLang.Tests.Infrastructure;
+using CursorLang.Agent.Services;
+using CursorLang.Core.Models;
+using CursorLang.Tests.Shared;
-namespace CursorLang.Tests.Services;
+namespace CursorLang.Agent.Tests.Services;
///
/// Making sense of Caps Lock presses: a short one differs from a long one only
@@ -19,8 +19,8 @@ public sealed class CapsLockHotkeyServiceTests
{
using var harness = Harness.Create(holdMilliseconds: 10_000);
- Assert.True(Sta.Run(() => harness.Service.HandleKeyEvent(CapsLock, isKeyDown: true)));
- Assert.True(Sta.Run(() => harness.Service.HandleKeyEvent(CapsLock, isKeyDown: false)));
+ Assert.True(Pump.Run(() => harness.Service.HandleKeyEvent(CapsLock, isKeyDown: true)));
+ Assert.True(Pump.Run(() => harness.Service.HandleKeyEvent(CapsLock, isKeyDown: false)));
}
[Fact]
@@ -28,8 +28,8 @@ public sealed class CapsLockHotkeyServiceTests
{
using var harness = Harness.Create(holdMilliseconds: 10_000);
- Assert.False(Sta.Run(() => harness.Service.HandleKeyEvent(LetterA, isKeyDown: true)));
- Assert.False(Sta.Run(() => harness.Service.HandleKeyEvent(LetterA, isKeyDown: false)));
+ Assert.False(Pump.Run(() => harness.Service.HandleKeyEvent(LetterA, isKeyDown: true)));
+ Assert.False(Pump.Run(() => harness.Service.HandleKeyEvent(LetterA, isKeyDown: false)));
Assert.Empty(harness.Events);
}
@@ -41,7 +41,7 @@ public sealed class CapsLockHotkeyServiceTests
harness.Press();
harness.Release();
- Sta.WaitFor(() => harness.Events.Count == 1, "the short press event arrived");
+ Pump.WaitFor(() => harness.Events.Count == 1, "the short press event arrived");
Assert.Equal(["tap"], harness.Events);
}
@@ -52,7 +52,7 @@ public sealed class CapsLockHotkeyServiceTests
harness.Press();
- Sta.WaitFor(() => harness.Events.Contains("hold-start"), "the hold threshold was passed");
+ Pump.WaitFor(() => harness.Events.Contains("hold-start"), "the hold threshold was passed");
Assert.Equal(["hold-start"], harness.Events);
}
@@ -62,11 +62,11 @@ public sealed class CapsLockHotkeyServiceTests
using var harness = Harness.Create(holdMilliseconds: 20);
harness.Press();
- Sta.WaitFor(() => harness.Events.Contains("hold-start"), "the hold threshold was passed");
+ Pump.WaitFor(() => harness.Events.Contains("hold-start"), "the hold threshold was passed");
harness.Release();
- Sta.WaitFor(() => harness.Events.Count == 2, "the end of the hold arrived");
+ Pump.WaitFor(() => harness.Events.Count == 2, "the end of the hold arrived");
Assert.Equal(["hold-start", "hold-end"], harness.Events);
}
@@ -80,11 +80,11 @@ public sealed class CapsLockHotkeyServiceTests
for (int i = 0; i < 10; i++)
{
- Sta.Pause(TimeSpan.FromMilliseconds(10));
+ Pump.Pause(TimeSpan.FromMilliseconds(10));
harness.Press();
}
- Sta.WaitFor(() => harness.Events.Contains("hold-start"), "the hold was announced despite the repeats");
+ Pump.WaitFor(() => harness.Events.Contains("hold-start"), "the hold was announced despite the repeats");
}
[Fact]
@@ -95,7 +95,7 @@ public sealed class CapsLockHotkeyServiceTests
harness.Press();
harness.Release();
- Sta.Pause(TimeSpan.FromMilliseconds(400));
+ Pump.Pause(TimeSpan.FromMilliseconds(400));
Assert.Equal(["tap"], harness.Events);
}
@@ -108,11 +108,11 @@ public sealed class CapsLockHotkeyServiceTests
using var harness = Harness.Create(holdMilliseconds: 20);
harness.Press();
- Sta.WaitFor(() => harness.Events.Contains("hold-start"), "the hold threshold was passed");
+ Pump.WaitFor(() => harness.Events.Contains("hold-start"), "the hold threshold was passed");
- Sta.Run(harness.Service.Stop);
+ Pump.Run(harness.Service.Stop);
- Sta.WaitFor(() => harness.Events.Count == 2, "the end of the hold was announced");
+ Pump.WaitFor(() => harness.Events.Count == 2, "the end of the hold was announced");
Assert.Equal(["hold-start", "hold-end"], harness.Events);
}
@@ -122,8 +122,8 @@ public sealed class CapsLockHotkeyServiceTests
using var harness = Harness.Create(holdMilliseconds: 10_000);
harness.Press();
- Sta.Run(harness.Service.Stop);
- Sta.Pause(TimeSpan.FromMilliseconds(50));
+ Pump.Run(harness.Service.Stop);
+ Pump.Pause(TimeSpan.FromMilliseconds(50));
Assert.Empty(harness.Events);
}
@@ -135,9 +135,9 @@ public sealed class CapsLockHotkeyServiceTests
using var harness = Harness.Create(holdMilliseconds: 40);
harness.Press();
- Sta.Run(harness.Service.Stop);
+ Pump.Run(harness.Service.Stop);
- Sta.Pause(TimeSpan.FromMilliseconds(120));
+ Pump.Pause(TimeSpan.FromMilliseconds(120));
Assert.Empty(harness.Events);
}
@@ -148,12 +148,12 @@ public sealed class CapsLockHotkeyServiceTests
using var harness = Harness.Create(holdMilliseconds: 10_000);
harness.Press();
- Sta.Run(harness.Service.Stop);
+ Pump.Run(harness.Service.Stop);
harness.Press();
harness.Release();
- Sta.WaitFor(() => harness.Events.Count == 1, "a short press after the restart");
+ Pump.WaitFor(() => harness.Events.Count == 1, "a short press after the restart");
Assert.Equal(["tap"], harness.Events);
}
@@ -164,10 +164,10 @@ public sealed class CapsLockHotkeyServiceTests
var harness = Harness.Create(holdMilliseconds: 20);
harness.Press();
- Sta.WaitFor(() => harness.Events.Contains("hold-start"), "the hold threshold was passed");
+ Pump.WaitFor(() => harness.Events.Contains("hold-start"), "the hold threshold was passed");
- Sta.Run(harness.Service.Dispose);
- Sta.Pause(TimeSpan.FromMilliseconds(80));
+ Pump.Run(harness.Service.Dispose);
+ Pump.Pause(TimeSpan.FromMilliseconds(80));
Assert.Equal(["hold-start"], harness.Events);
}
@@ -179,12 +179,12 @@ public sealed class CapsLockHotkeyServiceTests
harness.Press();
harness.Release();
- Sta.WaitFor(() => harness.Events.Count == 1, "the first press counted as short");
+ Pump.WaitFor(() => harness.Events.Count == 1, "the first press counted as short");
harness.Settings.CapsLockHoldMilliseconds = 20;
harness.Press();
- Sta.WaitFor(() => harness.Events.Contains("hold-start"), "the new threshold took effect");
+ Pump.WaitFor(() => harness.Events.Contains("hold-start"), "the new threshold took effect");
}
[Fact]
@@ -200,7 +200,7 @@ public sealed class CapsLockHotkeyServiceTests
public void The_interception_is_installed_and_removed()
{
using var harness = Harness.Create(holdMilliseconds: 10_000);
- Sta.Run(() =>
+ Pump.Run(() =>
{
harness.Service.Start();
Assert.True(harness.Service.IsRunning);
@@ -223,7 +223,7 @@ public sealed class CapsLockHotkeyServiceTests
{
var harness = Harness.Create(holdMilliseconds: 10_000);
- Sta.Run(() =>
+ Pump.Run(() =>
{
harness.Service.Start();
harness.Service.Dispose();
@@ -232,6 +232,32 @@ public sealed class CapsLockHotkeyServiceTests
});
}
+ ///
+ /// A hold is never announced for a key that has already been let go.
+ ///
+ ///
+ /// The countdown started by the press can still be delivered just after the
+ /// release: Windows does not withdraw a WM_TIMER it has already posted. Taken at
+ /// face value it turned a tap into a hold — the popup came up showing the layout
+ /// the tap was about to change away from, and the switch followed it.
+ ///
+ /// The tick is driven straight in here rather than waited for, because the point is
+ /// the one ordering a real clock will not reproduce on demand.
+ ///
+ [Fact]
+ public void A_hold_is_not_announced_after_the_key_has_been_released()
+ {
+ using var harness = Harness.Create(holdMilliseconds: 10_000);
+
+ harness.Press();
+ harness.Release();
+
+ harness.ForceHoldTick();
+ Pump.Drain();
+
+ Assert.Equal(["tap"], harness.Events);
+ }
+
///
/// The service together with its settings and the list of events that happened.
///
@@ -254,8 +280,10 @@ public sealed class CapsLockHotkeyServiceTests
{
var settings = new AppSettings { CapsLockHoldMilliseconds = holdMilliseconds };
- // The service remembers the dispatcher of the thread it was created on
- CapsLockHotkeyService service = Sta.Run(() => new CapsLockHotkeyService(settings));
+ // Events reach their subscribers the way they do in the agent: posted back
+ // to the message loop, after the hook procedure has returned
+ CapsLockHotkeyService service =
+ Pump.Run(() => new CapsLockHotkeyService(settings, Pump.Post));
var harness = new Harness(service, settings);
service.Tapped += (_, _) => harness.Events.Enqueue("tap");
@@ -265,10 +293,13 @@ public sealed class CapsLockHotkeyServiceTests
return harness;
}
- internal void Press() => Sta.Run(() => Service.HandleKeyEvent(CapsLock, isKeyDown: true));
+ internal void Press() => Pump.Run(() => Service.HandleKeyEvent(CapsLock, isKeyDown: true));
- internal void Release() => Sta.Run(() => Service.HandleKeyEvent(CapsLock, isKeyDown: false));
+ internal void Release() => Pump.Run(() => Service.HandleKeyEvent(CapsLock, isKeyDown: false));
- public void Dispose() => Sta.Run(Service.Dispose);
+ /// Delivers the hold countdown by hand, the way a late WM_TIMER does.
+ internal void ForceHoldTick() => Pump.Run(Service.HandleHoldElapsed);
+
+ public void Dispose() => Pump.Run(Service.Dispose);
}
}
diff --git a/CursorLang.Tests/Services/LayoutPopupServiceTests.cs b/CursorLang.Agent.Tests/Services/LayoutPopupServiceTests.cs
similarity index 59%
rename from CursorLang.Tests/Services/LayoutPopupServiceTests.cs
rename to CursorLang.Agent.Tests/Services/LayoutPopupServiceTests.cs
index 73c4970..84b250e 100644
--- a/CursorLang.Tests/Services/LayoutPopupServiceTests.cs
+++ b/CursorLang.Agent.Tests/Services/LayoutPopupServiceTests.cs
@@ -1,13 +1,12 @@
-using CursorLang.Models;
-using CursorLang.Services;
-using CursorLang.Tests.Infrastructure;
-using CursorLang.ViewModels;
+using CursorLang.Agent.Services;
+using CursorLang.Core.Models;
+using CursorLang.Tests.Shared;
-namespace CursorLang.Tests.Services;
+namespace CursorLang.Agent.Tests.Services;
///
-/// The lifetime of the tooltip. Its timer lives on the interface thread,
-/// so everything happens there as well.
+/// The lifetime of the tooltip. Its timer ticks on the message loop,
+/// so everything happens on the pump thread as well.
///
public sealed class LayoutPopupServiceTests
{
@@ -19,15 +18,14 @@ public sealed class LayoutPopupServiceTests
{
var window = new FakeLayoutPopupWindow();
var settings = new AppSettings { DurationMilliseconds = 10_000 };
- var viewModel = new LayoutPopupViewModel(settings);
- Sta.Run(() =>
+ Pump.Run(() =>
{
- using var service = new LayoutPopupService(window, viewModel, settings);
+ using var service = new LayoutPopupService(window, settings);
service.Show(Russian);
});
- Assert.Equal("RU", viewModel.ShortName);
+ Assert.Equal("RU", window.ShownText);
Assert.Equal(1, window.ShowCalls);
}
@@ -36,15 +34,14 @@ public sealed class LayoutPopupServiceTests
{
var window = new FakeLayoutPopupWindow();
var settings = new AppSettings { DurationMilliseconds = 30 };
- var viewModel = new LayoutPopupViewModel(settings);
- Sta.Run(() =>
+ Pump.Run(() =>
{
- using var service = new LayoutPopupService(window, viewModel, settings);
+ using var service = new LayoutPopupService(window, settings);
service.Show(Russian);
Assert.Equal(0, window.HideCalls);
- Sta.WaitFor(() => window.HideCalls == 1, "the tooltip hid itself");
+ Pump.WaitFor(() => window.HideCalls == 1, "the tooltip hid itself");
});
}
@@ -54,17 +51,16 @@ public sealed class LayoutPopupServiceTests
{
var window = new FakeLayoutPopupWindow();
var settings = new AppSettings { DurationMilliseconds = 10_000 };
- var viewModel = new LayoutPopupViewModel(settings);
- Sta.Run(() =>
+ Pump.Run(() =>
{
- using var service = new LayoutPopupService(window, viewModel, settings);
+ using var service = new LayoutPopupService(window, settings);
service.Show(Russian);
settings.DurationMilliseconds = 30;
service.Show(English);
- Sta.WaitFor(() => window.HideCalls == 1, "the tooltip hid by the new duration");
+ Pump.WaitFor(() => window.HideCalls == 1, "the tooltip hid by the new duration");
});
}
@@ -74,20 +70,19 @@ public sealed class LayoutPopupServiceTests
{
var window = new FakeLayoutPopupWindow();
var settings = new AppSettings { DurationMilliseconds = 60 };
- var viewModel = new LayoutPopupViewModel(settings);
- Sta.Run(() =>
+ Pump.Run(() =>
{
- using var service = new LayoutPopupService(window, viewModel, settings);
+ using var service = new LayoutPopupService(window, settings);
for (int i = 0; i < 5; i++)
{
service.Show(i % 2 == 0 ? Russian : English);
- Sta.Pause(TimeSpan.FromMilliseconds(20));
+ Pump.Pause(TimeSpan.FromMilliseconds(20));
Assert.Equal(0, window.HideCalls);
}
- Sta.WaitFor(() => window.HideCalls == 1, "the tooltip hid after the last show");
+ Pump.WaitFor(() => window.HideCalls == 1, "the tooltip hid after the last show");
});
}
@@ -96,14 +91,13 @@ public sealed class LayoutPopupServiceTests
{
var window = new FakeLayoutPopupWindow();
var settings = new AppSettings { DurationMilliseconds = 20 };
- var viewModel = new LayoutPopupViewModel(settings);
- Sta.Run(() =>
+ Pump.Run(() =>
{
- using var service = new LayoutPopupService(window, viewModel, settings);
+ using var service = new LayoutPopupService(window, settings);
service.ShowUntilHidden(Russian);
- Sta.Pause(TimeSpan.FromMilliseconds(80));
+ Pump.Pause(TimeSpan.FromMilliseconds(80));
Assert.Equal(1, window.ShowCalls);
Assert.Equal(0, window.HideCalls);
@@ -119,18 +113,17 @@ public sealed class LayoutPopupServiceTests
{
var window = new FakeLayoutPopupWindow();
var settings = new AppSettings { DurationMilliseconds = 30 };
- var viewModel = new LayoutPopupViewModel(settings);
- Sta.Run(() =>
+ Pump.Run(() =>
{
- using var service = new LayoutPopupService(window, viewModel, settings);
+ using var service = new LayoutPopupService(window, settings);
service.Show(Russian);
service.ShowUntilHidden(English);
- Sta.Pause(TimeSpan.FromMilliseconds(100));
+ Pump.Pause(TimeSpan.FromMilliseconds(100));
Assert.Equal(0, window.HideCalls);
- Assert.Equal("EN", viewModel.ShortName);
+ Assert.Equal("EN", window.ShownText);
});
}
@@ -139,15 +132,14 @@ public sealed class LayoutPopupServiceTests
{
var window = new FakeLayoutPopupWindow();
var settings = new AppSettings { DurationMilliseconds = 30 };
- var viewModel = new LayoutPopupViewModel(settings);
- Sta.Run(() =>
+ Pump.Run(() =>
{
- using var service = new LayoutPopupService(window, viewModel, settings);
+ using var service = new LayoutPopupService(window, settings);
service.Show(Russian);
service.Hide();
- Sta.Pause(TimeSpan.FromMilliseconds(100));
+ Pump.Pause(TimeSpan.FromMilliseconds(100));
// There must be no second hide from the timer
Assert.Equal(1, window.HideCalls);
@@ -159,15 +151,14 @@ public sealed class LayoutPopupServiceTests
{
var window = new FakeLayoutPopupWindow();
var settings = new AppSettings();
- var viewModel = new LayoutPopupViewModel(settings);
- Sta.Run(() =>
+ Pump.Run(() =>
{
- var service = new LayoutPopupService(window, viewModel, settings);
+ var service = new LayoutPopupService(window, settings);
service.Show(Russian);
service.Dispose();
- Sta.Pause(TimeSpan.FromMilliseconds(50));
+ Pump.Pause(TimeSpan.FromMilliseconds(50));
});
Assert.Equal(1, window.CloseCalls);
@@ -178,15 +169,14 @@ public sealed class LayoutPopupServiceTests
{
var window = new FakeLayoutPopupWindow();
var settings = new AppSettings { DurationMilliseconds = 20 };
- var viewModel = new LayoutPopupViewModel(settings);
- Sta.Run(() =>
+ Pump.Run(() =>
{
- var service = new LayoutPopupService(window, viewModel, settings);
+ var service = new LayoutPopupService(window, settings);
service.Show(Russian);
service.Dispose();
- Sta.Pause(TimeSpan.FromMilliseconds(80));
+ Pump.Pause(TimeSpan.FromMilliseconds(80));
Assert.Equal(0, window.HideCalls);
});
@@ -197,14 +187,13 @@ public sealed class LayoutPopupServiceTests
{
var window = new FakeLayoutPopupWindow();
var settings = new AppSettings { DurationMilliseconds = 20 };
- var viewModel = new LayoutPopupViewModel(settings);
- Sta.Run(() =>
+ Pump.Run(() =>
{
- using var service = new LayoutPopupService(window, viewModel, settings);
+ using var service = new LayoutPopupService(window, settings);
service.Show(Russian);
- Sta.WaitFor(() => window.HideCalls == 1, "the tooltip hid itself");
+ Pump.WaitFor(() => window.HideCalls == 1, "the tooltip hid itself");
Assert.Equal(["show", "hide"], window.Calls);
});
diff --git a/CursorLang.Agent.Tests/Windows/SettingsSignalTests.cs b/CursorLang.Agent.Tests/Windows/SettingsSignalTests.cs
new file mode 100644
index 0000000..47b0bab
--- /dev/null
+++ b/CursorLang.Agent.Tests/Windows/SettingsSignalTests.cs
@@ -0,0 +1,57 @@
+using CursorLang.Agent.Windows;
+using CursorLang.Core.Services;
+using CursorLang.Tests.Shared;
+
+namespace CursorLang.Agent.Tests.Windows;
+
+///
+/// The one thing the settings window says to the agent.
+///
+///
+/// Both halves of it live apart — the message and the window class name in Core, the
+/// window that answers in the agent — and nothing but a matching pair makes it work.
+/// A renamed class or a renamed message would leave the agent showing yesterday's
+/// settings until it is restarted, and nothing else would complain.
+///
+/// The agent's own window is used rather than a stand-in: what is being checked is
+/// that the window the agent really creates is the one the message reaches.
+///
+public sealed class SettingsSignalTests
+{
+ [Fact]
+ public void The_signal_reaches_the_agents_window()
+ {
+ var delivered = 0;
+
+ using AgentWindow window = Pump.Run(() =>
+ {
+ var created = new AgentWindow();
+ created.AddFilter((message, _, _) =>
+ {
+ if (message != SettingsSignal.Message)
+ {
+ return false;
+ }
+
+ delivered++;
+ return true;
+ });
+
+ return created;
+ });
+
+ Pump.Run(SettingsSignal.NotifyAgent);
+ Pump.Drain();
+
+ Assert.Equal(1, delivered);
+ }
+
+ // Nobody is listening, and that is a normal state of affairs: the settings window
+ // works perfectly well with no agent behind it
+ [Fact]
+ public void Signalling_with_no_agent_running_passes_without_consequence()
+ {
+ Pump.Run(SettingsSignal.NotifyAgent);
+ Pump.Drain();
+ }
+}
diff --git a/CursorLang.Agent/Agent.cs b/CursorLang.Agent/Agent.cs
new file mode 100644
index 0000000..6c95f70
--- /dev/null
+++ b/CursorLang.Agent/Agent.cs
@@ -0,0 +1,129 @@
+using System.ComponentModel;
+using CursorLang.Agent.Services;
+using CursorLang.Agent.Windows;
+using CursorLang.Core.Models;
+using CursorLang.Core.Services;
+using CursorLang.Core.Threading;
+
+namespace CursorLang.Agent;
+
+///
+/// The background half of CursorLang: the hook, the layout polling, the popup and the
+/// tray icon, with a Win32 message loop underneath and no WPF anywhere.
+///
+///
+/// The services are wired by hand rather than through a container, and that is a
+/// decision rather than an omission: the whole point of this process is how little it
+/// weighs, and a container is a megabyte of assembly and a graph of reflection on the
+/// way to the same object. There are ten of them and they are all listed here.
+///
+internal sealed class Agent : IDisposable
+{
+ private readonly SingleInstanceGate _gate;
+ private readonly AgentWindow _window;
+ private readonly SettingsService _settingsService;
+ private readonly AppSettings _settings;
+ private readonly LocalizationService _localization;
+ private readonly NativePopupWindow _popupWindow;
+ private readonly LayoutPopupService _popupService;
+ private readonly KeyboardLayoutService _layoutService;
+ private readonly CapsLockHotkeyService _hotkeyService;
+ private readonly LayoutNotificationCoordinator _notifications;
+ private readonly CapsLockSwitchCoordinator _capsLock;
+ private readonly NativeTrayIcon _tray;
+
+ internal Agent(SingleInstanceGate gate)
+ {
+ _gate = gate;
+
+ _window = new AgentWindow();
+ _window.AddFilter(OnWindowMessage);
+
+ _settingsService = new SettingsService();
+ _settings = _settingsService.Load();
+
+ _localization = new LocalizationService { CurrentLanguage = _settings.Language };
+ _settings.PropertyChanged += OnSettingsChanged;
+
+ _popupWindow = new NativePopupWindow(_settings);
+ _popupService = new LayoutPopupService(_popupWindow, _settings);
+
+ _layoutService = new KeyboardLayoutService(new KeyboardLayoutOptions());
+ _hotkeyService = new CapsLockHotkeyService(_settings, _window.Post);
+
+ _notifications = new LayoutNotificationCoordinator(_layoutService, _popupService);
+ _capsLock = new CapsLockSwitchCoordinator(_hotkeyService, _layoutService, _popupService, _settings);
+
+ _tray = new NativeTrayIcon(_window, _localization);
+ }
+
+ /// Starts everything and pumps messages until the user asks to quit.
+ internal int Run(bool automatic)
+ {
+ _gate.ActivationRequested += OnActivationRequested;
+
+ _tray.OpenRequested += OnOpenRequested;
+ _tray.ExitRequested += OnExitRequested;
+
+ bool hasTray = _tray.Install();
+
+ _notifications.Start();
+ _capsLock.Start();
+
+ if (!automatic || !hasTray)
+ {
+ SettingsLauncher.Open();
+ }
+
+ return MessageLoop.Run();
+ }
+
+ public void Dispose()
+ {
+ _gate.ActivationRequested -= OnActivationRequested;
+
+ _tray.OpenRequested -= OnOpenRequested;
+ _tray.ExitRequested -= OnExitRequested;
+
+ _settings.PropertyChanged -= OnSettingsChanged;
+
+ _capsLock.Dispose();
+ _notifications.Dispose();
+ _hotkeyService.Dispose();
+ _layoutService.Dispose();
+ _popupService.Dispose();
+ _settingsService.Dispose();
+ _tray.Dispose();
+ _window.Dispose();
+ }
+
+ // The settings window has written the file and says so. The write was a single
+ // atomic move, so there is nothing to wait for and nothing half-written to read
+ private bool OnWindowMessage(uint message, IntPtr wParam, IntPtr lParam)
+ {
+ if (message != SettingsSignal.Message)
+ {
+ return false;
+ }
+
+ _settingsService.Reload();
+ return true;
+ }
+
+ private void OnSettingsChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName == nameof(AppSettings.Language))
+ {
+ _localization.CurrentLanguage = _settings.Language;
+ }
+ }
+
+ // A second launch of the agent, from the Start menu for instance. The one already
+ // running answers the way the user expects a second launch to be answered
+ private void OnActivationRequested(object? sender, EventArgs e) =>
+ _window.Post(() => SettingsLauncher.Open());
+
+ private void OnOpenRequested(object? sender, EventArgs e) => SettingsLauncher.Open();
+
+ private void OnExitRequested(object? sender, EventArgs e) => _window.Quit();
+}
diff --git a/CursorLang.Agent/CursorLang.Agent.csproj b/CursorLang.Agent/CursorLang.Agent.csproj
new file mode 100644
index 0000000..6658901
--- /dev/null
+++ b/CursorLang.Agent/CursorLang.Agent.csproj
@@ -0,0 +1,54 @@
+
+
+
+ WinExe
+ net10.0-windows10.0.19041.0
+ 10.0.17763.0
+ enable
+ enable
+ true
+ true
+ $(NoWarn);CS1591
+ CursorLang.Agent
+ CursorLang
+ app.manifest
+ ..\CursorLang.Core\Resources\CursorLang.ico
+ AnyCPU
+ win-x64;win-arm64
+ true
+ false
+ true
+ 1.0.0
+ 1.0.0.0
+ 1.0.0.0
+ CursorLang
+ Aleksandr Neychev
+ Shows the keyboard layout at the cursor
+ Copyright (c) 2026
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/CursorLang.Agent/Interop/GdiNative.cs b/CursorLang.Agent/Interop/GdiNative.cs
new file mode 100644
index 0000000..8df054a
--- /dev/null
+++ b/CursorLang.Agent/Interop/GdiNative.cs
@@ -0,0 +1,161 @@
+using System.Drawing;
+using System.Runtime.InteropServices;
+using CursorLang.Core.Interop;
+
+namespace CursorLang.Agent.Interop;
+
+///
+/// Plain GDI: a font, text, and an off-screen bitmap to draw them into.
+///
+///
+/// GDI rather than GDI+ on purpose. System.Drawing.Common would make the drawing
+/// code shorter, but it is a separate assembly with a native GDI+ library behind it, and
+/// how little this process weighs is the entire reason it exists apart from the window.
+/// Everything the popup needs — one rounded rectangle and one line of text — GDI can do
+/// on its own, and it draws the text with ClearType, exactly as Windows does everywhere else.
+///
+internal static class GdiNative
+{
+ internal const int TRANSPARENT = 1;
+
+ internal const uint DT_SINGLELINE = 0x00000020;
+ internal const uint DT_CENTER = 0x00000001;
+ internal const uint DT_VCENTER = 0x00000004;
+ internal const uint DT_CALCRECT = 0x00000400;
+ internal const uint DT_NOPREFIX = 0x00000800;
+ internal const uint DT_NOCLIP = 0x00000100;
+
+ private const int DEFAULT_CHARSET = 1;
+ private const int OUT_TT_PRECIS = 4;
+ private const int CLIP_DEFAULT_PRECIS = 0;
+ private const int CLEARTYPE_QUALITY = 5;
+ private const int DEFAULT_PITCH = 0;
+
+ ///
+ /// The face the popup is written in.
+ ///
+ ///
+ /// WPF is asked for "Segoe UI" at FontWeight SemiBold and resolves that to the
+ /// seguisb.ttf face. To GDI that face is a family of its own — "Segoe UI Semibold" —
+ /// and asking for the "Segoe UI" family at weight 600 lands on Bold instead, which
+ /// is visibly heavier. So the family is named outright and the weight is left to the
+ /// mapper: the family has one member and no synthetic emboldening happens.
+ ///
+ private const string SemiBoldFace = "Segoe UI Semibold";
+
+ private const int FW_DONTCARE = 0;
+
+ ///
+ /// A font of the given size in physical pixels.
+ ///
+ ///
+ /// The size in the settings is in WPF units, that is 1/96 inch, while GDI counts
+ /// pixels — hence the multiplication by the monitor scale. The height is negative:
+ /// that asks for the em size rather than the cell height, which is what a font size
+ /// means everywhere else.
+ ///
+ internal static IntPtr CreateFont(double wpfFontSize, double scale)
+ {
+ var height = (int)Math.Round(wpfFontSize * scale);
+
+ return CreateFontW(
+ -height, 0, 0, 0, FW_DONTCARE,
+ false, false, false,
+ DEFAULT_CHARSET, OUT_TT_PRECIS, CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY, DEFAULT_PITCH,
+ SemiBoldFace);
+ }
+
+ /// The size of a single line of text with the font selected into the context.
+ internal static Size MeasureText(IntPtr deviceContext, string text)
+ {
+ var bounds = default(PopupWindowNative.Rect);
+ DrawText(deviceContext, text, text.Length, ref bounds,
+ DT_CALCRECT | DT_SINGLELINE | DT_NOPREFIX);
+
+ return new Size(bounds.Right - bounds.Left, bounds.Bottom - bounds.Top);
+ }
+
+ /// A colour as GDI wants it: 0x00BBGGRR, the alpha carried elsewhere.
+ internal static uint ToColorRef(Color color) =>
+ (uint)(color.R | (color.G << 8) | (color.B << 16));
+
+ [DllImport("gdi32.dll", CharSet = CharSet.Unicode, EntryPoint = "CreateFontW")]
+ private static extern IntPtr CreateFontW(int cHeight, int cWidth, int cEscapement, int cOrientation,
+ int cWeight, bool bItalic, bool bUnderline, bool bStrikeOut,
+ int iCharSet, int iOutPrecision, int iClipPrecision, int iQuality, int iPitchAndFamily,
+ string pszFaceName);
+
+ ///
+ /// A 32-bit surface to draw the popup into before anyone can see it.
+ ///
+ ///
+ /// Top-down — a negative height — so that the first row of
+ /// is the top row of the picture and the alpha fixing up afterwards can walk the
+ /// memory straight through.
+ ///
+ internal static IntPtr CreateSurface(IntPtr deviceContext, int width, int height, out IntPtr bits)
+ {
+ var header = new BitmapInfoHeader
+ {
+ biSize = Marshal.SizeOf(),
+ biWidth = width,
+ biHeight = -height,
+ biPlanes = 1,
+ biBitCount = 32,
+ biCompression = BI_RGB,
+ };
+
+ return CreateDIBSection(deviceContext, ref header, DIB_RGB_COLORS, out bits, IntPtr.Zero, 0);
+ }
+
+ private const uint BI_RGB = 0;
+ private const uint DIB_RGB_COLORS = 0;
+
+ [DllImport("gdi32.dll")]
+ internal static extern IntPtr CreateCompatibleDC(IntPtr hdc);
+
+ [DllImport("gdi32.dll")]
+ internal static extern bool DeleteDC(IntPtr hdc);
+
+ [DllImport("gdi32.dll")]
+ private static extern IntPtr CreateDIBSection(IntPtr hdc, ref BitmapInfoHeader header, uint usage,
+ out IntPtr bits, IntPtr section, uint offset);
+
+ [DllImport("gdi32.dll")]
+ internal static extern IntPtr SelectObject(IntPtr hdc, IntPtr h);
+
+ [DllImport("gdi32.dll")]
+ internal static extern bool DeleteObject(IntPtr ho);
+
+ [DllImport("gdi32.dll")]
+ internal static extern int SetBkMode(IntPtr hdc, int mode);
+
+ [DllImport("gdi32.dll")]
+ internal static extern uint SetTextColor(IntPtr hdc, uint color);
+
+ [DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "DrawTextW")]
+ internal static extern int DrawText(IntPtr hdc, string lpchText, int cchText,
+ ref PopupWindowNative.Rect lprc, uint format);
+
+ [DllImport("user32.dll")]
+ internal static extern IntPtr GetDC(IntPtr hWnd);
+
+ [DllImport("user32.dll")]
+ internal static extern int ReleaseDC(IntPtr hWnd, IntPtr hDC);
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct BitmapInfoHeader
+ {
+ public int biSize;
+ public int biWidth;
+ public int biHeight;
+ public short biPlanes;
+ public short biBitCount;
+ public uint biCompression;
+ public uint biSizeImage;
+ public int biXPelsPerMeter;
+ public int biYPelsPerMeter;
+ public uint biClrUsed;
+ public uint biClrImportant;
+ }
+}
diff --git a/CursorLang.Agent/Interop/MenuNative.cs b/CursorLang.Agent/Interop/MenuNative.cs
new file mode 100644
index 0000000..38326dc
--- /dev/null
+++ b/CursorLang.Agent/Interop/MenuNative.cs
@@ -0,0 +1,98 @@
+using System.Runtime.InteropServices;
+using CursorLang.Core.Interop;
+
+namespace CursorLang.Agent.Interop;
+
+///
+/// The system context menu — the one the tray icon raises.
+///
+///
+/// A menu built this way is drawn by Windows, so the theme and the language chosen in
+/// the application no longer reach it. That is the accepted price of leaving WPF: a WPF
+/// ContextMenu costs the whole rendering stack in the background process.
+///
+internal static class MenuNative
+{
+ private const uint MF_STRING = 0x00000000;
+ private const uint MF_SEPARATOR = 0x00000800;
+ private const uint MF_GRAYED = 0x00000001;
+
+ private const uint TPM_LEFTALIGN = 0x0000;
+ private const uint TPM_RIGHTBUTTON = 0x0002;
+ private const uint TPM_RETURNCMD = 0x0100;
+
+ /// An item of the menu being built.
+ /// What gives back when the item is chosen.
+ /// The text, or null for a separator.
+ /// A greyed item is shown but cannot be chosen.
+ internal readonly record struct Item(int Id, string? Caption, bool IsEnabled = true)
+ {
+ internal static Item Separator => new(0, null);
+ }
+
+ ///
+ /// Raises the menu at a screen point and returns the identifier of the chosen item,
+ /// or zero when the user dismissed it.
+ ///
+ ///
+ /// TPM_RETURNCMD means the answer comes back from the call itself instead of
+ /// as a WM_COMMAND later, which keeps the whole menu in one place. The call
+ /// does not return until the user is done with the menu — that is how a modal menu
+ /// works, and the message loop keeps running inside it.
+ ///
+ /// The window is brought to the foreground first and poked with an empty message
+ /// afterwards: without the first the menu never closes on a click elsewhere, and
+ /// without the second it stays on screen after the choice is made. Both are
+ /// long-standing quirks of a menu owned by a window the user cannot see.
+ ///
+ internal static int Track(IntPtr owner, PopupWindowNative.Point at, IReadOnlyList items)
+ {
+ IntPtr menu = CreatePopupMenu();
+ if (menu == IntPtr.Zero)
+ {
+ return 0;
+ }
+
+ try
+ {
+ foreach (Item item in items)
+ {
+ if (item.Caption is null)
+ {
+ AppendMenu(menu, MF_SEPARATOR, IntPtr.Zero, null);
+ continue;
+ }
+
+ uint flags = MF_STRING | (item.IsEnabled ? 0 : MF_GRAYED);
+ AppendMenu(menu, flags, new IntPtr(item.Id), item.Caption);
+ }
+
+ TrayIconNative.BringToForeground(owner);
+
+ int chosen = TrackPopupMenuEx(
+ menu, TPM_LEFTALIGN | TPM_RIGHTBUTTON | TPM_RETURNCMD,
+ at.X, at.Y, owner, IntPtr.Zero);
+
+ WindowNative.PostMessage(owner, WindowNative.WM_NULL, IntPtr.Zero, IntPtr.Zero);
+
+ return chosen;
+ }
+ finally
+ {
+ DestroyMenu(menu);
+ }
+ }
+
+ [DllImport("user32.dll")]
+ private static extern IntPtr CreatePopupMenu();
+
+ [DllImport("user32.dll")]
+ private static extern bool DestroyMenu(IntPtr hMenu);
+
+ [DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "AppendMenuW")]
+ private static extern bool AppendMenu(IntPtr hMenu, uint uFlags, IntPtr uIDNewItem, string? lpNewItem);
+
+ [DllImport("user32.dll")]
+ private static extern int TrackPopupMenuEx(IntPtr hMenu, uint uFlags, int x, int y,
+ IntPtr hwnd, IntPtr lptpm);
+}
diff --git a/CursorLang/Interop/TrayIconNative.cs b/CursorLang.Agent/Interop/TrayIconNative.cs
similarity index 94%
rename from CursorLang/Interop/TrayIconNative.cs
rename to CursorLang.Agent/Interop/TrayIconNative.cs
index f965a25..3fbd965 100644
--- a/CursorLang/Interop/TrayIconNative.cs
+++ b/CursorLang.Agent/Interop/TrayIconNative.cs
@@ -1,6 +1,7 @@
using System.Runtime.InteropServices;
+using CursorLang.Core.Interop;
-namespace CursorLang.Interop;
+namespace CursorLang.Agent.Interop;
///
/// Win32 API for the notification area: the icon itself, the messages it sends
@@ -28,7 +29,6 @@ internal static class TrayIconNative
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;
@@ -71,13 +71,6 @@ internal static class TrayIconNative
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)
{
@@ -134,6 +127,16 @@ internal static class TrayIconNative
/// The notification the icon has sent: it sits in the low word of lParam.
internal static int NotificationOf(IntPtr lParam) => (int)(lParam.ToInt64() & 0xFFFF);
+ ///
+ /// The point of the click. Version 4 of the protocol reports it in screen pixels
+ /// in wParam — exactly what TrackPopupMenuEx expects.
+ ///
+ internal static PopupWindowNative.Point PointOf(IntPtr wParam) => new()
+ {
+ X = (short)(wParam.ToInt64() & 0xFFFF),
+ Y = (short)((wParam.ToInt64() >> 16) & 0xFFFF),
+ };
+
///
/// Brings the window to the foreground.
///
diff --git a/CursorLang.Agent/Interop/WindowNative.cs b/CursorLang.Agent/Interop/WindowNative.cs
new file mode 100644
index 0000000..fc3e067
--- /dev/null
+++ b/CursorLang.Agent/Interop/WindowNative.cs
@@ -0,0 +1,180 @@
+using System.Runtime.InteropServices;
+using CursorLang.Core.Interop;
+
+namespace CursorLang.Agent.Interop;
+
+///
+/// The Win32 pieces a window needs when there is no framework to make one:
+/// the class, the window itself, the message loop.
+///
+internal static class WindowNative
+{
+ /// The window procedure. Windows keeps the only reference to it.
+ internal delegate IntPtr WindowProc(IntPtr hWnd, uint message, IntPtr wParam, IntPtr lParam);
+
+ internal const int WS_POPUP = unchecked((int)0x80000000);
+
+ internal const int WS_EX_LAYERED = 0x00080000;
+ internal const int WS_EX_TOOLWINDOW = 0x00000080;
+ internal const int WS_EX_NOACTIVATE = 0x08000000;
+ internal const int WS_EX_TRANSPARENT = 0x00000020;
+ internal const int WS_EX_TOPMOST = 0x00000008;
+
+ internal const int SW_HIDE = 0;
+ internal const int SW_SHOWNOACTIVATE = 4;
+
+ internal const uint WM_DESTROY = 0x0002;
+ internal const uint WM_CLOSE = 0x0010;
+ internal const uint WM_QUIT = 0x0012;
+ internal const uint WM_NULL = 0x0000;
+ internal const uint WM_ENDSESSION = 0x0016;
+
+ /// WM_APP and up belong to the application; the tray takes WM_APP + 1.
+ internal const uint WM_APP = 0x8000;
+
+ ///
+ /// Registers a window class. A class already there is not an error: the name is
+ /// unique per window kind, and a second agent in the same process would meet its
+ /// own registration.
+ ///
+ internal static void RegisterClass(string className, WindowProc windowProc)
+ {
+ var description = new WindowClass
+ {
+ cbSize = Marshal.SizeOf(),
+ lpfnWndProc = windowProc,
+ hInstance = GetModuleHandle(null),
+ lpszClassName = className,
+ };
+
+ if (RegisterClassEx(ref description) == 0 &&
+ Marshal.GetLastWin32Error() != ErrorClassAlreadyExists)
+ {
+ throw new InvalidOperationException(
+ $"RegisterClassExW failed for '{className}': {Marshal.GetLastWin32Error()}");
+ }
+ }
+
+ private const int ErrorClassAlreadyExists = 1410;
+
+ /// Creates a window of a registered class. It is not shown.
+ internal static IntPtr CreateWindow(string className, string title, int style, int exStyle)
+ {
+ IntPtr window = CreateWindowEx(
+ exStyle, className, title, style,
+ 0, 0, 0, 0,
+ IntPtr.Zero, IntPtr.Zero, GetModuleHandle(null), IntPtr.Zero);
+
+ if (window == IntPtr.Zero)
+ {
+ throw new InvalidOperationException(
+ $"CreateWindowExW failed for '{className}': {Marshal.GetLastWin32Error()}");
+ }
+
+ return window;
+ }
+
+ ///
+ /// Puts a finished picture into a layered window, together with where it goes and
+ /// how see-through it is.
+ ///
+ ///
+ /// One call replaces moving the window, resizing it, painting it and setting its
+ /// opacity, and it works while the window is still hidden. That is the point: the
+ /// content is ready before anyone can see the window, so it can never be shown
+ /// holding the picture of the previous time.
+ ///
+ internal static bool SetContent(
+ IntPtr window, PopupWindowNative.Point at, Size size, IntPtr sourceDc, byte alpha)
+ {
+ var source = new PopupWindowNative.Point { X = 0, Y = 0 };
+ var blend = new BlendFunction
+ {
+ BlendOp = AC_SRC_OVER,
+ SourceConstantAlpha = alpha,
+ AlphaFormat = AC_SRC_ALPHA,
+ };
+
+ return UpdateLayeredWindow(
+ window, IntPtr.Zero, ref at, ref size, sourceDc, ref source, 0, ref blend, ULW_ALPHA);
+ }
+
+ private const byte AC_SRC_OVER = 0;
+ private const byte AC_SRC_ALPHA = 1;
+ private const uint ULW_ALPHA = 0x00000002;
+
+ ///
+ /// BLENDFUNCTION. BlendFlags is never assigned and must stay all the same:
+ /// Windows reads the structure by its layout, and dropping a byte from the middle
+ /// of it would shift everything after.
+ ///
+ [StructLayout(LayoutKind.Sequential)]
+ private struct BlendFunction
+ {
+ public byte BlendOp;
+ public byte BlendFlags;
+ public byte SourceConstantAlpha;
+ public byte AlphaFormat;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ internal struct Size
+ {
+ public int Width;
+ public int Height;
+ }
+
+ [DllImport("user32.dll", SetLastError = true)]
+ private static extern bool UpdateLayeredWindow(IntPtr hWnd, IntPtr hdcDst,
+ ref PopupWindowNative.Point pptDst, ref Size psize, IntPtr hdcSrc,
+ ref PopupWindowNative.Point pptSrc, uint crKey, ref BlendFunction pblend, uint dwFlags);
+
+ [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
+ private static extern ushort RegisterClassEx(ref WindowClass lpwcx);
+
+ [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true, EntryPoint = "CreateWindowExW")]
+ private static extern IntPtr CreateWindowEx(int dwExStyle, string lpClassName, string lpWindowName,
+ int dwStyle, int x, int y, int nWidth, int nHeight,
+ IntPtr hWndParent, IntPtr hMenu, IntPtr hInstance, IntPtr lpParam);
+
+ [DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "DefWindowProcW")]
+ internal static extern IntPtr DefWindowProc(IntPtr hWnd, uint message, IntPtr wParam, IntPtr lParam);
+
+ [DllImport("user32.dll")]
+ internal static extern bool DestroyWindow(IntPtr hWnd);
+
+ [DllImport("user32.dll")]
+ internal static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
+
+ [DllImport("user32.dll")]
+ internal static extern bool IsWindowVisible(IntPtr hWnd);
+
+ [DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "PostMessageW")]
+ internal static extern bool PostMessage(IntPtr hWnd, uint message, IntPtr wParam, IntPtr lParam);
+
+ [DllImport("user32.dll")]
+ internal static extern void PostQuitMessage(int nExitCode);
+
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
+ private static extern IntPtr GetModuleHandle(string? lpModuleName);
+
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
+ private struct WindowClass
+ {
+ public int cbSize;
+ public uint style;
+
+ [MarshalAs(UnmanagedType.FunctionPtr)]
+ public WindowProc lpfnWndProc;
+
+ public int cbClsExtra;
+ public int cbWndExtra;
+ public IntPtr hInstance;
+ public IntPtr hIcon;
+ public IntPtr hCursor;
+ public IntPtr hbrBackground;
+ public string? lpszMenuName;
+ public string lpszClassName;
+ public IntPtr hIconSm;
+ }
+}
diff --git a/CursorLang.Agent/Program.cs b/CursorLang.Agent/Program.cs
new file mode 100644
index 0000000..892933f
--- /dev/null
+++ b/CursorLang.Agent/Program.cs
@@ -0,0 +1,38 @@
+using CursorLang.Core.Services;
+
+namespace CursorLang.Agent;
+
+internal static class Program
+{
+ ///
+ /// A single-threaded apartment because of the caret: AccessibleObjectFromWindow
+ /// and UI Automation both go through COM, and both expect the thread that calls them
+ /// to be an STA one.
+ ///
+ [STAThread]
+ private static int Main(string[] arguments)
+ {
+ bool automatic = StartupLaunch.IsAutomatic(arguments);
+
+ var gate = new SingleInstanceGate(SingleInstanceGate.AgentName);
+
+ // A second launch is the user asking for the application, so the one already
+ // running opens the settings window and this one steps aside. A second launch
+ // by Windows at sign-in asks for nothing and gets nothing
+ if (!gate.TryAcquire(showRunningInstance: !automatic))
+ {
+ gate.Dispose();
+ return 0;
+ }
+
+ try
+ {
+ using var agent = new Agent(gate);
+ return agent.Run(automatic);
+ }
+ finally
+ {
+ gate.Dispose();
+ }
+ }
+}
diff --git a/CursorLang.Agent/Properties/launchSettings.json b/CursorLang.Agent/Properties/launchSettings.json
new file mode 100644
index 0000000..ebf611e
--- /dev/null
+++ b/CursorLang.Agent/Properties/launchSettings.json
@@ -0,0 +1,11 @@
+{
+ "profiles": {
+ "Agent": {
+ "commandName": "Project"
+ },
+ "Agent (started by Windows)": {
+ "commandName": "Project",
+ "commandLineArgs": "--startup"
+ }
+ }
+}
diff --git a/CursorLang/Services/CapsLockHotkeyService.cs b/CursorLang.Agent/Services/CapsLockHotkeyService.cs
similarity index 64%
rename from CursorLang/Services/CapsLockHotkeyService.cs
rename to CursorLang.Agent/Services/CapsLockHotkeyService.cs
index f0748fc..0f03585 100644
--- a/CursorLang/Services/CapsLockHotkeyService.cs
+++ b/CursorLang.Agent/Services/CapsLockHotkeyService.cs
@@ -1,8 +1,9 @@
-using System.Windows.Threading;
-using CursorLang.Interop;
-using CursorLang.Models;
+using CursorLang.Core.Interop;
+using CursorLang.Core.Models;
+using CursorLang.Core.Services;
+using CursorLang.Core.Threading;
-namespace CursorLang.Services;
+namespace CursorLang.Agent.Services;
///
/// Holds the system Caps Lock hook and tells a short tap from a hold.
@@ -12,22 +13,32 @@ namespace CursorLang.Services;
/// intercepted — the press and the release. That is also the only way to cancel the
/// case change: Windows toggles Caps Lock on the press event, and letting it through
/// "just in case" is not an option.
+///
+/// Two things changed on the way out of WPF: the hold is timed by
+/// , and the event reaches its subscribers through a message
+/// posted to the agent's window rather than through the dispatcher.
///
public sealed class CapsLockHotkeyService : ICapsLockHotkeyService, IDisposable
{
private const int VirtualKeyCapsLock = 0x14;
private readonly AppSettings _settings;
+ private readonly Action _post;
private readonly LowLevelKeyboardHook _hook;
- private readonly DispatcherTimer _holdTimer = new();
- private readonly Dispatcher _dispatcher = Dispatcher.CurrentDispatcher;
+ private readonly MessageTimer _holdTimer = new();
private bool _isPressed;
private bool _isHolding;
- public CapsLockHotkeyService(AppSettings settings)
+ /// Where the hold threshold is read from, on every press.
+ ///
+ /// Hands work back to the message loop. Taken as a delegate rather than as the
+ /// agent's window so that the press logic can be checked without one.
+ ///
+ public CapsLockHotkeyService(AppSettings settings, Action post)
{
_settings = settings;
+ _post = post;
_hook = new LowLevelKeyboardHook(HandleKeyEvent);
_holdTimer.Tick += OnHoldTimerTick;
}
@@ -49,12 +60,12 @@ public sealed class CapsLockHotkeyService : ICapsLockHotkeyService, IDisposable
}
// When the application is closing, nobody is waiting for events any more, so
- // unlike in Stop the state is reset quietly: the dispatcher queue may be shut
- // down by that moment
+ // unlike in Stop the state is reset quietly: the message loop is already gone by
+ // that moment and posted work would never run
public void Dispose()
{
_holdTimer.Tick -= OnHoldTimerTick;
- _holdTimer.Stop();
+ _holdTimer.Dispose();
_isPressed = false;
_isHolding = false;
_hook.Dispose();
@@ -104,9 +115,28 @@ public sealed class CapsLockHotkeyService : ICapsLockHotkeyService, IDisposable
return true;
}
- private void OnHoldTimerTick(object? sender, EventArgs e)
+ private void OnHoldTimerTick(object? sender, EventArgs e) => HandleHoldElapsed();
+
+ ///
+ /// The hold countdown has run out.
+ ///
+ ///
+ /// The key being down is checked rather than assumed. A countdown started on the
+ /// press can still be delivered just after the release — Windows does not withdraw a
+ /// WM_TIMER it has already posted — and announcing a hold then would put the popup on
+ /// screen showing the layout the tap is about to change away from.
+ ///
+ /// The tests reach this directly: that ordering is the whole point and a real clock
+ /// will not reproduce it on demand.
+ ///
+ internal void HandleHoldElapsed()
{
_holdTimer.Stop();
+ if (!_isPressed)
+ {
+ return;
+ }
+
_isHolding = true;
HoldStarted?.Invoke(this, EventArgs.Empty);
}
@@ -117,7 +147,7 @@ public sealed class CapsLockHotkeyService : ICapsLockHotkeyService, IDisposable
{
if (handler is not null)
{
- _dispatcher.BeginInvoke(() => handler(this, EventArgs.Empty));
+ _post(() => handler(this, EventArgs.Empty));
}
}
diff --git a/CursorLang/Services/LayoutPopupService.cs b/CursorLang.Agent/Services/LayoutPopupService.cs
similarity index 68%
rename from CursorLang/Services/LayoutPopupService.cs
rename to CursorLang.Agent/Services/LayoutPopupService.cs
index 48fe7dc..080ce22 100644
--- a/CursorLang/Services/LayoutPopupService.cs
+++ b/CursorLang.Agent/Services/LayoutPopupService.cs
@@ -1,24 +1,27 @@
-using System.Windows.Threading;
-using CursorLang.Models;
-using CursorLang.ViewModels;
+using CursorLang.Core.Models;
+using CursorLang.Core.Services;
+using CursorLang.Core.Threading;
-namespace CursorLang.Services;
+namespace CursorLang.Agent.Services;
///
/// Manages the lifetime of the popup: the window is only responsible for showing it,
/// while the decision of when to show and when to take it down is made here.
///
+///
+/// The same service it always was, with DispatcherTimer swapped for
+/// : both tick on the thread that owns the window, so
+/// nothing else about the logic had to move.
+///
public sealed class LayoutPopupService : ILayoutPopupService, IDisposable
{
private readonly ILayoutPopupWindow _window;
- private readonly LayoutPopupViewModel _viewModel;
private readonly AppSettings _settings;
- private readonly DispatcherTimer _hideTimer = new();
+ private readonly MessageTimer _hideTimer = new();
- public LayoutPopupService(ILayoutPopupWindow window, LayoutPopupViewModel viewModel, AppSettings settings)
+ public LayoutPopupService(ILayoutPopupWindow window, AppSettings settings)
{
_window = window;
- _viewModel = viewModel;
_settings = settings;
_hideTimer.Tick += OnHideTimerTick;
@@ -37,9 +40,7 @@ public sealed class LayoutPopupService : ILayoutPopupService, IDisposable
public void ShowUntilHidden(KeyboardLayout layout)
{
_hideTimer.Stop();
-
- _viewModel.ShortName = layout.ShortName;
- _window.ShowPopup();
+ _window.ShowPopup(layout.ShortName);
}
public void Hide()
@@ -50,8 +51,8 @@ public sealed class LayoutPopupService : ILayoutPopupService, IDisposable
public void Dispose()
{
- _hideTimer.Stop();
_hideTimer.Tick -= OnHideTimerTick;
+ _hideTimer.Dispose();
_window.Close();
}
diff --git a/CursorLang.Agent/Services/SettingsLauncher.cs b/CursorLang.Agent/Services/SettingsLauncher.cs
new file mode 100644
index 0000000..3607c63
--- /dev/null
+++ b/CursorLang.Agent/Services/SettingsLauncher.cs
@@ -0,0 +1,43 @@
+using System.ComponentModel;
+using System.Diagnostics;
+using CursorLang.Core.Services;
+
+namespace CursorLang.Agent.Services;
+
+///
+/// Starts the settings window.
+///
+///
+/// The agent does not keep track of whether the window is already open, and does not
+/// need to: the settings process guards a single-instance slot of its own, so a second
+/// launch raises the window already there and exits. That costs a process start to find
+/// out, which is a fraction of the time it takes a person to look at the tray, and it
+/// saves the agent from holding a handle to something it does not own.
+///
+internal static class SettingsLauncher
+{
+ ///
+ /// Opens the settings window. Returns false when the executable is not
+ /// where it should be — a half-copied installation, or the agent run from a build
+ /// folder of its own.
+ ///
+ internal static bool Open()
+ {
+ if (AgentExecutable.SettingsPath is not { } path)
+ {
+ return false;
+ }
+
+ try
+ {
+ using Process? started = Process.Start(new ProcessStartInfo(path) { UseShellExecute = false });
+ return started is not null;
+ }
+ catch (Exception e) when (e is Win32Exception or InvalidOperationException)
+ {
+ // Nothing to tell the user with: the agent has no window of its own, and
+ // the one that would have shown the message is the one that failed to start
+ return false;
+ }
+ }
+}
diff --git a/CursorLang.Agent/Windows/AgentWindow.cs b/CursorLang.Agent/Windows/AgentWindow.cs
new file mode 100644
index 0000000..cd193c8
--- /dev/null
+++ b/CursorLang.Agent/Windows/AgentWindow.cs
@@ -0,0 +1,85 @@
+using System.Collections.Concurrent;
+using CursorLang.Agent.Interop;
+
+namespace CursorLang.Agent.Windows;
+
+///
+/// The window the agent lives around: never shown, but it owns the tray icon and it
+/// is the way back onto the message loop from a callback.
+///
+///
+/// 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.
+///
+internal sealed class AgentWindow : NativeWindow
+{
+ ///
+ /// A message hook. Returning true means the message has been dealt with.
+ ///
+ internal delegate bool MessageFilter(uint message, IntPtr wParam, IntPtr lParam);
+
+ private const string ClassName = "CursorLang.Agent.Window";
+
+ /// Drain the queue of posted work. WM_APP is free for the application.
+ private const uint WM_INVOKE = WindowNative.WM_APP + 100;
+
+ private readonly List _filters = [];
+ private readonly ConcurrentQueue _posted = new();
+
+ internal AgentWindow()
+ : base(ClassName, "CursorLang agent", WindowNative.WS_POPUP, WindowNative.WS_EX_TOOLWINDOW)
+ {
+ }
+
+ internal void AddFilter(MessageFilter filter) => _filters.Add(filter);
+
+ ///
+ /// Runs the action on the message loop, after the current message is done with.
+ ///
+ ///
+ /// This is what the agent has instead of Dispatcher.BeginInvoke. The caller
+ /// that matters is the keyboard hook: Windows removes a hook whose procedure takes
+ /// too long, so the procedure only records what happened and the answer — showing
+ /// the popup, switching the layout — waits for the message after this one.
+ ///
+ internal void Post(Action action)
+ {
+ _posted.Enqueue(action);
+ WindowNative.PostMessage(Handle, WM_INVOKE, IntPtr.Zero, IntPtr.Zero);
+ }
+
+ /// Asks the message loop to finish.
+ internal void Quit() => WindowNative.PostQuitMessage(0);
+
+ protected override bool OnMessage(uint message, IntPtr wParam, IntPtr lParam, out IntPtr result)
+ {
+ result = IntPtr.Zero;
+
+ if (message == WM_INVOKE)
+ {
+ while (_posted.TryDequeue(out Action? action))
+ {
+ action();
+ }
+
+ return true;
+ }
+
+ if (message is WindowNative.WM_CLOSE or WindowNative.WM_ENDSESSION)
+ {
+ Quit();
+ return true;
+ }
+
+ foreach (MessageFilter filter in _filters)
+ {
+ if (filter(message, wParam, lParam))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/CursorLang.Agent/Windows/NativePopupWindow.cs b/CursorLang.Agent/Windows/NativePopupWindow.cs
new file mode 100644
index 0000000..1a7d1ce
--- /dev/null
+++ b/CursorLang.Agent/Windows/NativePopupWindow.cs
@@ -0,0 +1,392 @@
+using System.Drawing;
+using System.Runtime.InteropServices;
+using CursorLang.Agent.Interop;
+using CursorLang.Agent.Services;
+using CursorLang.Core.Interop;
+using CursorLang.Core.Models;
+using CursorLang.Core.Services;
+
+namespace CursorLang.Agent.Windows;
+
+///
+/// The popup with the short name of the layout, drawn by Win32 alone.
+///
+///
+/// A like-for-like replacement of the WPF popup this once was: a rounded rectangle of
+/// radius 4 with 10×4 padding, the fill and the text colour from the settings, the whole
+/// thing at the opacity from the settings, the name in Segoe UI SemiBold at the size
+/// from the settings.
+///
+/// The picture is drawn into an off-screen bitmap and handed to the window whole, by
+/// UpdateLayeredWindow. Painting on demand instead — a WM_PAINT after the
+/// window is shown — is what the first version did, and it had the popup appear holding
+/// the picture of the previous show: hiding a window does not throw its content away,
+/// and the content is always the other layout. Here there is nothing to be stale,
+/// because the window is never shown before its picture is in place.
+///
+/// It also does away with two devices the painted version needed: the corners came from
+/// a window region, which cuts without antialiasing, and the opacity from
+/// SetLayeredWindowAttributes. Both are now just pixels in the bitmap.
+///
+/// Responsible only for showing the popup, its size and its place on screen: when to
+/// take it down is decided by .
+///
+internal sealed class NativePopupWindow : NativeWindow, ILayoutPopupWindow
+{
+ private const string ClassName = "CursorLang.Agent.Popup";
+
+ // The numbers of the XAML: Border CornerRadius="4" Padding="10,4", all in WPF units
+ private const double CornerRadius = 4;
+ private const double PaddingX = 10;
+ private const double PaddingY = 4;
+
+ private readonly AppSettings _settings;
+
+ private IntPtr _font;
+ private double _fontSize;
+ private double _fontScale;
+
+ internal NativePopupWindow(AppSettings settings)
+ : base(
+ ClassName,
+ "CursorLang popup",
+ WindowNative.WS_POPUP,
+ WindowNative.WS_EX_LAYERED | WindowNative.WS_EX_TOOLWINDOW |
+ WindowNative.WS_EX_NOACTIVATE | WindowNative.WS_EX_TRANSPARENT |
+ WindowNative.WS_EX_TOPMOST)
+ {
+ _settings = settings;
+ }
+
+ ///
+ /// Shows the popup with the given text at the place set by the settings.
+ ///
+ public void ShowPopup(string text)
+ {
+ if (Handle == IntPtr.Zero)
+ {
+ return;
+ }
+
+ bool atFixedPoint = _settings.PlacementMode == PopupPlacementMode.FixedPoint;
+ PopupWindowNative.Rect work = default;
+ PopupWindowNative.Rect anchor = default;
+ double scale;
+
+ if (atFixedPoint)
+ {
+ (work, scale) = PopupWindowNative.GetActiveMonitorWorkArea();
+ }
+ else
+ {
+ anchor = GetAnchor();
+ scale = PopupWindowNative.GetScaleAt(new PopupWindowNative.Point { X = anchor.Left, Y = anchor.Top });
+ }
+
+ EnsureFont(scale);
+
+ Size measured = MeasureText(text);
+ int width = measured.Width + (2 * PopupLayout.ToPixels(PaddingX, scale));
+ int height = measured.Height + (2 * PopupLayout.ToPixels(PaddingY, scale));
+
+ PopupWindowNative.Point position = atFixedPoint
+ ? PopupLayout.OnScreen(
+ work, _settings.ScreenPosition, PopupLayout.ToPixels(_settings.ScreenMargin, scale), width, height)
+ : PopupLayout.NearAnchor(anchor, AnchorSideForMode(), OffsetForMode(scale), width, height);
+
+ if (!Draw(text, position, width, height, PopupLayout.ToPixels(CornerRadius, scale)))
+ {
+ return;
+ }
+
+ if (!WindowNative.IsWindowVisible(Handle))
+ {
+ WindowNative.ShowWindow(Handle, WindowNative.SW_SHOWNOACTIVATE);
+ }
+ }
+
+ public void Hide()
+ {
+ if (Handle != IntPtr.Zero)
+ {
+ WindowNative.ShowWindow(Handle, WindowNative.SW_HIDE);
+ }
+ }
+
+ /// Destroys the window. The agent only does this on the way out.
+ public void Close() => Dispose();
+
+ public override void Dispose()
+ {
+ ReleaseFont();
+ base.Dispose();
+ }
+
+ ///
+ /// Draws the popup off screen and hands the finished picture to the window.
+ ///
+ ///
+ /// The bitmap is thrown away afterwards rather than kept: it is a few tens of
+ /// kilobytes for the length of one call, the popup is shown rarely, and a cached one
+ /// would have to be rebuilt on every change of size, colour or scale anyway.
+ ///
+ private bool Draw(string text, PopupWindowNative.Point at, int width, int height, int radius)
+ {
+ IntPtr screen = GdiNative.GetDC(IntPtr.Zero);
+ if (screen == IntPtr.Zero)
+ {
+ return false;
+ }
+
+ IntPtr memory = IntPtr.Zero;
+ IntPtr surface = IntPtr.Zero;
+
+ try
+ {
+ memory = GdiNative.CreateCompatibleDC(screen);
+ if (memory == IntPtr.Zero)
+ {
+ return false;
+ }
+
+ surface = GdiNative.CreateSurface(memory, width, height, out IntPtr bits);
+ if (surface == IntPtr.Zero)
+ {
+ return false;
+ }
+
+ GdiNative.SelectObject(memory, surface);
+
+ Fill(bits, width, height);
+ DrawText(memory, text, width, height);
+
+ // GDI writes nothing into the alpha channel, so the letters it just drew are
+ // sitting at zero alpha and would come out invisible. The inside of the
+ // popup is opaque anyway, so the whole surface is simply declared so — and
+ // the corners are rounded off afterwards, which is the only place alpha
+ // varies
+ MakeOpaque(bits, width, height);
+ RoundTheCorners(bits, width, height, radius);
+
+ var size = new WindowNative.Size { Width = width, Height = height };
+ var alpha = (byte)Math.Clamp(Math.Round(_settings.Opacity * 255), 0, 255);
+
+ return WindowNative.SetContent(Handle, at, size, memory, alpha);
+ }
+ finally
+ {
+ // The context goes first: a bitmap still selected into one cannot be
+ // deleted, and this way that holds however the method was left
+ if (memory != IntPtr.Zero)
+ {
+ GdiNative.DeleteDC(memory);
+ }
+
+ if (surface != IntPtr.Zero)
+ {
+ GdiNative.DeleteObject(surface);
+ }
+
+ GdiNative.ReleaseDC(IntPtr.Zero, screen);
+ }
+ }
+
+ private void Fill(IntPtr bits, int width, int height)
+ {
+ Color background = _settings.BackgroundColor;
+
+ // Straight into the bitmap rather than through a brush: the pixels have to be
+ // written anyway to carry an alpha channel GDI would not touch
+ int packed = (255 << 24) | (background.R << 16) | (background.G << 8) | background.B;
+ var row = new int[width];
+ Array.Fill(row, packed);
+
+ for (var y = 0; y < height; y++)
+ {
+ Marshal.Copy(row, 0, bits + (y * width * 4), width);
+ }
+ }
+
+ private void DrawText(IntPtr deviceContext, string text, int width, int height)
+ {
+ if (_font == IntPtr.Zero || text.Length == 0)
+ {
+ return;
+ }
+
+ var bounds = new PopupWindowNative.Rect { Left = 0, Top = 0, Right = width, Bottom = height };
+
+ IntPtr previousFont = GdiNative.SelectObject(deviceContext, _font);
+ GdiNative.SetBkMode(deviceContext, GdiNative.TRANSPARENT);
+ GdiNative.SetTextColor(deviceContext, GdiNative.ToColorRef(_settings.ForegroundColor));
+
+ GdiNative.DrawText(deviceContext, text, text.Length, ref bounds,
+ GdiNative.DT_SINGLELINE | GdiNative.DT_CENTER | GdiNative.DT_VCENTER |
+ GdiNative.DT_NOPREFIX | GdiNative.DT_NOCLIP);
+
+ GdiNative.SelectObject(deviceContext, previousFont);
+ }
+
+ private static void MakeOpaque(IntPtr bits, int width, int height)
+ {
+ var row = new int[width];
+
+ for (var y = 0; y < height; y++)
+ {
+ IntPtr line = bits + (y * width * 4);
+ Marshal.Copy(line, row, 0, width);
+
+ for (var x = 0; x < width; x++)
+ {
+ row[x] = (int)((uint)row[x] | 0xFF000000);
+ }
+
+ Marshal.Copy(row, 0, line, width);
+ }
+ }
+
+ ///
+ /// Cuts the four corners to a radius, fading the edge rather than stepping it.
+ ///
+ ///
+ /// The painted version cut them with a window region, which is a yes-or-no mask and
+ /// left a visible staircase at 200% scale. Here the corner pixels carry a partial
+ /// alpha worked out from how far the pixel centre is past the arc, which is what
+ /// antialiasing amounts to. The colours are premultiplied to match, as
+ /// UpdateLayeredWindow expects.
+ ///
+ private static void RoundTheCorners(IntPtr bits, int width, int height, int radius)
+ {
+ if (radius <= 0)
+ {
+ return;
+ }
+
+ radius = Math.Min(radius, Math.Min(width, height) / 2);
+
+ var row = new int[width];
+
+ for (var y = 0; y < height; y++)
+ {
+ bool nearTop = y < radius;
+ bool nearBottom = y >= height - radius;
+ if (!nearTop && !nearBottom)
+ {
+ continue;
+ }
+
+ IntPtr line = bits + (y * width * 4);
+ Marshal.Copy(line, row, 0, width);
+
+ double centreY = nearTop ? radius - 0.5 : height - radius - 0.5;
+
+ for (var x = 0; x < width; x++)
+ {
+ bool nearLeft = x < radius;
+ bool nearRight = x >= width - radius;
+ if (!nearLeft && !nearRight)
+ {
+ continue;
+ }
+
+ double centreX = nearLeft ? radius - 0.5 : width - radius - 0.5;
+ double distance = Math.Sqrt(
+ ((x - centreX) * (x - centreX)) + ((y - centreY) * (y - centreY)));
+
+ // One pixel of softness across the arc: fully inside, fully outside,
+ // and a ramp in between
+ double coverage = Math.Clamp(radius - distance + 0.5, 0, 1);
+ if (coverage >= 1)
+ {
+ continue;
+ }
+
+ row[x] = Premultiply(row[x], coverage);
+ }
+
+ Marshal.Copy(row, 0, line, width);
+ }
+ }
+
+ private static int Premultiply(int pixel, double coverage)
+ {
+ var value = (uint)pixel;
+ var alpha = (uint)Math.Round(((value >> 24) & 0xFF) * coverage);
+
+ uint red = (uint)Math.Round(((value >> 16) & 0xFF) * coverage);
+ uint green = (uint)Math.Round(((value >> 8) & 0xFF) * coverage);
+ uint blue = (uint)Math.Round((value & 0xFF) * coverage);
+
+ return (int)((alpha << 24) | (red << 16) | (green << 8) | blue);
+ }
+
+ // The anchor point: the caret in the input field or the mouse cursor. The cursor
+ // is a rectangle of zero size, so the corner computation is shared by both
+ private PopupWindowNative.Rect GetAnchor()
+ {
+ if (_settings.PlacementMode == PopupPlacementMode.AtCaret &&
+ CaretNative.TryGetCaretRect() is { } caret)
+ {
+ return caret;
+ }
+
+ return PopupLayout.AsAnchor(PopupWindowNative.GetCursorPosition());
+ }
+
+ // Every anchor mode has a side and an offset of its own
+ private AnchorSide AnchorSideForMode() =>
+ _settings.PlacementMode == PopupPlacementMode.AtCaret ? _settings.CaretSide : _settings.CursorSide;
+
+ private int OffsetForMode(double scale) => PopupLayout.ToPixels(
+ _settings.PlacementMode == PopupPlacementMode.AtCaret ? _settings.CaretOffset : _settings.CursorOffset,
+ scale);
+
+ private Size MeasureText(string text)
+ {
+ IntPtr deviceContext = GdiNative.GetDC(Handle);
+ if (deviceContext == IntPtr.Zero)
+ {
+ return Size.Empty;
+ }
+
+ try
+ {
+ IntPtr previousFont = GdiNative.SelectObject(deviceContext, _font);
+ Size measured = GdiNative.MeasureText(deviceContext, text);
+ GdiNative.SelectObject(deviceContext, previousFont);
+
+ return measured;
+ }
+ finally
+ {
+ GdiNative.ReleaseDC(Handle, deviceContext);
+ }
+ }
+
+ // The font is rebuilt only when the size in the settings or the monitor scale
+ // changes: it is the one expensive thing a show does
+ private void EnsureFont(double scale)
+ {
+ if (_font != IntPtr.Zero &&
+ Math.Abs(_fontSize - _settings.FontSize) < 0.01 &&
+ Math.Abs(_fontScale - scale) < 0.01)
+ {
+ return;
+ }
+
+ ReleaseFont();
+
+ _fontSize = _settings.FontSize;
+ _fontScale = scale;
+ _font = GdiNative.CreateFont(_fontSize, scale);
+ }
+
+ private void ReleaseFont()
+ {
+ if (_font != IntPtr.Zero)
+ {
+ GdiNative.DeleteObject(_font);
+ _font = IntPtr.Zero;
+ }
+ }
+}
diff --git a/CursorLang.Agent/Windows/NativeTrayIcon.cs b/CursorLang.Agent/Windows/NativeTrayIcon.cs
new file mode 100644
index 0000000..5bddf73
--- /dev/null
+++ b/CursorLang.Agent/Windows/NativeTrayIcon.cs
@@ -0,0 +1,128 @@
+using CursorLang.Agent.Interop;
+using CursorLang.Core.Interop;
+using CursorLang.Core.Services;
+
+namespace CursorLang.Agent.Windows;
+
+///
+/// The icon in the notification area: the way to the settings window and the only way
+/// to quit the application.
+///
+///
+/// The interop half is the same as it always was — the icon has never known anything
+/// about the framework. What changed is the menu: a WPF ContextMenu obeyed the
+/// theme and the language chosen in the settings, and a TrackPopupMenuEx menu is
+/// drawn by Windows in the system look. The language still reaches it, because the
+/// captions are ours; the theme does not, and that is the price of taking the rendering
+/// stack out of the background process.
+///
+/// The captions are read each time the menu is raised rather than once: the language is
+/// changed in the settings without a restart, and the menu is built on every click
+/// anyway — a menu costs nothing to build and is asked for rarely.
+///
+internal sealed class NativeTrayIcon : 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 const int CommandSettings = 1;
+ private const int CommandExit = 2;
+
+ private readonly AgentWindow _window;
+ private readonly ILocalizationService _localization;
+
+ private IntPtr _icon;
+ private bool _isInstalled;
+
+ internal NativeTrayIcon(AgentWindow window, ILocalizationService localization)
+ {
+ _window = window;
+ _localization = localization;
+
+ _window.AddFilter(OnMessage);
+ }
+
+ internal event EventHandler? OpenRequested;
+
+ internal event EventHandler? ExitRequested;
+
+ internal bool Install()
+ {
+ if (_isInstalled)
+ {
+ return true;
+ }
+
+ _icon = TrayIconNative.LoadApplicationIcon();
+ _isInstalled = TrayIconNative.Add(_window.Handle, IconId, _icon, Tooltip);
+
+ return _isInstalled;
+ }
+
+ public void Dispose()
+ {
+ if (_isInstalled)
+ {
+ TrayIconNative.Remove(_window.Handle, IconId);
+ _isInstalled = false;
+ }
+
+ TrayIconNative.ReleaseIcon(_icon);
+ _icon = IntPtr.Zero;
+ }
+
+ private bool OnMessage(uint message, IntPtr wParam, IntPtr lParam)
+ {
+ // Explorer has restarted and taken every icon down with it
+ if (message == (uint)TrayIconNative.TaskbarCreatedMessage && _isInstalled)
+ {
+ TrayIconNative.Add(_window.Handle, IconId, _icon, Tooltip);
+ return true;
+ }
+
+ if (message != TrayIconNative.CallbackMessage)
+ {
+ return false;
+ }
+
+ switch (TrayIconNative.NotificationOf(lParam))
+ {
+ case TrayIconNative.SelectNotification:
+ case TrayIconNative.KeySelectNotification:
+ OpenRequested?.Invoke(this, EventArgs.Empty);
+ return true;
+
+ case TrayIconNative.ContextMenuNotification:
+ ShowMenu(TrayIconNative.PointOf(wParam));
+ return true;
+
+ default:
+ return false;
+ }
+ }
+
+ /// Raises the menu of the icon where the pointer is.
+ internal void ShowMenu(PopupWindowNative.Point at)
+ {
+ MenuNative.Item[] items =
+ [
+ new(CommandSettings, _localization["TrayMenuSettings"]),
+ MenuNative.Item.Separator,
+ new(CommandExit, _localization["TrayMenuExit"]),
+ ];
+
+ switch (MenuNative.Track(_window.Handle, at, items))
+ {
+ case CommandSettings:
+ OpenRequested?.Invoke(this, EventArgs.Empty);
+ break;
+
+ case CommandExit:
+ ExitRequested?.Invoke(this, EventArgs.Empty);
+ break;
+ }
+ }
+}
diff --git a/CursorLang.Agent/Windows/NativeWindow.cs b/CursorLang.Agent/Windows/NativeWindow.cs
new file mode 100644
index 0000000..fac5360
--- /dev/null
+++ b/CursorLang.Agent/Windows/NativeWindow.cs
@@ -0,0 +1,104 @@
+using CursorLang.Agent.Interop;
+
+namespace CursorLang.Agent.Windows;
+
+///
+/// A window with no framework behind it: a registered class, a handle and a window
+/// procedure that lands in .
+///
+///
+/// Windows knows one procedure per class, so the procedure here is shared and static,
+/// and finds the instance by handle. The very first message of a window arrives while
+/// CreateWindowExW is still running and there is nothing to find yet — that is
+/// what the field holding the instance under construction is for.
+///
+/// Everything is deliberately without locks: the agent has one message loop, every
+/// window belongs to it, and a window procedure can only ever be called on the thread
+/// that created the window.
+///
+internal abstract class NativeWindow : IDisposable
+{
+ private static readonly Dictionary Live = [];
+ private static readonly HashSet RegisteredClasses = new(StringComparer.Ordinal);
+
+ // The shared procedure is a static field for the same reason a hook procedure is:
+ // Windows holds the only reference to it and the collector does not see that
+ private static readonly WindowNative.WindowProc SharedProc = StaticWindowProc;
+
+ [ThreadStatic]
+ private static NativeWindow? _creating;
+
+ protected NativeWindow(string className, string title, int style, int exStyle)
+ {
+ if (RegisteredClasses.Add(className))
+ {
+ WindowNative.RegisterClass(className, SharedProc);
+ }
+
+ _creating = this;
+ try
+ {
+ Handle = WindowNative.CreateWindow(className, title, style, exStyle);
+ }
+ finally
+ {
+ _creating = null;
+ }
+
+ Live[Handle] = this;
+ }
+
+ /// The window handle. Zero once the window is gone.
+ internal IntPtr Handle { get; private set; }
+
+ public virtual void Dispose()
+ {
+ if (Handle == IntPtr.Zero)
+ {
+ return;
+ }
+
+ IntPtr handle = Handle;
+ Handle = IntPtr.Zero;
+ Live.Remove(handle);
+
+ WindowNative.DestroyWindow(handle);
+ }
+
+ ///
+ /// A message for this window. Returning false passes it to
+ /// DefWindowProcW, which is what the vast majority of messages want — and
+ /// what all of them want for a window whose content is set from the outside.
+ ///
+ protected virtual bool OnMessage(uint message, IntPtr wParam, IntPtr lParam, out IntPtr result)
+ {
+ result = IntPtr.Zero;
+ return false;
+ }
+
+ private static IntPtr StaticWindowProc(IntPtr hWnd, uint message, IntPtr wParam, IntPtr lParam)
+ {
+ if (!Live.TryGetValue(hWnd, out NativeWindow? window))
+ {
+ if (_creating is null)
+ {
+ return WindowNative.DefWindowProc(hWnd, message, wParam, lParam);
+ }
+
+ // The window is being created right now: bind the handle to the instance
+ // so that the rest of its creation messages find their way home
+ window = _creating;
+ window.Handle = hWnd;
+ Live[hWnd] = window;
+ }
+
+ if (message == WindowNative.WM_DESTROY)
+ {
+ Live.Remove(hWnd);
+ }
+
+ return window.OnMessage(message, wParam, lParam, out IntPtr result)
+ ? result
+ : WindowNative.DefWindowProc(hWnd, message, wParam, lParam);
+ }
+}
diff --git a/CursorLang/app.manifest b/CursorLang.Agent/app.manifest
similarity index 91%
rename from CursorLang/app.manifest
rename to CursorLang.Agent/app.manifest
index 68e31ae..16a7a26 100644
--- a/CursorLang/app.manifest
+++ b/CursorLang.Agent/app.manifest
@@ -1,6 +1,6 @@
-
+
diff --git a/CursorLang.Core.Tests/CursorLang.Core.Tests.csproj b/CursorLang.Core.Tests/CursorLang.Core.Tests.csproj
new file mode 100644
index 0000000..82d48cf
--- /dev/null
+++ b/CursorLang.Core.Tests/CursorLang.Core.Tests.csproj
@@ -0,0 +1,35 @@
+
+
+
+ net10.0-windows10.0.19041.0
+ 10.0.17763.0
+ enable
+ enable
+ Exe
+ AnyCPU
+ true
+ true
+ $(NoWarn);CS1591
+ false
+ true
+ CursorLang.Core.Tests
+ CursorLang.Core.Tests
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/CursorLang.Tests/Interop/CaretNativeTests.cs b/CursorLang.Core.Tests/Interop/CaretNativeTests.cs
similarity index 95%
rename from CursorLang.Tests/Interop/CaretNativeTests.cs
rename to CursorLang.Core.Tests/Interop/CaretNativeTests.cs
index 2d8a696..552c7f3 100644
--- a/CursorLang.Tests/Interop/CaretNativeTests.cs
+++ b/CursorLang.Core.Tests/Interop/CaretNativeTests.cs
@@ -1,7 +1,7 @@
-using CursorLang.Interop;
-using CursorLang.Tests.Infrastructure;
+using CursorLang.Core.Interop;
+using CursorLang.Tests.Shared;
-namespace CursorLang.Tests.Interop;
+namespace CursorLang.Core.Tests.Interop;
///
/// Vetting the caret position. Some applications report it in their own
@@ -96,7 +96,7 @@ public sealed class CaretNativeTests
[Fact]
public void Asking_the_system_for_the_caret_goes_without_errors()
{
- PopupWindowNative.Rect? caret = Sta.Run(CaretNative.TryGetCaretRect);
+ PopupWindowNative.Rect? caret = Pump.Run(CaretNative.TryGetCaretRect);
if (caret is not null)
{
diff --git a/CursorLang.Tests/Interop/LowLevelKeyboardHookTests.cs b/CursorLang.Core.Tests/Interop/LowLevelKeyboardHookTests.cs
similarity index 97%
rename from CursorLang.Tests/Interop/LowLevelKeyboardHookTests.cs
rename to CursorLang.Core.Tests/Interop/LowLevelKeyboardHookTests.cs
index 2216608..3f7cc55 100644
--- a/CursorLang.Tests/Interop/LowLevelKeyboardHookTests.cs
+++ b/CursorLang.Core.Tests/Interop/LowLevelKeyboardHookTests.cs
@@ -1,9 +1,9 @@
using System.Reflection;
using System.Runtime.InteropServices;
-using CursorLang.Interop;
-using CursorLang.Tests.Infrastructure;
+using CursorLang.Core.Interop;
+using CursorLang.Tests.Shared;
-namespace CursorLang.Tests.Interop;
+namespace CursorLang.Core.Tests.Interop;
///
/// Making sense of the events of the system keyboard hook.
@@ -126,7 +126,7 @@ public sealed class LowLevelKeyboardHookTests
[Fact]
public void The_interception_is_installed_and_removed()
{
- Sta.Run(() =>
+ Pump.Run(() =>
{
using var hook = new LowLevelKeyboardHook(static (_, _) => false);
@@ -143,7 +143,7 @@ public sealed class LowLevelKeyboardHookTests
[Fact]
public void Installing_again_changes_nothing()
{
- Sta.Run(() =>
+ Pump.Run(() =>
{
using var hook = new LowLevelKeyboardHook(static (_, _) => false);
@@ -169,7 +169,7 @@ public sealed class LowLevelKeyboardHookTests
[Fact]
public void Closing_removes_the_interception()
{
- Sta.Run(() =>
+ Pump.Run(() =>
{
var hook = new LowLevelKeyboardHook(static (_, _) => false);
hook.Install();
diff --git a/CursorLang.Tests/Models/AppSettingsTests.cs b/CursorLang.Core.Tests/Models/AppSettingsTests.cs
similarity index 94%
rename from CursorLang.Tests/Models/AppSettingsTests.cs
rename to CursorLang.Core.Tests/Models/AppSettingsTests.cs
index 13db438..9b50559 100644
--- a/CursorLang.Tests/Models/AppSettingsTests.cs
+++ b/CursorLang.Core.Tests/Models/AppSettingsTests.cs
@@ -1,10 +1,10 @@
using System.ComponentModel;
+using System.Drawing;
using System.Reflection;
using System.Text.Json;
-using System.Windows.Media;
-using CursorLang.Models;
+using CursorLang.Core.Models;
-namespace CursorLang.Tests.Models;
+namespace CursorLang.Core.Tests.Models;
public sealed class AppSettingsTests
{
@@ -26,8 +26,8 @@ public sealed class AppSettingsTests
Assert.Equal(0.9, settings.Opacity);
Assert.Equal(500, settings.DurationMilliseconds);
Assert.Equal(300, settings.CapsLockHoldMilliseconds);
- Assert.Equal(Color.FromRgb(0x20, 0x20, 0x20), settings.BackgroundColor);
- Assert.Equal(Color.FromRgb(0xFF, 0xFF, 0xFF), settings.ForegroundColor);
+ Assert.Equal(Color.FromArgb(0x20, 0x20, 0x20), settings.BackgroundColor);
+ Assert.Equal(Color.FromArgb(0xFF, 0xFF, 0xFF), settings.ForegroundColor);
}
// The app must not change how the system behaves until it is asked to
@@ -135,7 +135,7 @@ public sealed class AppSettingsTests
string text => text + "-other",
double number => number + 1,
bool flag => !flag,
- Color color => Color.FromRgb((byte)(color.R + 1), color.G, color.B),
+ Color color => Color.FromArgb((byte)(color.R + 1), color.G, color.B),
Enum value => NextEnumValue(value),
DateTimeOffset moment => moment.AddDays(1),
diff --git a/CursorLang.Tests/Models/KeyboardLayoutTests.cs b/CursorLang.Core.Tests/Models/KeyboardLayoutTests.cs
similarity index 96%
rename from CursorLang.Tests/Models/KeyboardLayoutTests.cs
rename to CursorLang.Core.Tests/Models/KeyboardLayoutTests.cs
index b471f03..3552f9b 100644
--- a/CursorLang.Tests/Models/KeyboardLayoutTests.cs
+++ b/CursorLang.Core.Tests/Models/KeyboardLayoutTests.cs
@@ -1,7 +1,7 @@
using System.Globalization;
-using CursorLang.Models;
+using CursorLang.Core.Models;
-namespace CursorLang.Tests.Models;
+namespace CursorLang.Core.Tests.Models;
public sealed class KeyboardLayoutTests
{
diff --git a/CursorLang.Tests/Models/LayoutChangedEventArgsTests.cs b/CursorLang.Core.Tests/Models/LayoutChangedEventArgsTests.cs
similarity index 91%
rename from CursorLang.Tests/Models/LayoutChangedEventArgsTests.cs
rename to CursorLang.Core.Tests/Models/LayoutChangedEventArgsTests.cs
index 9110c7a..913f776 100644
--- a/CursorLang.Tests/Models/LayoutChangedEventArgsTests.cs
+++ b/CursorLang.Core.Tests/Models/LayoutChangedEventArgsTests.cs
@@ -1,6 +1,6 @@
-using CursorLang.Models;
+using CursorLang.Core.Models;
-namespace CursorLang.Tests.Models;
+namespace CursorLang.Core.Tests.Models;
public sealed class LayoutChangedEventArgsTests
{
diff --git a/CursorLang.Tests/Resources/StringsTests.cs b/CursorLang.Core.Tests/Resources/StringsTests.cs
similarity index 69%
rename from CursorLang.Tests/Resources/StringsTests.cs
rename to CursorLang.Core.Tests/Resources/StringsTests.cs
index cd12217..0fe2475 100644
--- a/CursorLang.Tests/Resources/StringsTests.cs
+++ b/CursorLang.Core.Tests/Resources/StringsTests.cs
@@ -1,21 +1,19 @@
using System.Collections;
using System.Globalization;
-using System.IO;
-using System.Reflection;
using System.Resources;
-using System.Text.RegularExpressions;
-using CursorLang.Models;
+using CursorLang.Core.Models;
+using CursorLang.Core.Services;
-namespace CursorLang.Tests.Resources;
+namespace CursorLang.Core.Tests.Resources;
///
/// Checks of the resources themselves: they carry every caption in the settings
/// window, and a missing key only shows on a live window.
///
-public sealed partial class StringsTests
+public sealed class StringsTests
{
private static readonly ResourceManager Resources =
- new("CursorLang.Resources.Strings", typeof(App).Assembly);
+ new("CursorLang.Core.Resources.Strings", typeof(LocalizationService).Assembly);
private static readonly CultureInfo English = CultureInfo.GetCultureInfo("en");
private static readonly CultureInfo Russian = CultureInfo.GetCultureInfo("ru");
@@ -80,28 +78,6 @@ public sealed partial class StringsTests
}
}
- ///
- /// Every key the settings window markup asks for has to exist in the
- /// resources: otherwise the user sees the key itself in its place.
- ///
- [Fact]
- public void Every_key_from_the_settings_window_markup_exists_in_the_resources()
- {
- HashSet known = [.. NeutralKeys()];
- List missing = [];
-
- foreach (Match match in LocalizationBinding().Matches(ReadSettingsWindowMarkup()))
- {
- string key = match.Groups["key"].Value;
- if (!known.Contains(key))
- {
- missing.Add(key);
- }
- }
-
- Assert.Empty(missing);
- }
-
///
/// The version of an update is put into the string by the app, so the place
/// for it has to be there in both languages.
@@ -113,13 +89,6 @@ public sealed partial class StringsTests
Assert.Contains("{0}", Resources.GetString("UpdateAvailable", Russian), StringComparison.Ordinal);
}
- /// The markup does ask for strings — otherwise the check above means nothing.
- [Fact]
- public void The_settings_window_markup_asks_for_resource_strings()
- {
- Assert.NotEmpty(LocalizationBinding().Matches(ReadSettingsWindowMarkup()));
- }
-
public static TheoryData EnumKeys()
{
var data = new TheoryData();
@@ -164,15 +133,4 @@ public sealed partial class StringsTests
private static ResourceSet RussianSet() =>
Resources.GetResourceSet(Russian, createIfNotExists: true, tryParents: false)!;
- private static string ReadSettingsWindowMarkup()
- {
- using Stream stream = Assembly.GetExecutingAssembly().GetManifestResourceStream("MainWindow.xaml")
- ?? throw new InvalidOperationException("The settings window markup is not embedded in the test assembly");
-
- using var reader = new StreamReader(stream);
- return reader.ReadToEnd();
- }
-
- [GeneratedRegex(@"Localization\[(?\w+)\]")]
- private static partial Regex LocalizationBinding();
}
diff --git a/CursorLang.Tests/Services/CapsLockSwitchCoordinatorTests.cs b/CursorLang.Core.Tests/Services/CapsLockSwitchCoordinatorTests.cs
similarity index 97%
rename from CursorLang.Tests/Services/CapsLockSwitchCoordinatorTests.cs
rename to CursorLang.Core.Tests/Services/CapsLockSwitchCoordinatorTests.cs
index 1f8bd4a..9961671 100644
--- a/CursorLang.Tests/Services/CapsLockSwitchCoordinatorTests.cs
+++ b/CursorLang.Core.Tests/Services/CapsLockSwitchCoordinatorTests.cs
@@ -1,8 +1,8 @@
-using CursorLang.Models;
-using CursorLang.Services;
-using CursorLang.Tests.Infrastructure;
+using CursorLang.Core.Models;
+using CursorLang.Core.Services;
+using CursorLang.Tests.Shared;
-namespace CursorLang.Tests.Services;
+namespace CursorLang.Core.Tests.Services;
///
/// What happens on Caps Lock presses and how the interception follows the setting.
diff --git a/CursorLang.Tests/Services/GiteaReleaseFeedTests.cs b/CursorLang.Core.Tests/Services/GiteaReleaseFeedTests.cs
similarity index 98%
rename from CursorLang.Tests/Services/GiteaReleaseFeedTests.cs
rename to CursorLang.Core.Tests/Services/GiteaReleaseFeedTests.cs
index 36f80eb..7ffe52b 100644
--- a/CursorLang.Tests/Services/GiteaReleaseFeedTests.cs
+++ b/CursorLang.Core.Tests/Services/GiteaReleaseFeedTests.cs
@@ -1,11 +1,10 @@
using System.Net;
-using System.Net.Http;
using System.Runtime.InteropServices;
-using CursorLang.Models;
-using CursorLang.Services;
-using CursorLang.Tests.Infrastructure;
+using CursorLang.Core.Models;
+using CursorLang.Core.Services;
+using CursorLang.Tests.Shared;
-namespace CursorLang.Tests.Services;
+namespace CursorLang.Core.Tests.Services;
///
/// Reading the release list of Gitea. The answer of the server is not ours to
diff --git a/CursorLang.Tests/Services/KeyboardLayoutServiceTests.cs b/CursorLang.Core.Tests/Services/KeyboardLayoutServiceTests.cs
similarity index 81%
rename from CursorLang.Tests/Services/KeyboardLayoutServiceTests.cs
rename to CursorLang.Core.Tests/Services/KeyboardLayoutServiceTests.cs
index 048ba7e..357ef21 100644
--- a/CursorLang.Tests/Services/KeyboardLayoutServiceTests.cs
+++ b/CursorLang.Core.Tests/Services/KeyboardLayoutServiceTests.cs
@@ -1,9 +1,9 @@
using System.Collections.Concurrent;
-using CursorLang.Models;
-using CursorLang.Services;
-using CursorLang.Tests.Infrastructure;
+using CursorLang.Core.Models;
+using CursorLang.Core.Services;
+using CursorLang.Tests.Shared;
-namespace CursorLang.Tests.Services;
+namespace CursorLang.Core.Tests.Services;
///
/// Watching the layout of the foreground window. The test supplies what the
@@ -48,9 +48,9 @@ public sealed class KeyboardLayoutServiceTests
using var world = new World { LocaleId = English };
KeyboardLayoutService service = world.CreateService();
- Sta.Run(service.Start);
+ Pump.Run(service.Start);
world.LocaleId = Russian;
- Sta.Run(service.Poll);
+ Pump.Run(service.Poll);
LayoutChangedEventArgs change = Assert.Single(world.Changes);
Assert.Equal(LayoutChangeReason.UserSwitched, change.Reason);
@@ -63,11 +63,11 @@ public sealed class KeyboardLayoutServiceTests
using var world = new World { LocaleId = English };
KeyboardLayoutService service = world.CreateService();
- Sta.Run(service.Start);
+ Pump.Run(service.Start);
world.ForegroundWindow = SecondWindow;
world.LocaleId = Russian;
- Sta.Run(service.Poll);
+ Pump.Run(service.Poll);
LayoutChangedEventArgs change = Assert.Single(world.Changes);
Assert.Equal(LayoutChangeReason.ApplicationSwitched, change.Reason);
@@ -80,10 +80,10 @@ public sealed class KeyboardLayoutServiceTests
using var world = new World { LocaleId = English };
KeyboardLayoutService service = world.CreateService();
- Sta.Run(service.Start);
+ Pump.Run(service.Start);
world.ForegroundWindow = SecondWindow;
- Sta.Run(service.Poll);
+ Pump.Run(service.Poll);
Assert.Empty(world.Changes);
}
@@ -94,11 +94,11 @@ public sealed class KeyboardLayoutServiceTests
using var world = new World { LocaleId = Russian };
KeyboardLayoutService service = world.CreateService();
- Sta.Run(service.Start);
+ Pump.Run(service.Start);
for (int i = 0; i < 5; i++)
{
- Sta.Run(service.Poll);
+ Pump.Run(service.Poll);
}
Assert.Empty(world.Changes);
@@ -111,17 +111,17 @@ public sealed class KeyboardLayoutServiceTests
using var world = new World { LocaleId = English };
KeyboardLayoutService service = world.CreateService();
- Sta.Run(service.Start);
+ Pump.Run(service.Start);
world.ForegroundWindow = IntPtr.Zero;
world.LocaleId = Russian;
- Sta.Run(service.Poll);
+ Pump.Run(service.Poll);
Assert.Empty(world.Changes);
// And the layout was not remembered: the change is noticed once a window is back
world.ForegroundWindow = FirstWindow;
- Sta.Run(service.Poll);
+ Pump.Run(service.Poll);
Assert.Single(world.Changes);
}
@@ -132,12 +132,11 @@ public sealed class KeyboardLayoutServiceTests
using var world = new World { LocaleId = English };
KeyboardLayoutService service = world.CreateService();
- Sta.Run(service.Start);
+ Pump.Run(service.Start);
world.LocaleId = Russian;
- Sta.Run(service.Poll);
- Sta.Run(service.Poll);
- Sta.Run(service.Poll);
+ Pump.Run(service.Poll);
+ Pump.Run(service.Poll);
Assert.Single(world.Changes);
}
@@ -149,8 +148,8 @@ public sealed class KeyboardLayoutServiceTests
KeyboardLayoutService service = world.CreateService();
world.LocaleId = Russian;
- Sta.Run(service.Start);
- Sta.Run(service.Poll);
+ Pump.Run(service.Start);
+ Pump.Run(service.Poll);
// Start remembered the layout that was in place at that moment
Assert.Empty(world.Changes);
@@ -162,10 +161,10 @@ public sealed class KeyboardLayoutServiceTests
using var world = new World { LocaleId = English };
KeyboardLayoutService service = world.CreateService(TimeSpan.FromMilliseconds(15));
- Sta.Run(service.Start);
+ Pump.Run(service.Start);
world.LocaleId = Russian;
- Sta.WaitFor(() => !world.Changes.IsEmpty, "the timer noticed the layout change");
+ Pump.WaitFor(() => !world.Changes.IsEmpty, "the timer noticed the layout change");
}
[Fact]
@@ -174,11 +173,11 @@ public sealed class KeyboardLayoutServiceTests
using var world = new World { LocaleId = English };
KeyboardLayoutService service = world.CreateService(TimeSpan.FromMilliseconds(15));
- Sta.Run(service.Start);
- Sta.Run(service.Stop);
+ Pump.Run(service.Start);
+ Pump.Run(service.Stop);
world.LocaleId = Russian;
- Sta.Pause(TimeSpan.FromMilliseconds(120));
+ Pump.Pause(TimeSpan.FromMilliseconds(120));
Assert.Empty(world.Changes);
}
@@ -189,11 +188,11 @@ public sealed class KeyboardLayoutServiceTests
using var world = new World { LocaleId = English };
KeyboardLayoutService service = world.CreateService(TimeSpan.FromMilliseconds(15));
- Sta.Run(service.Start);
- Sta.Run(service.Dispose);
+ Pump.Run(service.Start);
+ Pump.Run(service.Dispose);
world.LocaleId = Russian;
- Sta.Pause(TimeSpan.FromMilliseconds(120));
+ Pump.Pause(TimeSpan.FromMilliseconds(120));
Assert.Empty(world.Changes);
}
@@ -204,34 +203,34 @@ public sealed class KeyboardLayoutServiceTests
using var world = new World { LocaleId = English };
KeyboardLayoutService service = world.CreateService(TimeSpan.FromMilliseconds(15));
- Sta.Run(service.Start);
- Sta.Run(service.Stop);
- Sta.Run(service.Start);
+ Pump.Run(service.Start);
+ Pump.Run(service.Stop);
+ Pump.Run(service.Start);
world.LocaleId = Russian;
- Sta.WaitFor(() => !world.Changes.IsEmpty, "the watch resumed");
+ Pump.WaitFor(() => !world.Changes.IsEmpty, "the watch resumed");
}
// The ordinary service asks Windows itself about the layout
[Fact]
public void The_service_can_work_with_the_real_system()
{
- KeyboardLayoutService service = Sta.Run(() =>
+ KeyboardLayoutService service = Pump.Run(() =>
new KeyboardLayoutService(new KeyboardLayoutOptions { PollInterval = TimeSpan.FromHours(1) }));
try
{
- Sta.Run(service.Start);
- Sta.Run(service.Poll);
+ Pump.Run(service.Start);
+ Pump.Run(service.Poll);
Assert.InRange(service.Current.LocaleId, 1, 0xFFFF);
- Sta.Run(service.Stop);
+ Pump.Run(service.Stop);
}
finally
{
- Sta.Run(service.Dispose);
+ Pump.Run(service.Dispose);
}
}
@@ -260,13 +259,12 @@ public sealed class KeyboardLayoutServiceTests
internal KeyboardLayoutService CreateService(TimeSpan? pollInterval = null)
{
- // An hour between ticks means the poll only runs when the test asks for it
var options = new KeyboardLayoutOptions
{
PollInterval = pollInterval ?? TimeSpan.FromHours(1),
};
- _service = Sta.Run(() => new KeyboardLayoutService(
+ _service = Pump.Run(() => new KeyboardLayoutService(
options,
() => ForegroundWindow,
() => LocaleId,
@@ -281,7 +279,7 @@ public sealed class KeyboardLayoutServiceTests
{
if (_service is not null)
{
- Sta.Run(_service.Dispose);
+ Pump.Run(_service.Dispose);
}
}
}
diff --git a/CursorLang.Tests/Services/LayoutNotificationCoordinatorTests.cs b/CursorLang.Core.Tests/Services/LayoutNotificationCoordinatorTests.cs
similarity index 96%
rename from CursorLang.Tests/Services/LayoutNotificationCoordinatorTests.cs
rename to CursorLang.Core.Tests/Services/LayoutNotificationCoordinatorTests.cs
index c13e886..e0db2d3 100644
--- a/CursorLang.Tests/Services/LayoutNotificationCoordinatorTests.cs
+++ b/CursorLang.Core.Tests/Services/LayoutNotificationCoordinatorTests.cs
@@ -1,8 +1,8 @@
-using CursorLang.Models;
-using CursorLang.Services;
-using CursorLang.Tests.Infrastructure;
+using CursorLang.Core.Models;
+using CursorLang.Core.Services;
+using CursorLang.Tests.Shared;
-namespace CursorLang.Tests.Services;
+namespace CursorLang.Core.Tests.Services;
///
/// The link between watching the layout and showing the tooltip.
diff --git a/CursorLang.Tests/Services/LocalizationServiceTests.cs b/CursorLang.Core.Tests/Services/LocalizationServiceTests.cs
similarity index 98%
rename from CursorLang.Tests/Services/LocalizationServiceTests.cs
rename to CursorLang.Core.Tests/Services/LocalizationServiceTests.cs
index dbac3e3..dd2e45e 100644
--- a/CursorLang.Tests/Services/LocalizationServiceTests.cs
+++ b/CursorLang.Core.Tests/Services/LocalizationServiceTests.cs
@@ -1,9 +1,9 @@
using System.ComponentModel;
using System.Globalization;
using System.Windows.Data;
-using CursorLang.Services;
+using CursorLang.Core.Services;
-namespace CursorLang.Tests.Services;
+namespace CursorLang.Core.Tests.Services;
public sealed class LocalizationServiceTests
{
diff --git a/CursorLang.Tests/Services/PopupLayoutTests.cs b/CursorLang.Core.Tests/Services/PopupLayoutTests.cs
similarity index 98%
rename from CursorLang.Tests/Services/PopupLayoutTests.cs
rename to CursorLang.Core.Tests/Services/PopupLayoutTests.cs
index ed5b0cb..2c0df54 100644
--- a/CursorLang.Tests/Services/PopupLayoutTests.cs
+++ b/CursorLang.Core.Tests/Services/PopupLayoutTests.cs
@@ -1,8 +1,8 @@
-using CursorLang.Interop;
-using CursorLang.Models;
-using CursorLang.Services;
+using CursorLang.Core.Interop;
+using CursorLang.Core.Models;
+using CursorLang.Core.Services;
-namespace CursorLang.Tests.Services;
+namespace CursorLang.Core.Tests.Services;
///
/// The placement maths for the tooltip. This is the easiest place to get a sign
diff --git a/CursorLang.Tests/Services/RegistryStartupTests.cs b/CursorLang.Core.Tests/Services/RegistryStartupTests.cs
similarity index 97%
rename from CursorLang.Tests/Services/RegistryStartupTests.cs
rename to CursorLang.Core.Tests/Services/RegistryStartupTests.cs
index 5e01baa..57f0286 100644
--- a/CursorLang.Tests/Services/RegistryStartupTests.cs
+++ b/CursorLang.Core.Tests/Services/RegistryStartupTests.cs
@@ -1,9 +1,9 @@
-using CursorLang.Models;
-using CursorLang.Services;
-using CursorLang.Tests.Infrastructure;
+using CursorLang.Core.Models;
+using CursorLang.Core.Services;
+using CursorLang.Tests.Shared;
using Microsoft.Win32;
-namespace CursorLang.Tests.Services;
+namespace CursorLang.Core.Tests.Services;
///
/// Startup of a build unpacked into a folder: a value under the Run key. The tests
diff --git a/CursorLang.Tests/Services/SettingsServiceTests.cs b/CursorLang.Core.Tests/Services/SettingsServiceTests.cs
similarity index 70%
rename from CursorLang.Tests/Services/SettingsServiceTests.cs
rename to CursorLang.Core.Tests/Services/SettingsServiceTests.cs
index 77bcf02..5b80847 100644
--- a/CursorLang.Tests/Services/SettingsServiceTests.cs
+++ b/CursorLang.Core.Tests/Services/SettingsServiceTests.cs
@@ -1,16 +1,16 @@
+using System.Drawing;
using System.Globalization;
-using System.IO;
using System.Text.Json;
-using System.Windows.Media;
-using CursorLang.Models;
-using CursorLang.Services;
-using CursorLang.Tests.Infrastructure;
-using CursorLang.Tests.Models;
+using CursorLang.Core.Models;
+using CursorLang.Core.Services;
+using CursorLang.Core.Tests.Models;
+using CursorLang.Tests.Shared;
-namespace CursorLang.Tests.Services;
+namespace CursorLang.Core.Tests.Services;
///
-/// Keeping the settings in a file. Everything happens in a temporary folder:
+/// Keeping the settings in a file. Only the settings window writes, and it asks for
+/// that with TrackChanges; the agent loads the same file and never saves. Everything happens in a temporary folder:
/// the tests have no business touching the user's own settings.
///
public sealed class SettingsServiceTests
@@ -23,7 +23,7 @@ public sealed class SettingsServiceTests
{
using var folder = new TempFolder();
- AppSettings settings = Sta.Run(() =>
+ AppSettings settings = Pump.Run(() =>
{
using SettingsService service = Create(folder);
return service.Load();
@@ -43,7 +43,7 @@ public sealed class SettingsServiceTests
{
using var folder = new TempFolder();
- AppSettings settings = Sta.Run(() =>
+ AppSettings settings = Pump.Run(() =>
{
CultureInfo previous = CultureInfo.CurrentUICulture;
try
@@ -67,7 +67,7 @@ public sealed class SettingsServiceTests
{
using var folder = new TempFolder();
- Sta.Run(() =>
+ Pump.Run(() =>
{
using SettingsService service = Create(folder);
AppSettings settings = service.Load();
@@ -75,13 +75,13 @@ public sealed class SettingsServiceTests
settings.FontSize = 42;
settings.Theme = AppTheme.Dark;
settings.PlacementMode = PopupPlacementMode.AtCaret;
- settings.BackgroundColor = Color.FromRgb(0x11, 0x22, 0x33);
+ settings.BackgroundColor = Color.FromArgb(0x11, 0x22, 0x33);
settings.UseCapsLockHotkey = true;
service.Save();
});
- AppSettings restored = Sta.Run(() =>
+ AppSettings restored = Pump.Run(() =>
{
using SettingsService service = Create(folder);
return service.Load();
@@ -90,7 +90,7 @@ public sealed class SettingsServiceTests
Assert.Equal(42, restored.FontSize);
Assert.Equal(AppTheme.Dark, restored.Theme);
Assert.Equal(PopupPlacementMode.AtCaret, restored.PlacementMode);
- Assert.Equal(Color.FromRgb(0x11, 0x22, 0x33), restored.BackgroundColor);
+ Assert.Equal(Color.FromArgb(0x11, 0x22, 0x33), restored.BackgroundColor);
Assert.True(restored.UseCapsLockHotkey);
}
@@ -99,12 +99,12 @@ public sealed class SettingsServiceTests
{
using var folder = new TempFolder();
- Sta.Run(() =>
+ Pump.Run(() =>
{
using SettingsService service = Create(folder);
AppSettings settings = service.Load();
settings.Theme = AppTheme.Dark;
- settings.BackgroundColor = Color.FromRgb(0x20, 0x20, 0x20);
+ settings.BackgroundColor = Color.FromArgb(0x20, 0x20, 0x20);
service.Save();
});
@@ -124,17 +124,18 @@ public sealed class SettingsServiceTests
using var folder = new TempFolder();
string path = folder.File("settings.json");
- Sta.Run(() =>
+ Pump.Run(() =>
{
using SettingsService service = Create(folder);
AppSettings settings = service.Load();
+ service.TrackChanges();
settings.FontSize = 33;
// Right after the edit there is nothing on disk yet: the write is deferred
Assert.False(File.Exists(path));
- Sta.WaitFor(() => File.Exists(path), "the settings were written by the timer");
+ Pump.WaitFor(() => File.Exists(path), "the settings were written by the timer");
});
Assert.Contains("\"FontSize\": 33", File.ReadAllText(path), StringComparison.Ordinal);
@@ -147,19 +148,114 @@ public sealed class SettingsServiceTests
using var folder = new TempFolder();
string path = folder.File("settings.json");
- Sta.Run(() =>
+ Pump.Run(() =>
{
using SettingsService service = new(path, folder.File("inherited.json"), TimeSpan.FromMilliseconds(150));
AppSettings settings = service.Load();
+ service.TrackChanges();
for (int i = 0; i < 10; i++)
{
settings.Opacity = 0.5 + (i * 0.01);
Assert.False(File.Exists(path));
- Sta.Pause(TimeSpan.FromMilliseconds(10));
+ Pump.Pause(TimeSpan.FromMilliseconds(10));
}
- Sta.WaitFor(() => File.Exists(path), "the write happened after the pause in edits");
+ Pump.WaitFor(() => File.Exists(path), "the write happened after the pause in edits");
+ });
+ }
+
+ ///
+ /// Asking to track changes before reading the file still tracks them.
+ ///
+ ///
+ /// The settings window asks in exactly that order: its container hands out the
+ /// service first and the settings only when something needs them. A version of this
+ /// that quietly did nothing when the file had not been read yet left the window
+ /// saving nothing at all — neither while it was open nor when it was closed.
+ ///
+ [Fact]
+ public void Tracking_asked_for_before_the_file_is_read_still_saves()
+ {
+ using var folder = new TempFolder();
+ string path = folder.File("settings.json");
+
+ Pump.Run(() =>
+ {
+ using SettingsService service = Create(folder);
+
+ // Before Load, the way the settings window does it
+ service.TrackChanges();
+
+ AppSettings settings = service.Load();
+ settings.FontSize = 29;
+
+ Pump.WaitFor(() => File.Exists(path), "the settings were written by the timer");
+ });
+
+ Assert.Contains("\"FontSize\": 29", File.ReadAllText(path), StringComparison.Ordinal);
+ }
+
+ // Two reads would mean two instances, and the window would edit one while the
+ // service saved the other
+ [Fact]
+ public void Reading_twice_hands_out_the_same_settings()
+ {
+ using var folder = new TempFolder();
+
+ Pump.Run(() =>
+ {
+ using SettingsService service = Create(folder);
+
+ Assert.Same(service.Load(), service.Load());
+ });
+ }
+
+ ///
+ /// Re-reading pours the file into the instance everything is already bound to.
+ ///
+ ///
+ /// This is the agent's whole side of the connection: the settings window writes and
+ /// says so, and the agent calls this. Replacing the instance instead of filling it
+ /// would leave the popup, the hook and the timers bound to the old one.
+ ///
+ [Fact]
+ public void Re_reading_lands_in_the_settings_already_in_hand()
+ {
+ using var folder = new TempFolder();
+ string path = folder.File("settings.json");
+
+ Pump.Run(() =>
+ {
+ using SettingsService service = Create(folder);
+ AppSettings settings = service.Load();
+
+ File.WriteAllText(path, """{"FontSize": 31, "BackgroundColor": "#FF102030"}""");
+ service.Reload();
+
+ Assert.Equal(31, settings.FontSize);
+ Assert.Equal(Color.FromArgb(0x10, 0x20, 0x30), settings.BackgroundColor);
+ });
+ }
+
+ // A file that has gone missing or turned to nonsense leaves the settings alone:
+ // showing the popup with yesterday's colours beats showing it with none
+ [Fact]
+ public void Re_reading_an_unreadable_file_keeps_what_was_already_there()
+ {
+ using var folder = new TempFolder();
+ string path = folder.File("settings.json");
+
+ Pump.Run(() =>
+ {
+ using SettingsService service = Create(folder);
+ AppSettings settings = service.Load();
+ settings.FontSize = 44;
+
+ File.WriteAllText(path, "not json at all");
+ service.Reload();
+
+ Assert.Equal(44, settings.FontSize);
});
}
@@ -168,10 +264,11 @@ public sealed class SettingsServiceTests
{
using var folder = new TempFolder();
- Sta.Run(() =>
+ Pump.Run(() =>
{
SettingsService service = Create(folder);
AppSettings settings = service.Load();
+ service.TrackChanges();
settings.FontSize = 27;
service.Dispose();
@@ -189,16 +286,17 @@ public sealed class SettingsServiceTests
using var folder = new TempFolder();
string path = folder.File("settings.json");
- Sta.Run(() =>
+ Pump.Run(() =>
{
SettingsService service = Create(folder);
AppSettings settings = service.Load();
+ service.TrackChanges();
service.Dispose();
string afterDispose = File.ReadAllText(path);
settings.FontSize = 99;
- Sta.Pause(TimeSpan.FromMilliseconds(60));
+ Pump.Pause(TimeSpan.FromMilliseconds(60));
Assert.Equal(afterDispose, File.ReadAllText(path));
});
@@ -213,7 +311,7 @@ public sealed class SettingsServiceTests
File.WriteAllText(inherited, """{"FontSize": 31, "Language": "ru"}""");
- AppSettings settings = Sta.Run(() =>
+ AppSettings settings = Pump.Run(() =>
{
using SettingsService service = new(own, inherited, SaveDelay);
return service.Load();
@@ -237,7 +335,7 @@ public sealed class SettingsServiceTests
File.WriteAllText(inherited, original);
- Sta.Run(() =>
+ Pump.Run(() =>
{
using SettingsService service = new(folder.File("settings.json"), inherited, SaveDelay);
_ = service.Load();
@@ -256,7 +354,7 @@ public sealed class SettingsServiceTests
File.WriteAllText(own, """{"FontSize": 12}""");
File.WriteAllText(inherited, """{"FontSize": 31}""");
- AppSettings settings = Sta.Run(() =>
+ AppSettings settings = Pump.Run(() =>
{
using SettingsService service = new(own, inherited, SaveDelay);
return service.Load();
@@ -272,7 +370,7 @@ public sealed class SettingsServiceTests
using var folder = new TempFolder();
string path = folder.File("settings.json");
- Sta.Run(() =>
+ Pump.Run(() =>
{
using SettingsService service = new(path, path, SaveDelay);
_ = service.Load();
@@ -288,7 +386,7 @@ public sealed class SettingsServiceTests
using var folder = new TempFolder();
File.WriteAllText(folder.File("settings.json"), "{this is not json");
- AppSettings settings = Sta.Run(() =>
+ AppSettings settings = Pump.Run(() =>
{
using SettingsService service = Create(folder);
return service.Load();
@@ -303,7 +401,7 @@ public sealed class SettingsServiceTests
using var folder = new TempFolder();
File.WriteAllText(folder.File("settings.json"), """{"FontSize": 15, "SomethingNew": true}""");
- AppSettings settings = Sta.Run(() =>
+ AppSettings settings = Pump.Run(() =>
{
using SettingsService service = Create(folder);
return service.Load();
@@ -321,13 +419,13 @@ public sealed class SettingsServiceTests
using var folder = new TempFolder();
File.WriteAllText(folder.File("settings.json"), $$"""{"BackgroundColor": {{stored}}}""");
- AppSettings settings = Sta.Run(() =>
+ AppSettings settings = Pump.Run(() =>
{
using SettingsService service = Create(folder);
return service.Load();
});
- Assert.Equal(Color.FromRgb(r, g, b), settings.BackgroundColor);
+ Assert.Equal(Color.FromArgb(r, g, b), settings.BackgroundColor);
}
[Theory]
@@ -339,13 +437,13 @@ public sealed class SettingsServiceTests
using var folder = new TempFolder();
File.WriteAllText(folder.File("settings.json"), $$"""{"BackgroundColor": {{stored}}}""");
- AppSettings settings = Sta.Run(() =>
+ AppSettings settings = Pump.Run(() =>
{
using SettingsService service = Create(folder);
return service.Load();
});
- Assert.Equal(Colors.Black, settings.BackgroundColor);
+ Assert.Equal(Color.Black, settings.BackgroundColor);
}
// The service creates the settings folder itself
@@ -355,7 +453,7 @@ public sealed class SettingsServiceTests
using var folder = new TempFolder();
string nested = Path.Combine(folder.Path, "a", "b", "settings.json");
- Sta.Run(() =>
+ Pump.Run(() =>
{
using SettingsService service = new(nested, folder.File("inherited.json"), SaveDelay);
_ = service.Load();
@@ -375,7 +473,7 @@ public sealed class SettingsServiceTests
string path = folder.File("settings.json");
Directory.CreateDirectory(path);
- Sta.Run(() =>
+ Pump.Run(() =>
{
using SettingsService service = new(path, folder.File("inherited.json"), SaveDelay);
AppSettings settings = service.Load();
@@ -392,7 +490,7 @@ public sealed class SettingsServiceTests
{
using var folder = new TempFolder();
- Sta.Run(() =>
+ Pump.Run(() =>
{
using SettingsService service = Create(folder);
service.Save();
@@ -406,7 +504,7 @@ public sealed class SettingsServiceTests
{
using var folder = new TempFolder();
- Sta.Run(() =>
+ Pump.Run(() =>
{
SettingsService service = Create(folder);
service.Dispose();
@@ -420,7 +518,7 @@ public sealed class SettingsServiceTests
{
using var folder = new TempFolder();
- Sta.Run(() =>
+ Pump.Run(() =>
{
using SettingsService service = Create(folder);
_ = service.Load();
@@ -441,7 +539,7 @@ public sealed class SettingsServiceTests
[Fact]
public void The_storage_place_is_chosen_on_its_own()
{
- Sta.Run(() =>
+ Pump.Run(() =>
{
// Nothing is read and nothing is written: only the fact that a path
// gets chosen without error is under test
diff --git a/CursorLang.Tests/Services/SingleInstanceGateTests.cs b/CursorLang.Core.Tests/Services/SingleInstanceGateTests.cs
similarity index 62%
rename from CursorLang.Tests/Services/SingleInstanceGateTests.cs
rename to CursorLang.Core.Tests/Services/SingleInstanceGateTests.cs
index e559f93..4947369 100644
--- a/CursorLang.Tests/Services/SingleInstanceGateTests.cs
+++ b/CursorLang.Core.Tests/Services/SingleInstanceGateTests.cs
@@ -1,8 +1,8 @@
using System.Collections.Concurrent;
-using CursorLang.Services;
-using CursorLang.Tests.Infrastructure;
+using CursorLang.Core.Services;
+using CursorLang.Tests.Shared;
-namespace CursorLang.Tests.Services;
+namespace CursorLang.Core.Tests.Services;
///
/// The place of the single instance. The kernel object names in the tests are
@@ -14,15 +14,15 @@ public sealed class SingleInstanceGateTests
public void The_first_run_takes_the_place()
{
string suffix = UniqueSuffix();
- SingleInstanceGate gate = Sta.Run(() => new SingleInstanceGate(suffix));
+ SingleInstanceGate gate = Pump.Run(() => new SingleInstanceGate(suffix));
try
{
- Assert.True(Sta.Run(gate.TryAcquire));
+ Assert.True(Pump.Run(gate.TryAcquire));
}
finally
{
- Sta.Run(gate.Dispose);
+ Pump.Run(gate.Dispose);
}
}
@@ -30,16 +30,16 @@ public sealed class SingleInstanceGateTests
public void The_second_run_does_not_get_the_place()
{
string suffix = UniqueSuffix();
- SingleInstanceGate first = Sta.Run(() => new SingleInstanceGate(suffix));
+ SingleInstanceGate first = Pump.Run(() => new SingleInstanceGate(suffix));
try
{
- Assert.True(Sta.Run(first.TryAcquire));
+ Assert.True(Pump.Run(first.TryAcquire));
Assert.False(TryAcquireApart(suffix));
}
finally
{
- Sta.Run(first.Dispose);
+ Pump.Run(first.Dispose);
}
}
@@ -47,21 +47,21 @@ public sealed class SingleInstanceGateTests
public void The_second_run_asks_the_running_one_to_show_its_window()
{
string suffix = UniqueSuffix();
- SingleInstanceGate first = Sta.Run(() => new SingleInstanceGate(suffix));
+ SingleInstanceGate first = Pump.Run(() => new SingleInstanceGate(suffix));
ConcurrentQueue requests = new();
first.ActivationRequested += (_, e) => requests.Enqueue(e);
try
{
- Sta.Run(first.TryAcquire);
+ Pump.Run(first.TryAcquire);
Assert.False(TryAcquireApart(suffix));
- Sta.WaitFor(() => !requests.IsEmpty, "the running instance got the request to show itself");
+ Pump.WaitFor(() => !requests.IsEmpty, "the running instance got the request to show itself");
}
finally
{
- Sta.Run(first.Dispose);
+ Pump.Run(first.Dispose);
}
}
@@ -69,21 +69,21 @@ public sealed class SingleInstanceGateTests
public void Without_a_second_run_no_request_arrives()
{
string suffix = UniqueSuffix();
- SingleInstanceGate gate = Sta.Run(() => new SingleInstanceGate(suffix));
+ SingleInstanceGate gate = Pump.Run(() => new SingleInstanceGate(suffix));
ConcurrentQueue requests = new();
gate.ActivationRequested += (_, e) => requests.Enqueue(e);
try
{
- Sta.Run(gate.TryAcquire);
- Sta.Pause(TimeSpan.FromMilliseconds(80));
+ Pump.Run(gate.TryAcquire);
+ Pump.Pause(TimeSpan.FromMilliseconds(80));
Assert.Empty(requests);
}
finally
{
- Sta.Run(gate.Dispose);
+ Pump.Run(gate.Dispose);
}
}
@@ -93,9 +93,9 @@ public sealed class SingleInstanceGateTests
{
string suffix = UniqueSuffix();
- SingleInstanceGate first = Sta.Run(() => new SingleInstanceGate(suffix));
- Assert.True(Sta.Run(first.TryAcquire));
- Sta.Run(first.Dispose);
+ SingleInstanceGate first = Pump.Run(() => new SingleInstanceGate(suffix));
+ Assert.True(Pump.Run(first.TryAcquire));
+ Pump.Run(first.Dispose);
Assert.True(TryAcquireApart(suffix));
}
@@ -104,17 +104,17 @@ public sealed class SingleInstanceGateTests
public void No_requests_arrive_after_the_exit()
{
string suffix = UniqueSuffix();
- SingleInstanceGate first = Sta.Run(() => new SingleInstanceGate(suffix));
+ SingleInstanceGate first = Pump.Run(() => new SingleInstanceGate(suffix));
ConcurrentQueue requests = new();
first.ActivationRequested += (_, e) => requests.Enqueue(e);
- Sta.Run(first.TryAcquire);
- Sta.Run(first.Dispose);
+ Pump.Run(first.TryAcquire);
+ Pump.Run(first.Dispose);
// The place is free, so the new run simply takes it for itself
Assert.True(TryAcquireApart(suffix));
- Sta.Pause(TimeSpan.FromMilliseconds(80));
+ Pump.Pause(TimeSpan.FromMilliseconds(80));
Assert.Empty(requests);
}
@@ -128,50 +128,53 @@ public sealed class SingleInstanceGateTests
// A thread that took the mutex and ended without releasing it is exactly
// what a crashed application looks like to Windows
- Sta.RunApart(() =>
+ Pump.RunApart(() =>
{
var abandoned = new Mutex(initiallyOwned: false, "CursorLang.SingleInstance" + suffix);
abandoned.WaitOne(TimeSpan.Zero, exitContext: false);
});
- SingleInstanceGate gate = Sta.Run(() => new SingleInstanceGate(suffix));
+ SingleInstanceGate gate = Pump.Run(() => new SingleInstanceGate(suffix));
try
{
- Assert.True(Sta.Run(gate.TryAcquire));
+ Assert.True(Pump.Run(gate.TryAcquire));
}
finally
{
- Sta.Run(gate.Dispose);
+ Pump.Run(gate.Dispose);
}
}
[Fact]
public void Closing_without_taking_the_place_passes_without_consequence()
{
- SingleInstanceGate gate = Sta.Run(() => new SingleInstanceGate(UniqueSuffix()));
+ SingleInstanceGate gate = Pump.Run(() => new SingleInstanceGate(UniqueSuffix()));
- Sta.Run(gate.Dispose);
+ Pump.Run(gate.Dispose);
}
- // The application takes the place under its ordinary name
- [Fact]
- public void The_ordinary_application_takes_the_place_under_its_own_name()
+ // Each half of the application takes a place of its own: one background process
+ // and one settings window, and neither gets in the other's way
+ [Theory]
+ [InlineData(SingleInstanceGate.AgentName)]
+ [InlineData(SingleInstanceGate.SettingsName)]
+ public void Each_half_of_the_application_takes_a_place_of_its_own(string name)
{
- SingleInstanceGate gate = Sta.Run(() => new SingleInstanceGate());
+ SingleInstanceGate gate = Pump.Run(() => new SingleInstanceGate(name));
// The place may be held by a running application — then it is simply not taken
- Sta.Run(gate.Dispose);
+ Pump.Run(gate.Dispose);
}
[Fact]
public void Closing_twice_passes_without_consequence()
{
- SingleInstanceGate gate = Sta.Run(() => new SingleInstanceGate(UniqueSuffix()));
+ SingleInstanceGate gate = Pump.Run(() => new SingleInstanceGate(UniqueSuffix()));
- Sta.Run(gate.TryAcquire);
- Sta.Run(gate.Dispose);
- Sta.Run(gate.Dispose);
+ Pump.Run(gate.TryAcquire);
+ Pump.Run(gate.Dispose);
+ Pump.Run(gate.Dispose);
}
// Every test gets its own namespace of kernel objects
@@ -185,7 +188,7 @@ public sealed class SingleInstanceGateTests
{
bool acquired = false;
- Sta.RunApart(() =>
+ Pump.RunApart(() =>
{
var gate = new SingleInstanceGate(suffix);
diff --git a/CursorLang.Tests/Services/StartupLaunchTests.cs b/CursorLang.Core.Tests/Services/StartupLaunchTests.cs
similarity index 53%
rename from CursorLang.Tests/Services/StartupLaunchTests.cs
rename to CursorLang.Core.Tests/Services/StartupLaunchTests.cs
index e6353b9..d0cf0d4 100644
--- a/CursorLang.Tests/Services/StartupLaunchTests.cs
+++ b/CursorLang.Core.Tests/Services/StartupLaunchTests.cs
@@ -1,6 +1,7 @@
-using CursorLang.Services;
+using CursorLang.Core.Services;
+using CursorLang.Tests.Shared;
-namespace CursorLang.Tests.Services;
+namespace CursorLang.Core.Tests.Services;
///
/// Telling a launch by Windows apart from a launch by the user: the first one goes
@@ -49,6 +50,8 @@ public sealed class StartupLaunchTests
[Fact]
public void The_startup_entry_is_written_with_the_argument()
{
+ using var agent = new StagedAgentExecutable();
+
string? command = RegistryStartup.GetCommand();
Assert.NotNull(command);
@@ -57,4 +60,39 @@ public sealed class StartupLaunchTests
// And the path itself stays quoted: it has spaces in it more often than not
Assert.StartsWith("\"", command, StringComparison.Ordinal);
}
+
+ ///
+ /// Windows must start the agent, whoever asked for it.
+ ///
+ ///
+ /// The checkbox lives in the settings window, which is a process of its own. Were
+ /// the entry written from the path of whoever is running, the startup list would
+ /// hold the settings window — a process that shows a window and exits, instead of
+ /// the one that is supposed to sit in the tray.
+ ///
+ [Fact]
+ public void The_startup_entry_names_the_agent_rather_than_whoever_wrote_it()
+ {
+ using var agent = new StagedAgentExecutable();
+
+ string? command = RegistryStartup.GetCommand();
+
+ Assert.NotNull(command);
+ Assert.Contains("CursorLang.exe", command, StringComparison.OrdinalIgnoreCase);
+ Assert.DoesNotContain("CursorLang.Settings.exe", command, StringComparison.OrdinalIgnoreCase);
+ }
+
+ // Without an agent on disk there is nothing to put in the startup list, and
+ // pointing Windows at a file that is not there would be worse than saying nothing
+ [Fact]
+ public void Without_an_agent_on_disk_there_is_no_entry_to_write()
+ {
+ string agent = Path.Combine(AppContext.BaseDirectory, "CursorLang.exe");
+ if (File.Exists(agent))
+ {
+ Assert.Skip("The agent is built into the test output folder — nothing to check here");
+ }
+
+ Assert.Null(RegistryStartup.GetCommand());
+ }
}
diff --git a/CursorLang.Tests/Services/StartupServiceTests.cs b/CursorLang.Core.Tests/Services/StartupServiceTests.cs
similarity index 89%
rename from CursorLang.Tests/Services/StartupServiceTests.cs
rename to CursorLang.Core.Tests/Services/StartupServiceTests.cs
index 3567556..3cef1b1 100644
--- a/CursorLang.Tests/Services/StartupServiceTests.cs
+++ b/CursorLang.Core.Tests/Services/StartupServiceTests.cs
@@ -1,9 +1,10 @@
+using CursorLang.Core.Interop;
+using CursorLang.Core.Models;
+using CursorLang.Core.Services;
+using CursorLang.Tests.Shared;
using Windows.ApplicationModel;
-using CursorLang.Interop;
-using CursorLang.Models;
-using CursorLang.Services;
-namespace CursorLang.Tests.Services;
+namespace CursorLang.Core.Tests.Services;
///
/// Startup by way of Windows. The tests run outside an MSIX package — as does any
@@ -20,6 +21,8 @@ public sealed class StartupServiceTests
return;
}
+ using var agent = new StagedAgentExecutable();
+
// Whether startup is on depends on the machine; what matters is that the
// question is answered at all and the setting is not hidden
Assert.NotEqual(StartupState.Unavailable, await new StartupService().GetStateAsync());
diff --git a/CursorLang.Tests/Services/UpdateOptionsTests.cs b/CursorLang.Core.Tests/Services/UpdateOptionsTests.cs
similarity index 93%
rename from CursorLang.Tests/Services/UpdateOptionsTests.cs
rename to CursorLang.Core.Tests/Services/UpdateOptionsTests.cs
index 4caca12..f3e0ba6 100644
--- a/CursorLang.Tests/Services/UpdateOptionsTests.cs
+++ b/CursorLang.Core.Tests/Services/UpdateOptionsTests.cs
@@ -1,6 +1,6 @@
-using CursorLang.Services;
+using CursorLang.Core.Services;
-namespace CursorLang.Tests.Services;
+namespace CursorLang.Core.Tests.Services;
///
/// Where the app looks for its releases. The values belong to the build, and a
diff --git a/CursorLang.Tests/Services/UpdateServiceTests.cs b/CursorLang.Core.Tests/Services/UpdateServiceTests.cs
similarity index 97%
rename from CursorLang.Tests/Services/UpdateServiceTests.cs
rename to CursorLang.Core.Tests/Services/UpdateServiceTests.cs
index 7f17fc2..a671c93 100644
--- a/CursorLang.Tests/Services/UpdateServiceTests.cs
+++ b/CursorLang.Core.Tests/Services/UpdateServiceTests.cs
@@ -1,12 +1,10 @@
-using System.IO;
using System.Net;
-using System.Net.Http;
using System.Text;
-using CursorLang.Models;
-using CursorLang.Services;
-using CursorLang.Tests.Infrastructure;
+using CursorLang.Core.Models;
+using CursorLang.Core.Services;
+using CursorLang.Tests.Shared;
-namespace CursorLang.Tests.Services;
+namespace CursorLang.Core.Tests.Services;
///
/// What the app does with a release once it has found one: whether it is newer
diff --git a/CursorLang.Core.Tests/Threading/MessageTimerTests.cs b/CursorLang.Core.Tests/Threading/MessageTimerTests.cs
new file mode 100644
index 0000000..27081fc
--- /dev/null
+++ b/CursorLang.Core.Tests/Threading/MessageTimerTests.cs
@@ -0,0 +1,129 @@
+using CursorLang.Core.Threading;
+using CursorLang.Tests.Shared;
+
+namespace CursorLang.Core.Tests.Threading;
+
+///
+/// The timer the agent has instead of a dispatcher timer.
+///
+///
+/// It ticks on the message loop, so everything here runs on the pump thread — a timer
+/// started on one thread and awaited on another would never be seen to fire.
+///
+public sealed class MessageTimerTests
+{
+ [Fact]
+ public void A_started_timer_ticks()
+ {
+ int ticks = 0;
+
+ Pump.Run(() =>
+ {
+ using var timer = new MessageTimer();
+ timer.Interval = TimeSpan.FromMilliseconds(15);
+ timer.Tick += (_, _) => ticks++;
+ timer.Start();
+
+ Pump.WaitFor(() => ticks > 0, "the timer ticked");
+ });
+ }
+
+ [Fact]
+ public void A_timer_keeps_ticking_until_it_is_stopped()
+ {
+ var ticks = 0;
+
+ Pump.Run(() =>
+ {
+ using var timer = new MessageTimer();
+ timer.Interval = TimeSpan.FromMilliseconds(15);
+ timer.Tick += (_, _) => ticks++;
+ timer.Start();
+
+ Pump.WaitFor(() => ticks >= 3, "the timer ticked more than once");
+ });
+ }
+
+ // A stopped countdown does not go off, however long the loop runs afterwards
+ [Fact]
+ public void A_stopped_timer_does_not_tick()
+ {
+ var ticks = 0;
+
+ Pump.Run(() =>
+ {
+ using var timer = new MessageTimer();
+ timer.Interval = TimeSpan.FromMilliseconds(10);
+ timer.Tick += (_, _) => ticks++;
+ timer.Start();
+ timer.Stop();
+
+ Pump.Pause(TimeSpan.FromMilliseconds(80));
+ });
+
+ Assert.Equal(0, ticks);
+ }
+
+ // Restarting means from zero, so a countdown kept short by repeated restarts
+ // never reaches its end
+ [Fact]
+ public void Restarting_begins_the_countdown_again()
+ {
+ var ticks = 0;
+
+ Pump.Run(() =>
+ {
+ using var timer = new MessageTimer();
+ timer.Interval = TimeSpan.FromMilliseconds(60);
+ timer.Tick += (_, _) => ticks++;
+
+ for (var i = 0; i < 6; i++)
+ {
+ timer.Start();
+ Pump.Pause(TimeSpan.FromMilliseconds(20));
+ }
+
+ Assert.Equal(0, ticks);
+
+ Pump.WaitFor(() => ticks > 0, "left alone, the timer reached its end");
+ });
+ }
+
+ [Fact]
+ public void Closing_the_timer_ends_the_ticking()
+ {
+ var ticks = 0;
+
+ Pump.Run(() =>
+ {
+ var timer = new MessageTimer { Interval = TimeSpan.FromMilliseconds(15) };
+ timer.Tick += (_, _) => ticks++;
+ timer.Start();
+
+ Pump.WaitFor(() => ticks > 0, "the timer ticked");
+ timer.Dispose();
+
+ int seen = ticks;
+ Pump.Pause(TimeSpan.FromMilliseconds(80));
+
+ Assert.Equal(seen, ticks);
+ });
+ }
+
+ [Fact]
+ public void A_timer_that_was_never_started_says_so()
+ {
+ Pump.Run(() =>
+ {
+ using var timer = new MessageTimer();
+
+ Assert.False(timer.IsRunning);
+
+ timer.Start();
+ Assert.True(timer.IsRunning);
+
+ timer.Stop();
+ Assert.False(timer.IsRunning);
+ });
+ }
+}
diff --git a/CursorLang.Core/AssemblyInfo.cs b/CursorLang.Core/AssemblyInfo.cs
new file mode 100644
index 0000000..f89f865
--- /dev/null
+++ b/CursorLang.Core/AssemblyInfo.cs
@@ -0,0 +1,10 @@
+using System.Runtime.CompilerServices;
+
+// Core keeps its interop and its arithmetic internal, as it always did. The two
+// processes built on it are not outside consumers but the other halves of the same
+// application, so they are let in rather than the surface being widened for them.
+[assembly: InternalsVisibleTo("CursorLang")]
+[assembly: InternalsVisibleTo("CursorLang.Settings")]
+[assembly: InternalsVisibleTo("CursorLang.Core.Tests")]
+[assembly: InternalsVisibleTo("CursorLang.Agent.Tests")]
+[assembly: InternalsVisibleTo("CursorLang.Settings.Tests")]
diff --git a/CursorLang.Core/CursorLang.Core.csproj b/CursorLang.Core/CursorLang.Core.csproj
new file mode 100644
index 0000000..74e20bc
--- /dev/null
+++ b/CursorLang.Core/CursorLang.Core.csproj
@@ -0,0 +1,40 @@
+
+
+
+ net10.0-windows10.0.19041.0
+ 10.0.17763.0
+ enable
+ enable
+ true
+ true
+ $(NoWarn);CS1591
+ CursorLang.Core
+ CursorLang.Core
+ AnyCPU
+ win-x64;win-arm64
+ 1.0.0
+ 1.0.0.0
+ 1.0.0.0
+ CursorLang
+ Aleksandr Neychev
+ Shared part of CursorLang: models, settings, layout tracking, updates
+ Copyright (c) 2026
+
+
+
+ true
+
+
+
+ $(DefineConstants);CARET_UI_AUTOMATION
+
+
+
+
+
+
+
+
+
+
+
diff --git a/CursorLang/Interop/CaretNative.cs b/CursorLang.Core/Interop/CaretNative.cs
similarity index 91%
rename from CursorLang/Interop/CaretNative.cs
rename to CursorLang.Core/Interop/CaretNative.cs
index 76f9f51..d86019b 100644
--- a/CursorLang/Interop/CaretNative.cs
+++ b/CursorLang.Core/Interop/CaretNative.cs
@@ -1,9 +1,11 @@
using System.Runtime.InteropServices;
+#if CARET_UI_AUTOMATION
using System.Windows.Automation;
using System.Windows.Automation.Text;
+#endif
using Accessibility;
-namespace CursorLang.Interop;
+namespace CursorLang.Core.Interop;
///
/// Locates the caret in the active input field — including one in another application.
@@ -12,6 +14,12 @@ namespace CursorLang.Interop;
/// There is no single way to do it: classic Win32 applications create a system caret,
/// while Chrome, Electron and others draw it themselves and report its position only
/// through accessibility interfaces. So we ask the system first, then the application.
+///
+/// The UI Automation step is behind CARET_UI_AUTOMATION: it is the one part of
+/// the background process that reaches into the WPF half of the desktop runtime —
+/// TextPatternRange hands its rectangles back as System.Windows.Rect, which lives in
+/// WindowsBase — and it was measured at +3.9 MB private. It is also the last of the
+/// three steps and rarely reached. See the switch in CursorLang.Core.csproj.
///
internal static class CaretNative
{
@@ -95,6 +103,7 @@ internal static class CaretNative
inner.Left >= outer.Left && inner.Right <= outer.Right &&
inner.Top >= outer.Top && inner.Bottom <= outer.Bottom;
+#if CARET_UI_AUTOMATION
/// How long we wait for another application to answer over UI Automation.
private static readonly TimeSpan AutomationTimeout = TimeSpan.FromMilliseconds(150);
@@ -154,6 +163,11 @@ internal static class CaretNative
return null;
}
}
+#else
+ // Built without UI Automation: Chromium and Electron keep the system caret and MSAA
+ // steps above, and where those stay silent the popup falls back to the cursor
+ private static PopupWindowNative.Rect? TryGetAutomationCaret() => null;
+#endif
// The system caret: its coordinates come relative to the window that owns it
private static PopupWindowNative.Rect? TryGetSystemCaret(ForegroundInputNative.GuiThreadInfo info)
diff --git a/CursorLang/Interop/ForegroundInputNative.cs b/CursorLang.Core/Interop/ForegroundInputNative.cs
similarity index 97%
rename from CursorLang/Interop/ForegroundInputNative.cs
rename to CursorLang.Core/Interop/ForegroundInputNative.cs
index 002ac64..fce21d1 100644
--- a/CursorLang/Interop/ForegroundInputNative.cs
+++ b/CursorLang.Core/Interop/ForegroundInputNative.cs
@@ -1,6 +1,6 @@
using System.Runtime.InteropServices;
-namespace CursorLang.Interop;
+namespace CursorLang.Core.Interop;
///
/// Input details of the active application: which window holds keyboard focus
diff --git a/CursorLang/Interop/ForegroundPermissionNative.cs b/CursorLang.Core/Interop/ForegroundPermissionNative.cs
similarity index 96%
rename from CursorLang/Interop/ForegroundPermissionNative.cs
rename to CursorLang.Core/Interop/ForegroundPermissionNative.cs
index ff169eb..646915e 100644
--- a/CursorLang/Interop/ForegroundPermissionNative.cs
+++ b/CursorLang.Core/Interop/ForegroundPermissionNative.cs
@@ -1,6 +1,6 @@
using System.Runtime.InteropServices;
-namespace CursorLang.Interop;
+namespace CursorLang.Core.Interop;
///
/// The right to bring a window to the foreground.
diff --git a/CursorLang/Interop/KeyboardLayoutNative.cs b/CursorLang.Core/Interop/KeyboardLayoutNative.cs
similarity index 98%
rename from CursorLang/Interop/KeyboardLayoutNative.cs
rename to CursorLang.Core/Interop/KeyboardLayoutNative.cs
index d425887..9b10543 100644
--- a/CursorLang/Interop/KeyboardLayoutNative.cs
+++ b/CursorLang.Core/Interop/KeyboardLayoutNative.cs
@@ -1,6 +1,6 @@
using System.Runtime.InteropServices;
-namespace CursorLang.Interop;
+namespace CursorLang.Core.Interop;
///
/// Win32 API for reading the layout of the active application.
diff --git a/CursorLang/Interop/LowLevelKeyboardHook.cs b/CursorLang.Core/Interop/LowLevelKeyboardHook.cs
similarity index 99%
rename from CursorLang/Interop/LowLevelKeyboardHook.cs
rename to CursorLang.Core/Interop/LowLevelKeyboardHook.cs
index b0dc779..86eb42b 100644
--- a/CursorLang/Interop/LowLevelKeyboardHook.cs
+++ b/CursorLang.Core/Interop/LowLevelKeyboardHook.cs
@@ -1,6 +1,6 @@
using System.Runtime.InteropServices;
-namespace CursorLang.Interop;
+namespace CursorLang.Core.Interop;
///
/// A system keyboard hook (WH_KEYBOARD_LL): sees key presses in every application
diff --git a/CursorLang/Interop/PackageIdentityNative.cs b/CursorLang.Core/Interop/PackageIdentityNative.cs
similarity index 97%
rename from CursorLang/Interop/PackageIdentityNative.cs
rename to CursorLang.Core/Interop/PackageIdentityNative.cs
index 28a67d5..7608c78 100644
--- a/CursorLang/Interop/PackageIdentityNative.cs
+++ b/CursorLang.Core/Interop/PackageIdentityNative.cs
@@ -1,6 +1,6 @@
using System.Runtime.InteropServices;
-namespace CursorLang.Interop;
+namespace CursorLang.Core.Interop;
///
/// Answers whether the application runs from an MSIX package.
diff --git a/CursorLang/Interop/PopupWindowNative.cs b/CursorLang.Core/Interop/PopupWindowNative.cs
similarity index 68%
rename from CursorLang/Interop/PopupWindowNative.cs
rename to CursorLang.Core/Interop/PopupWindowNative.cs
index 8d22efb..0259e9e 100644
--- a/CursorLang/Interop/PopupWindowNative.cs
+++ b/CursorLang.Core/Interop/PopupWindowNative.cs
@@ -1,6 +1,6 @@
using System.Runtime.InteropServices;
-namespace CursorLang.Interop;
+namespace CursorLang.Core.Interop;
///
/// Win32 API for the popup window: styles, positioning near the cursor
@@ -21,12 +21,6 @@ internal static class PopupWindowNative
[DllImport("user32.dll")]
private static extern IntPtr GetForegroundWindow();
- [DllImport("user32.dll", SetLastError = true)]
- private static extern int GetWindowLong(IntPtr hWnd, int nIndex);
-
- [DllImport("user32.dll")]
- private static extern int SetWindowLong(IntPtr hWnd, int nIndex, int dwNewLong);
-
[DllImport("user32.dll")]
private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter,
int X, int Y, int cx, int cy, uint uFlags);
@@ -61,12 +55,6 @@ internal static class PopupWindowNative
public uint dwFlags;
}
- private const int GWL_EXSTYLE = -20;
- // The window does not take focus away from the active application
- private const int WS_EX_NOACTIVATE = 0x08000000;
- // And does not show up in Alt+Tab
- private const int WS_EX_TOOLWINDOW = 0x00000080;
-
private const uint SWP_NOSIZE = 0x0001;
private const uint SWP_NOZORDER = 0x0004;
private const uint SWP_NOACTIVATE = 0x0010;
@@ -80,16 +68,6 @@ internal static class PopupWindowNative
return cursor;
}
- ///
- /// The popup shows up on top of other applications, so it must neither
- /// activate itself nor steal input focus from the active window.
- ///
- internal static void MakePassive(IntPtr hWnd)
- {
- int exStyle = GetWindowLong(hWnd, GWL_EXSTYLE);
- SetWindowLong(hWnd, GWL_EXSTYLE, exStyle | WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW);
- }
-
///
/// Moves the window to a screen point without changing its size or z-order.
/// The coordinates are physical pixels: monitors have different scaling, while
@@ -101,21 +79,6 @@ internal static class PopupWindowNative
SetWindowPos(hWnd, IntPtr.Zero, x, y, 0, 0, SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE);
}
- ///
- /// Sets the window position and size in physical pixels.
- ///
- ///
- /// The size is set this way rather than through Width/Height: on the first show the
- /// window is still subject to the system minimum window size (SM_CXMIN×SM_CYMIN)
- /// and the popup comes out noticeably larger than its text. By the time this is
- /// called the window is already shown and has become a popup window, which that
- /// restriction does not apply to.
- ///
- internal static void SetBounds(IntPtr hWnd, int x, int y, int width, int height)
- {
- SetWindowPos(hWnd, IntPtr.Zero, x, y, width, height, SWP_NOZORDER | SWP_NOACTIVATE);
- }
-
/// The scale of the monitor the point is on (1.0 at 96 DPI).
internal static double GetScaleAt(Point point) =>
GetScaleOf(MonitorFromPoint(point, MONITOR_DEFAULTTONEAREST));
diff --git a/CursorLang/Models/AppSettings.cs b/CursorLang.Core/Models/AppSettings.cs
similarity index 62%
rename from CursorLang/Models/AppSettings.cs
rename to CursorLang.Core/Models/AppSettings.cs
index a06ab92..6ca77e8 100644
--- a/CursorLang/Models/AppSettings.cs
+++ b/CursorLang.Core/Models/AppSettings.cs
@@ -1,14 +1,23 @@
+using System.Drawing;
using System.Text.Json.Serialization;
-using System.Windows.Media;
using CommunityToolkit.Mvvm.ComponentModel;
-namespace CursorLang.Models;
+namespace CursorLang.Core.Models;
///
-/// The application settings. Every change applies on the fly: the popup and the
-/// settings window are bound to these properties, and SettingsService saves
-/// them to disk.
+/// The application settings, as the settings window writes them to settings.json.
///
+///
+/// The colours are rather than
+/// System.Windows.Media.Color. The former lives in System.Drawing.Primitives,
+/// which is part of the base runtime and brings neither WPF nor GDI+ along; the latter
+/// is WindowsBase, and this type is read by the agent, which must stay clear of it.
+/// The settings window turns them into brushes in its converters.
+///
+/// Both processes hold an instance of this, but only the settings window writes: the
+/// agent re-reads the file and pours the fresh values into the instance it already has,
+/// so everything subscribed to it stays subscribed. See .
+///
public sealed partial class AppSettings : ObservableObject
{
/// The interface language as a culture code: "ru", "en".
@@ -94,11 +103,11 @@ public sealed partial class AppSettings : ObservableObject
/// The fill of the popup. The opacity is set by .
[ObservableProperty]
- private Color _backgroundColor = Color.FromRgb(0x20, 0x20, 0x20);
+ private Color _backgroundColor = Color.FromArgb(0xFF, 0x20, 0x20, 0x20);
/// The colour of the layout name in the popup.
[ObservableProperty]
- private Color _foregroundColor = Color.FromRgb(0xFF, 0xFF, 0xFF);
+ private Color _foregroundColor = Color.FromArgb(0xFF, 0xFF, 0xFF, 0xFF);
/// as a .
[JsonIgnore]
@@ -107,4 +116,35 @@ public sealed partial class AppSettings : ObservableObject
/// as a .
[JsonIgnore]
public TimeSpan CapsLockHoldDelay => TimeSpan.FromMilliseconds(CapsLockHoldMilliseconds);
+
+ ///
+ /// Takes the values of another instance over, raising a change notification for
+ /// every property that has actually moved.
+ ///
+ ///
+ /// This is how the agent learns about an edit: the settings window is a separate
+ /// process, so the fresh values arrive as a freshly parsed instance and are poured
+ /// into the one everything is already bound to, rather than replacing it.
+ ///
+ public void CopyFrom(AppSettings other)
+ {
+ Language = other.Language;
+ Theme = other.Theme;
+ PlacementMode = other.PlacementMode;
+ CursorSide = other.CursorSide;
+ CursorOffset = other.CursorOffset;
+ CaretSide = other.CaretSide;
+ CaretOffset = other.CaretOffset;
+ ScreenPosition = other.ScreenPosition;
+ ScreenMargin = other.ScreenMargin;
+ FontSize = other.FontSize;
+ Opacity = other.Opacity;
+ DurationMilliseconds = other.DurationMilliseconds;
+ UseCapsLockHotkey = other.UseCapsLockHotkey;
+ CapsLockHoldMilliseconds = other.CapsLockHoldMilliseconds;
+ CheckForUpdates = other.CheckForUpdates;
+ LastUpdateCheck = other.LastUpdateCheck;
+ BackgroundColor = other.BackgroundColor;
+ ForegroundColor = other.ForegroundColor;
+ }
}
diff --git a/CursorLang/Models/AppTheme.cs b/CursorLang.Core/Models/AppTheme.cs
similarity index 87%
rename from CursorLang/Models/AppTheme.cs
rename to CursorLang.Core/Models/AppTheme.cs
index 0988653..a18bb2f 100644
--- a/CursorLang/Models/AppTheme.cs
+++ b/CursorLang.Core/Models/AppTheme.cs
@@ -1,4 +1,4 @@
-namespace CursorLang.Models;
+namespace CursorLang.Core.Models;
///
/// The look of the settings window. By default the application follows the Windows
diff --git a/CursorLang/Models/KeyboardLayout.cs b/CursorLang.Core/Models/KeyboardLayout.cs
similarity index 97%
rename from CursorLang/Models/KeyboardLayout.cs
rename to CursorLang.Core/Models/KeyboardLayout.cs
index a778d1f..76b9baa 100644
--- a/CursorLang/Models/KeyboardLayout.cs
+++ b/CursorLang.Core/Models/KeyboardLayout.cs
@@ -1,6 +1,6 @@
using System.Globalization;
-namespace CursorLang.Models;
+namespace CursorLang.Core.Models;
///
/// A keyboard layout in a form convenient for display.
diff --git a/CursorLang/Models/LayoutChangedEventArgs.cs b/CursorLang.Core/Models/LayoutChangedEventArgs.cs
similarity index 94%
rename from CursorLang/Models/LayoutChangedEventArgs.cs
rename to CursorLang.Core/Models/LayoutChangedEventArgs.cs
index 545f304..c8d7ce3 100644
--- a/CursorLang/Models/LayoutChangedEventArgs.cs
+++ b/CursorLang.Core/Models/LayoutChangedEventArgs.cs
@@ -1,4 +1,4 @@
-namespace CursorLang.Models;
+namespace CursorLang.Core.Models;
///
/// Why the current layout has changed.
diff --git a/CursorLang/Models/PopupPlacement.cs b/CursorLang.Core/Models/PopupPlacement.cs
similarity index 96%
rename from CursorLang/Models/PopupPlacement.cs
rename to CursorLang.Core/Models/PopupPlacement.cs
index f9afdd8..179082f 100644
--- a/CursorLang/Models/PopupPlacement.cs
+++ b/CursorLang.Core/Models/PopupPlacement.cs
@@ -1,4 +1,4 @@
-namespace CursorLang.Models;
+namespace CursorLang.Core.Models;
///
/// How the place for the popup is chosen.
diff --git a/CursorLang/Models/ReleaseInfo.cs b/CursorLang.Core/Models/ReleaseInfo.cs
similarity index 96%
rename from CursorLang/Models/ReleaseInfo.cs
rename to CursorLang.Core/Models/ReleaseInfo.cs
index 7249519..20a9fc8 100644
--- a/CursorLang/Models/ReleaseInfo.cs
+++ b/CursorLang.Core/Models/ReleaseInfo.cs
@@ -1,4 +1,4 @@
-namespace CursorLang.Models;
+namespace CursorLang.Core.Models;
///
/// A file attached to a release.
diff --git a/CursorLang/Models/StartupState.cs b/CursorLang.Core/Models/StartupState.cs
similarity index 96%
rename from CursorLang/Models/StartupState.cs
rename to CursorLang.Core/Models/StartupState.cs
index d25641a..70c5e90 100644
--- a/CursorLang/Models/StartupState.cs
+++ b/CursorLang.Core/Models/StartupState.cs
@@ -1,4 +1,4 @@
-namespace CursorLang.Models;
+namespace CursorLang.Core.Models;
///
/// The state of startup. Follows StartupTaskState of Windows: the user and
diff --git a/CursorLang/Models/UpdateStatus.cs b/CursorLang.Core/Models/UpdateStatus.cs
similarity index 95%
rename from CursorLang/Models/UpdateStatus.cs
rename to CursorLang.Core/Models/UpdateStatus.cs
index 5d59e8d..187fc6f 100644
--- a/CursorLang/Models/UpdateStatus.cs
+++ b/CursorLang.Core/Models/UpdateStatus.cs
@@ -1,4 +1,4 @@
-namespace CursorLang.Models;
+namespace CursorLang.Core.Models;
///
/// Which step the update is at. One value — one state of the interface:
diff --git a/CursorLang/Resources/CursorLang.ico b/CursorLang.Core/Resources/CursorLang.ico
similarity index 100%
rename from CursorLang/Resources/CursorLang.ico
rename to CursorLang.Core/Resources/CursorLang.ico
diff --git a/CursorLang/Resources/Strings.resx b/CursorLang.Core/Resources/Strings.resx
similarity index 99%
rename from CursorLang/Resources/Strings.resx
rename to CursorLang.Core/Resources/Strings.resx
index bf66334..497a8ab 100644
--- a/CursorLang/Resources/Strings.resx
+++ b/CursorLang.Core/Resources/Strings.resx
@@ -215,7 +215,7 @@
Check for updates
- Check for updates at startup
+ Check for updatesChecking for updates…
diff --git a/CursorLang/Resources/Strings.ru.resx b/CursorLang.Core/Resources/Strings.ru.resx
similarity index 99%
rename from CursorLang/Resources/Strings.ru.resx
rename to CursorLang.Core/Resources/Strings.ru.resx
index f1d044b..8e2e20a 100644
--- a/CursorLang/Resources/Strings.ru.resx
+++ b/CursorLang.Core/Resources/Strings.ru.resx
@@ -215,7 +215,7 @@
Проверить обновления
- Проверять обновления при запуске
+ Проверять обновленияИдёт проверка обновлений…
diff --git a/CursorLang.Core/Services/AgentExecutable.cs b/CursorLang.Core/Services/AgentExecutable.cs
new file mode 100644
index 0000000..fe845d2
--- /dev/null
+++ b/CursorLang.Core/Services/AgentExecutable.cs
@@ -0,0 +1,35 @@
+namespace CursorLang.Core.Services;
+
+///
+/// Where the background half of the application lives on disk.
+///
+///
+/// Two processes now share one folder, and each of them at some point needs the path
+/// of the other: the settings window registers the agent for startup and must not
+/// register itself, and the agent starts the settings window from the tray menu.
+/// Environment.ProcessPath answers the wrong question for both, so the paths
+/// are worked out from the folder the assemblies were loaded from.
+///
+internal static class AgentExecutable
+{
+ /// The background process — the one Windows starts at sign-in.
+ internal const string AgentFileName = "CursorLang.exe";
+
+ /// The settings window, started on demand and gone when closed.
+ internal const string SettingsFileName = "CursorLang.Settings.exe";
+
+ ///
+ /// The full path of the agent, or null when it is not next to us — which
+ /// happens in the tests and would happen to a half-copied installation.
+ ///
+ internal static string? AgentPath => Beside(AgentFileName);
+
+ /// The full path of the settings window, on the same terms.
+ internal static string? SettingsPath => Beside(SettingsFileName);
+
+ private static string? Beside(string fileName)
+ {
+ string path = Path.Combine(AppContext.BaseDirectory, fileName);
+ return File.Exists(path) ? path : null;
+ }
+}
diff --git a/CursorLang/Services/CapsLockSwitchCoordinator.cs b/CursorLang.Core/Services/CapsLockSwitchCoordinator.cs
similarity index 97%
rename from CursorLang/Services/CapsLockSwitchCoordinator.cs
rename to CursorLang.Core/Services/CapsLockSwitchCoordinator.cs
index 10af38c..7d6a709 100644
--- a/CursorLang/Services/CapsLockSwitchCoordinator.cs
+++ b/CursorLang.Core/Services/CapsLockSwitchCoordinator.cs
@@ -1,7 +1,7 @@
using System.ComponentModel;
-using CursorLang.Models;
+using CursorLang.Core.Models;
-namespace CursorLang.Services;
+namespace CursorLang.Core.Services;
///
/// Turns Caps Lock presses into actions: a short one switches the layout, a long one
diff --git a/CursorLang.Core/Services/ColorJsonConverter.cs b/CursorLang.Core/Services/ColorJsonConverter.cs
new file mode 100644
index 0000000..07989e9
--- /dev/null
+++ b/CursorLang.Core/Services/ColorJsonConverter.cs
@@ -0,0 +1,56 @@
+using System.Drawing;
+using System.Globalization;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace CursorLang.Core.Services;
+
+///
+/// Reads and writes a colour as "#AARRGGBB".
+///
+///
+/// That is the form earlier versions wrote, when the colours were WPF ones and
+/// Color.ToString() produced it, so files already on disk keep working. The
+/// parsing is done here rather than by ColorConverter because that one lives in
+/// PresentationCore, and Core is read by the agent. Named colours are accepted too:
+/// nothing writes them, but the file is plain text and people edit it by hand.
+///
+internal sealed class ColorJsonConverter : JsonConverter
+{
+ public override Color Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ string? value = reader.GetString();
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return Color.Black;
+ }
+
+ value = value.Trim();
+
+ if (!value.StartsWith('#'))
+ {
+ Color named = Color.FromName(value);
+
+ // Unpacked back into a plain colour on purpose: a known colour carries its
+ // name with it and does not compare equal to the same bytes written in hex,
+ // which would make "Red" and "#FFFF0000" two different settings
+ return named.IsKnownColor ? Color.FromArgb(named.ToArgb()) : Color.Black;
+ }
+
+ ReadOnlySpan digits = value.AsSpan(1);
+ if (!uint.TryParse(digits, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out uint packed))
+ {
+ return Color.Black;
+ }
+
+ return digits.Length switch
+ {
+ 6 => Color.FromArgb((int)(packed | 0xFF000000)),
+ 8 => Color.FromArgb((int)packed),
+ _ => Color.Black,
+ };
+ }
+
+ public override void Write(Utf8JsonWriter writer, Color value, JsonSerializerOptions options) =>
+ writer.WriteStringValue($"#{value.A:X2}{value.R:X2}{value.G:X2}{value.B:X2}");
+}
diff --git a/CursorLang/Services/GiteaReleaseFeed.cs b/CursorLang.Core/Services/GiteaReleaseFeed.cs
similarity index 98%
rename from CursorLang/Services/GiteaReleaseFeed.cs
rename to CursorLang.Core/Services/GiteaReleaseFeed.cs
index 356d8ee..795c386 100644
--- a/CursorLang/Services/GiteaReleaseFeed.cs
+++ b/CursorLang.Core/Services/GiteaReleaseFeed.cs
@@ -1,11 +1,9 @@
-using System.IO;
-using System.Net.Http;
using System.Net.Http.Headers;
using System.Runtime.InteropServices;
using System.Text.Json;
-using CursorLang.Models;
+using CursorLang.Core.Models;
-namespace CursorLang.Services;
+namespace CursorLang.Core.Services;
///
/// Gitea releases.
diff --git a/CursorLang/Services/ICapsLockHotkeyService.cs b/CursorLang.Core/Services/ICapsLockHotkeyService.cs
similarity index 96%
rename from CursorLang/Services/ICapsLockHotkeyService.cs
rename to CursorLang.Core/Services/ICapsLockHotkeyService.cs
index 32f0152..28bf54c 100644
--- a/CursorLang/Services/ICapsLockHotkeyService.cs
+++ b/CursorLang.Core/Services/ICapsLockHotkeyService.cs
@@ -1,4 +1,4 @@
-namespace CursorLang.Services;
+namespace CursorLang.Core.Services;
///
/// Intercepts Caps Lock at the system level and splits the presses into short and
diff --git a/CursorLang/Services/IKeyboardLayoutService.cs b/CursorLang.Core/Services/IKeyboardLayoutService.cs
similarity index 86%
rename from CursorLang/Services/IKeyboardLayoutService.cs
rename to CursorLang.Core/Services/IKeyboardLayoutService.cs
index ccb049b..7a54a47 100644
--- a/CursorLang/Services/IKeyboardLayoutService.cs
+++ b/CursorLang.Core/Services/IKeyboardLayoutService.cs
@@ -1,6 +1,6 @@
-using CursorLang.Models;
+using CursorLang.Core.Models;
-namespace CursorLang.Services;
+namespace CursorLang.Core.Services;
///
/// Tracks the layout of the active window — including in other applications —
diff --git a/CursorLang/Services/ILayoutPopupService.cs b/CursorLang.Core/Services/ILayoutPopupService.cs
similarity index 88%
rename from CursorLang/Services/ILayoutPopupService.cs
rename to CursorLang.Core/Services/ILayoutPopupService.cs
index 71b1fa0..9519257 100644
--- a/CursorLang/Services/ILayoutPopupService.cs
+++ b/CursorLang.Core/Services/ILayoutPopupService.cs
@@ -1,6 +1,6 @@
-using CursorLang.Models;
+using CursorLang.Core.Models;
-namespace CursorLang.Services;
+namespace CursorLang.Core.Services;
///
/// Shows the layout popup at the cursor.
diff --git a/CursorLang/Services/ILayoutPopupWindow.cs b/CursorLang.Core/Services/ILayoutPopupWindow.cs
similarity index 57%
rename from CursorLang/Services/ILayoutPopupWindow.cs
rename to CursorLang.Core/Services/ILayoutPopupWindow.cs
index cbee1dd..0b4d5db 100644
--- a/CursorLang/Services/ILayoutPopupWindow.cs
+++ b/CursorLang.Core/Services/ILayoutPopupWindow.cs
@@ -1,4 +1,4 @@
-namespace CursorLang.Services;
+namespace CursorLang.Core.Services;
///
/// The popup window as seen by whoever decides when it is shown.
@@ -10,8 +10,15 @@ namespace CursorLang.Services;
///
public interface ILayoutPopupWindow
{
- /// Shows the window at the place set by the settings.
- void ShowPopup();
+ ///
+ /// Shows the window with the given text at the place set by the settings.
+ ///
+ ///
+ /// The text is passed in rather than bound: there is no view model behind the
+ /// window any more, and no data binding either — it is a Win32 window that paints
+ /// one line of text itself.
+ ///
+ void ShowPopup(string shortName);
/// Takes the window off the screen without destroying it.
void Hide();
diff --git a/CursorLang/Services/ILocalizationService.cs b/CursorLang.Core/Services/ILocalizationService.cs
similarity index 95%
rename from CursorLang/Services/ILocalizationService.cs
rename to CursorLang.Core/Services/ILocalizationService.cs
index b3497e4..bb27487 100644
--- a/CursorLang/Services/ILocalizationService.cs
+++ b/CursorLang.Core/Services/ILocalizationService.cs
@@ -1,6 +1,6 @@
using System.ComponentModel;
-namespace CursorLang.Services;
+namespace CursorLang.Core.Services;
/// An interface language to choose from in the settings.
/// The culture code: "ru", "en".
diff --git a/CursorLang/Services/IReleaseFeed.cs b/CursorLang.Core/Services/IReleaseFeed.cs
similarity index 88%
rename from CursorLang/Services/IReleaseFeed.cs
rename to CursorLang.Core/Services/IReleaseFeed.cs
index 45f7434..8772216 100644
--- a/CursorLang/Services/IReleaseFeed.cs
+++ b/CursorLang.Core/Services/IReleaseFeed.cs
@@ -1,7 +1,6 @@
-using System.Net.Http;
-using CursorLang.Models;
+using CursorLang.Core.Models;
-namespace CursorLang.Services;
+namespace CursorLang.Core.Services;
///
/// The release list of the repository.
diff --git a/CursorLang/Services/IStartupService.cs b/CursorLang.Core/Services/IStartupService.cs
similarity index 87%
rename from CursorLang/Services/IStartupService.cs
rename to CursorLang.Core/Services/IStartupService.cs
index 6abbb19..cf29ecb 100644
--- a/CursorLang/Services/IStartupService.cs
+++ b/CursorLang.Core/Services/IStartupService.cs
@@ -1,6 +1,6 @@
-using CursorLang.Models;
+using CursorLang.Core.Models;
-namespace CursorLang.Services;
+namespace CursorLang.Core.Services;
///
/// Starting the app together with Windows.
diff --git a/CursorLang/Services/IUpdateService.cs b/CursorLang.Core/Services/IUpdateService.cs
similarity index 95%
rename from CursorLang/Services/IUpdateService.cs
rename to CursorLang.Core/Services/IUpdateService.cs
index 812638a..35fcf40 100644
--- a/CursorLang/Services/IUpdateService.cs
+++ b/CursorLang.Core/Services/IUpdateService.cs
@@ -1,6 +1,6 @@
-using CursorLang.Models;
+using CursorLang.Core.Models;
-namespace CursorLang.Services;
+namespace CursorLang.Core.Services;
///
/// Checking for and installing new versions of the application.
diff --git a/CursorLang/Services/KeyboardLayoutService.cs b/CursorLang.Core/Services/KeyboardLayoutService.cs
similarity index 88%
rename from CursorLang/Services/KeyboardLayoutService.cs
rename to CursorLang.Core/Services/KeyboardLayoutService.cs
index 0120bb6..be062df 100644
--- a/CursorLang/Services/KeyboardLayoutService.cs
+++ b/CursorLang.Core/Services/KeyboardLayoutService.cs
@@ -1,8 +1,8 @@
-using System.Windows.Threading;
-using CursorLang.Interop;
-using CursorLang.Models;
+using CursorLang.Core.Interop;
+using CursorLang.Core.Models;
+using CursorLang.Core.Threading;
-namespace CursorLang.Services;
+namespace CursorLang.Core.Services;
///
/// The settings of layout tracking.
@@ -23,10 +23,13 @@ public sealed class KeyboardLayoutOptions
/// (ITfLanguageProfileNotifySink) report only language changes inside our own process
/// — both options were tried and did not work. One tick is three Win32 calls reading
/// data from kernel memory.
+///
+/// The timer ticks on the message loop of whatever thread starts it, the same as a
+/// dispatcher timer did: the agent has a plain Win32 loop and no dispatcher to offer.
///
public sealed class KeyboardLayoutService : IKeyboardLayoutService, IDisposable
{
- private readonly DispatcherTimer _pollTimer;
+ private readonly MessageTimer _pollTimer;
private readonly Func _getForegroundWindow;
private readonly Func _getActiveLocaleId;
private readonly Action _requestNextLayout;
@@ -57,7 +60,7 @@ public sealed class KeyboardLayoutService : IKeyboardLayoutService, IDisposable
_getActiveLocaleId = getActiveLocaleId;
_requestNextLayout = requestNextLayout;
- _pollTimer = new DispatcherTimer { Interval = options.PollInterval };
+ _pollTimer = new MessageTimer { Interval = options.PollInterval };
_pollTimer.Tick += OnTick;
}
@@ -78,8 +81,8 @@ public sealed class KeyboardLayoutService : IKeyboardLayoutService, IDisposable
public void Dispose()
{
- _pollTimer.Stop();
_pollTimer.Tick -= OnTick;
+ _pollTimer.Dispose();
}
private void OnTick(object? sender, EventArgs e) => Poll();
diff --git a/CursorLang/Services/LayoutNotificationCoordinator.cs b/CursorLang.Core/Services/LayoutNotificationCoordinator.cs
similarity index 94%
rename from CursorLang/Services/LayoutNotificationCoordinator.cs
rename to CursorLang.Core/Services/LayoutNotificationCoordinator.cs
index df6ba3d..df52f11 100644
--- a/CursorLang/Services/LayoutNotificationCoordinator.cs
+++ b/CursorLang.Core/Services/LayoutNotificationCoordinator.cs
@@ -1,6 +1,6 @@
-using CursorLang.Models;
+using CursorLang.Core.Models;
-namespace CursorLang.Services;
+namespace CursorLang.Core.Services;
///
/// Ties layout tracking to showing the popup.
diff --git a/CursorLang/Services/LocalizationService.cs b/CursorLang.Core/Services/LocalizationService.cs
similarity index 64%
rename from CursorLang/Services/LocalizationService.cs
rename to CursorLang.Core/Services/LocalizationService.cs
index b4b1575..b977462 100644
--- a/CursorLang/Services/LocalizationService.cs
+++ b/CursorLang.Core/Services/LocalizationService.cs
@@ -1,18 +1,29 @@
using System.Globalization;
using System.Resources;
-using System.Windows.Data;
using CommunityToolkit.Mvvm.ComponentModel;
-namespace CursorLang.Services;
+namespace CursorLang.Core.Services;
///
/// Takes the strings from the resources and, when the language changes, asks WPF to
/// re-read every binding.
///
+///
+/// Both processes use it: the settings window for its whole interface, the agent for
+/// the three captions of the tray menu. The theme no longer reaches that menu — Windows
+/// draws it — but the language still does.
+///
public sealed class LocalizationService : ObservableObject, ILocalizationService
{
+ ///
+ /// The name WPF reports when an indexer changes. Spelt out rather than taken from
+ /// Binding.IndexerName: that constant lives in PresentationFramework, and
+ /// Core is read by the agent, which does not load WPF.
+ ///
+ public const string IndexerName = "Item[]";
+
private static readonly ResourceManager Resources =
- new("CursorLang.Resources.Strings", typeof(App).Assembly);
+ new("CursorLang.Core.Resources.Strings", typeof(LocalizationService).Assembly);
private CultureInfo _culture = CultureInfo.GetCultureInfo("en");
@@ -41,7 +52,7 @@ public sealed class LocalizationService : ObservableObject, ILocalizationService
// We report a change of the indexer: that is how every binding of the
// {Binding Localization[Key]} kind updates, that is, all the interface text
- OnPropertyChanged(Binding.IndexerName);
+ OnPropertyChanged(IndexerName);
}
}
}
diff --git a/CursorLang/Services/PopupLayout.cs b/CursorLang.Core/Services/PopupLayout.cs
similarity index 97%
rename from CursorLang/Services/PopupLayout.cs
rename to CursorLang.Core/Services/PopupLayout.cs
index ca5efac..498eed8 100644
--- a/CursorLang/Services/PopupLayout.cs
+++ b/CursorLang.Core/Services/PopupLayout.cs
@@ -1,7 +1,7 @@
-using CursorLang.Interop;
-using CursorLang.Models;
+using CursorLang.Core.Interop;
+using CursorLang.Core.Models;
-namespace CursorLang.Services;
+namespace CursorLang.Core.Services;
///
/// Computes the screen point to show the popup at.
diff --git a/CursorLang/Services/RegistryStartup.cs b/CursorLang.Core/Services/RegistryStartup.cs
similarity index 83%
rename from CursorLang/Services/RegistryStartup.cs
rename to CursorLang.Core/Services/RegistryStartup.cs
index 17996dc..5017765 100644
--- a/CursorLang/Services/RegistryStartup.cs
+++ b/CursorLang.Core/Services/RegistryStartup.cs
@@ -1,9 +1,8 @@
-using System.IO;
using System.Security;
-using CursorLang.Models;
+using CursorLang.Core.Models;
using Microsoft.Win32;
-namespace CursorLang.Services;
+namespace CursorLang.Core.Services;
///
/// Startup for a build that is not a package: a value under the Run key.
@@ -112,16 +111,20 @@ 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.
+ /// What Windows is to run. null — the agent is not where it should be, and
+ /// there is nothing to write down.
///
///
- /// 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.
+ /// The agent by name rather than Environment.ProcessPath: this setting is
+ /// switched from the settings window, and its own path would put the wrong process
+ /// into the startup list — one that shows a window and exits.
+ ///
+ /// The argument is how the agent recognises a launch of this kind and goes straight
+ /// to the tray without the settings window: 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
+ AgentExecutable.AgentPath is { Length: > 0 } path
? $"\"{path}\" {StartupLaunch.Argument}"
: null;
}
diff --git a/CursorLang/Services/SettingsService.cs b/CursorLang.Core/Services/SettingsService.cs
similarity index 59%
rename from CursorLang/Services/SettingsService.cs
rename to CursorLang.Core/Services/SettingsService.cs
index 96d6909..ff39ae0 100644
--- a/CursorLang/Services/SettingsService.cs
+++ b/CursorLang.Core/Services/SettingsService.cs
@@ -1,24 +1,27 @@
using System.ComponentModel;
-using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
-using System.Windows.Media;
-using System.Windows.Threading;
-using CursorLang.Interop;
-using CursorLang.Models;
+using CursorLang.Core.Interop;
+using CursorLang.Core.Models;
+using CursorLang.Core.Threading;
using Windows.Storage;
-namespace CursorLang.Services;
+namespace CursorLang.Core.Services;
///
/// Keeps the settings in the settings.json file.
///
///
-/// The location of the file depends on how the application is installed. A package
-/// from the Store keeps its settings in a folder of its own: Windows removes it
-/// together with the application, and after the removal nothing superfluous is left
-/// in the system — that is what Store applications are expected to do. A separately
-/// installed application keeps its settings in %APPDATA%, as before.
+/// The file is the whole of the connection between the two processes, and they use it
+/// from opposite ends. The settings window calls and is the
+/// only writer; the agent only ever reads, and re-reads when the window tells it to.
+/// A second writer would mean two processes racing for one file and an edit going missing.
+///
+/// The location depends on how the application is installed. A package from the Store
+/// keeps its settings in a folder of its own: Windows removes it together with the
+/// application, and after the removal nothing superfluous is left in the system — that
+/// is what Store applications are expected to do. A separately installed application
+/// keeps its settings in %APPDATA%, as before.
///
/// Settings left over from a separately installed application are picked up by the
/// package on the first launch and moved over. The original file stays where it is:
@@ -35,15 +38,17 @@ public sealed class SettingsService : IDisposable
private const string FileName = "settings.json";
- private readonly string _filePath;
- private readonly string _inheritedFilePath;
- private readonly DispatcherTimer _saveTimer;
- private AppSettings? _settings;
-
// Sliders change their values continuously, so writing to disk
// is postponed until there is a pause in the changes
private static readonly TimeSpan SaveDelay = TimeSpan.FromMilliseconds(500);
+ private readonly string _filePath;
+ private readonly string _inheritedFilePath;
+ private readonly MessageTimer _saveTimer;
+
+ private AppSettings? _settings;
+ private bool _isTrackingChanges;
+
public SettingsService()
: this(
Path.Combine(GetSettingsFolder(), FileName),
@@ -62,15 +67,21 @@ public sealed class SettingsService : IDisposable
_filePath = filePath;
_inheritedFilePath = inheritedFilePath;
- _saveTimer = new DispatcherTimer { Interval = saveDelay };
+ _saveTimer = new MessageTimer { Interval = saveDelay };
_saveTimer.Tick += OnSaveTimerTick;
}
///
- /// Reads the settings from disk or returns the default values,
- /// and from then on saves any changes by itself.
+ /// Reads the settings from disk or returns the default values.
///
- public AppSettings Load()
+ ///
+ /// Asking twice hands out the same instance rather than reading again. Everything
+ /// binds to what this returns — the window, the popup, the hook — and a second
+ /// instance would mean one of them editing settings nobody else can see.
+ ///
+ public AppSettings Load() => _settings ??= ReadOrInherit();
+
+ private AppSettings ReadOrInherit()
{
AppSettings? stored = ReadFile(_filePath);
@@ -85,7 +96,6 @@ public sealed class SettingsService : IDisposable
}
_settings = stored ?? CreateDefault();
- _settings.PropertyChanged += OnSettingsChanged;
// Moved settings are fixed in the new place right away rather than on the
// first edit: otherwise the application would read someone else's file every
@@ -98,6 +108,52 @@ public sealed class SettingsService : IDisposable
return _settings;
}
+ ///
+ /// Starts saving every change, after a pause. For the settings window: it is the
+ /// only process allowed to write.
+ ///
+ ///
+ /// Reads the file if that has not happened yet. The settings window asks in exactly
+ /// that order — its container hands out this service first and the settings only
+ /// when something needs them — and a version of this that quietly did nothing
+ /// before the first read left the window saving nothing at all.
+ ///
+ public void TrackChanges()
+ {
+ if (_isTrackingChanges)
+ {
+ return;
+ }
+
+ Load().PropertyChanged += OnSettingsChanged;
+ _isTrackingChanges = true;
+ }
+
+ ///
+ /// Re-reads the file. For the agent, when the settings window says it has written.
+ ///
+ ///
+ /// There is nothing to wait for and nothing to debounce: the window writes the file
+ /// whole and moves it into place in one step, and only then says so. Nobody else
+ /// writes it — the agent does not watch the file, and an edit made behind the
+ /// application's back is not a case it is built for.
+ ///
+ public void Reload()
+ {
+ if (_settings is not null && ReadFile(_filePath) is { } fresh)
+ {
+ _settings.CopyFrom(fresh);
+ }
+ }
+
+ ///
+ /// Writes the settings and tells the agent to pick them up.
+ ///
+ ///
+ /// The file is written beside its destination and moved onto it, which on one
+ /// volume is a single step. That way the agent, which is told to re-read the moment
+ /// this returns, never meets a half-written file.
+ ///
public void Save()
{
if (_settings is null)
@@ -105,25 +161,34 @@ public sealed class SettingsService : IDisposable
return;
}
+ _saveTimer.Stop();
+
try
{
Directory.CreateDirectory(Path.GetDirectoryName(_filePath)!);
- File.WriteAllText(_filePath, JsonSerializer.Serialize(_settings, SerializerOptions));
+
+ string temporary = _filePath + ".tmp";
+ File.WriteAllText(temporary, JsonSerializer.Serialize(_settings, SerializerOptions));
+ File.Move(temporary, _filePath, overwrite: true);
}
catch (Exception e) when (e is IOException or UnauthorizedAccessException)
{
// The settings are not the kind of thing worth bringing the application down for
+ return;
}
+
+ SettingsSignal.NotifyAgent();
}
public void Dispose()
{
- _saveTimer.Stop();
_saveTimer.Tick -= OnSaveTimerTick;
+ _saveTimer.Dispose();
- if (_settings is not null)
+ if (_settings is not null && _isTrackingChanges)
{
_settings.PropertyChanged -= OnSettingsChanged;
+ _isTrackingChanges = false;
Save();
}
}
@@ -180,36 +245,5 @@ public sealed class SettingsService : IDisposable
_saveTimer.Start();
}
- private void OnSaveTimerTick(object? sender, EventArgs e)
- {
- _saveTimer.Stop();
- Save();
- }
-
- // Color is not serialized out of the box, and keeping it readable in the file is handy
- private sealed class ColorJsonConverter : JsonConverter
- {
- public override Color Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
- {
- string? value = reader.GetString();
- if (string.IsNullOrWhiteSpace(value))
- {
- return Colors.Black;
- }
-
- try
- {
- return (Color)ColorConverter.ConvertFromString(value)!;
- }
- catch (FormatException)
- {
- return Colors.Black;
- }
- }
-
- public override void Write(Utf8JsonWriter writer, Color value, JsonSerializerOptions options)
- {
- writer.WriteStringValue(value.ToString());
- }
- }
+ private void OnSaveTimerTick(object? sender, EventArgs e) => Save();
}
diff --git a/CursorLang.Core/Services/SettingsSignal.cs b/CursorLang.Core/Services/SettingsSignal.cs
new file mode 100644
index 0000000..10ad81f
--- /dev/null
+++ b/CursorLang.Core/Services/SettingsSignal.cs
@@ -0,0 +1,52 @@
+using System.Runtime.InteropServices;
+
+namespace CursorLang.Core.Services;
+
+///
+/// Tells the agent that settings.json has changed.
+///
+///
+/// The message carries nothing but the fact. The temptation to send the changed values
+/// along has to be resisted: the file would stop being the only source of truth, and
+/// the two would part company the first time somebody edits it by hand. Nothing else
+/// tells the agent — it does not watch the file — so a message that goes missing means
+/// settings it does not pick up until it is restarted.
+///
+/// Order is what makes it safe. The settings window writes the file whole, moves it
+/// into place in one step and only then signals, so by the time the agent reads there
+/// is nothing half-written to read.
+///
+/// A registered message rather than WM_APP + n: the identifier is unique across
+/// the system, so it cannot be confused with anything else that finds its way to that
+/// window.
+///
+internal static class SettingsSignal
+{
+ /// The window class the agent registers for its hidden window.
+ internal const string AgentWindowClass = "CursorLang.Agent.Window";
+
+ /// The message both sides agree on.
+ internal static uint Message { get; } = RegisterWindowMessage("CursorLang.SettingsChanged");
+
+ ///
+ /// Wakes the agent, if one is running in this session. Silence is a normal
+ /// answer: the settings window is perfectly usable with no agent behind it.
+ ///
+ internal static void NotifyAgent()
+ {
+ IntPtr agent = FindWindow(AgentWindowClass, null);
+ if (agent != IntPtr.Zero)
+ {
+ PostMessage(agent, Message, IntPtr.Zero, IntPtr.Zero);
+ }
+ }
+
+ [DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "RegisterWindowMessageW")]
+ private static extern uint RegisterWindowMessage(string lpString);
+
+ [DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "FindWindowW")]
+ private static extern IntPtr FindWindow(string lpClassName, string? lpWindowName);
+
+ [DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "PostMessageW")]
+ private static extern bool PostMessage(IntPtr hWnd, uint message, IntPtr wParam, IntPtr lParam);
+}
diff --git a/CursorLang/Services/SingleInstanceGate.cs b/CursorLang.Core/Services/SingleInstanceGate.cs
similarity index 75%
rename from CursorLang/Services/SingleInstanceGate.cs
rename to CursorLang.Core/Services/SingleInstanceGate.cs
index 3fd8d3d..042e933 100644
--- a/CursorLang/Services/SingleInstanceGate.cs
+++ b/CursorLang.Core/Services/SingleInstanceGate.cs
@@ -1,7 +1,6 @@
-using System.Windows.Threading;
-using CursorLang.Interop;
+using CursorLang.Core.Interop;
-namespace CursorLang.Services;
+namespace CursorLang.Core.Services;
///
/// Lets only one instance of the application run: a second launch does not bring up
@@ -13,13 +12,22 @@ namespace CursorLang.Services;
/// odd picture with fast user switching: the second user would be left without the
/// application, and showing them the window of the first one is impossible anyway —
/// windows belong to a session.
+///
+/// Two processes use this now, and each guards its own slot: the agent so that one
+/// background process runs, the settings window so that a second "Settings" from the
+/// tray raises the window already open instead of a second one. Hence the name part.
///
public sealed class SingleInstanceGate : IDisposable
{
+ /// The agent's slot — one background process per session.
+ public const string AgentName = ".Agent";
+
+ /// The settings window's slot — one window per session.
+ public const string SettingsName = ".Settings";
+
private const string MutexName = "CursorLang.SingleInstance";
private const string ActivationEventName = "CursorLang.ActivationRequest";
- private readonly Dispatcher _dispatcher = Dispatcher.CurrentDispatcher;
private readonly string _mutexName;
private readonly string _activationEventName;
@@ -28,23 +36,25 @@ public sealed class SingleInstanceGate : IDisposable
private RegisteredWaitHandle? _activationWait;
private bool _isOwner;
- public SingleInstanceGate()
- : this(string.Empty)
- {
- }
-
///
- /// Adds a distinguishing part to the kernel object names. Needed by the tests:
- /// otherwise they would share the single-instance slot with the running
- /// application and get in its way.
+ /// Takes a named slot. The name tells the agent's slot from the settings window's,
+ /// and the tests use one of their own: otherwise they would share a slot with the
+ /// running application and get in its way.
///
- internal SingleInstanceGate(string nameSuffix)
+ public SingleInstanceGate(string nameSuffix)
{
_mutexName = MutexName + nameSuffix;
_activationEventName = ActivationEventName + nameSuffix;
}
- /// Another launch asks for the window to be shown.
+ ///
+ /// Another launch asks for the window to be shown.
+ ///
+ ///
+ /// Raised on a thread pool thread, wherever the wait happened to be answered. The
+ /// two hosts get back to their own thread differently — one through the dispatcher,
+ /// one by posting to its window — so neither is assumed here.
+ ///
public event EventHandler? ActivationRequested;
///
@@ -124,8 +134,6 @@ public sealed class SingleInstanceGate : IDisposable
_mutex = null;
}
- // The thread pool reports the request from wherever it happens to be, while the
- // window obeys only its own thread
private void OnActivationSignalled(object? state, bool timedOut) =>
- _dispatcher.BeginInvoke(() => ActivationRequested?.Invoke(this, EventArgs.Empty));
+ ActivationRequested?.Invoke(this, EventArgs.Empty);
}
diff --git a/CursorLang/Services/StartupLaunch.cs b/CursorLang.Core/Services/StartupLaunch.cs
similarity index 96%
rename from CursorLang/Services/StartupLaunch.cs
rename to CursorLang.Core/Services/StartupLaunch.cs
index b7b0fba..a636c02 100644
--- a/CursorLang/Services/StartupLaunch.cs
+++ b/CursorLang.Core/Services/StartupLaunch.cs
@@ -1,9 +1,9 @@
using System.Runtime.InteropServices;
-using CursorLang.Interop;
+using CursorLang.Core.Interop;
using Windows.ApplicationModel;
using Windows.ApplicationModel.Activation;
-namespace CursorLang.Services;
+namespace CursorLang.Core.Services;
///
/// Whether Windows started the application by itself.
diff --git a/CursorLang/Services/StartupService.cs b/CursorLang.Core/Services/StartupService.cs
similarity index 96%
rename from CursorLang/Services/StartupService.cs
rename to CursorLang.Core/Services/StartupService.cs
index 22d3c9b..4a87c9b 100644
--- a/CursorLang/Services/StartupService.cs
+++ b/CursorLang.Core/Services/StartupService.cs
@@ -1,9 +1,9 @@
using System.Runtime.InteropServices;
-using CursorLang.Interop;
-using CursorLang.Models;
+using CursorLang.Core.Interop;
+using CursorLang.Core.Models;
using Windows.ApplicationModel;
-namespace CursorLang.Services;
+namespace CursorLang.Core.Services;
///
/// Startup, arranged by whatever means the current build has.
diff --git a/CursorLang/Services/UpdateOptions.cs b/CursorLang.Core/Services/UpdateOptions.cs
similarity index 97%
rename from CursorLang/Services/UpdateOptions.cs
rename to CursorLang.Core/Services/UpdateOptions.cs
index 60d9939..4a4a8a9 100644
--- a/CursorLang/Services/UpdateOptions.cs
+++ b/CursorLang.Core/Services/UpdateOptions.cs
@@ -1,4 +1,4 @@
-namespace CursorLang.Services;
+namespace CursorLang.Core.Services;
///
/// Where the application learns about new versions from.
diff --git a/CursorLang/Services/UpdateService.cs b/CursorLang.Core/Services/UpdateService.cs
similarity index 98%
rename from CursorLang/Services/UpdateService.cs
rename to CursorLang.Core/Services/UpdateService.cs
index b74fc05..6ec8acd 100644
--- a/CursorLang/Services/UpdateService.cs
+++ b/CursorLang.Core/Services/UpdateService.cs
@@ -1,14 +1,12 @@
using System.Diagnostics;
-using System.IO;
-using System.Net.Http;
using System.Net.Http.Headers;
using System.Reflection;
using System.Runtime.InteropServices;
-using CursorLang.Interop;
-using CursorLang.Models;
+using CursorLang.Core.Interop;
+using CursorLang.Core.Models;
using Windows.ApplicationModel;
-namespace CursorLang.Services;
+namespace CursorLang.Core.Services;
///
/// Learns about new versions from the repository and hands the downloaded package
diff --git a/CursorLang.Core/Threading/MessageLoop.cs b/CursorLang.Core/Threading/MessageLoop.cs
new file mode 100644
index 0000000..7694e62
--- /dev/null
+++ b/CursorLang.Core/Threading/MessageLoop.cs
@@ -0,0 +1,66 @@
+using System.Runtime.InteropServices;
+
+namespace CursorLang.Core.Threading;
+
+///
+/// The Win32 message loop — what the application has instead of a dispatcher.
+///
+///
+/// It lives in Core rather than in the agent because the things that need a pumping
+/// thread do: is used by the layout polling and by saving
+/// the settings, and both are Core's. The settings window has a loop of its own, run
+/// by WPF, and everything here works inside it just the same.
+///
+public static class MessageLoop
+{
+ ///
+ /// Pumps messages until WM_QUIT and returns its exit code.
+ ///
+ ///
+ /// A -1 from GetMessage means the window handle has already gone; going round
+ /// again would spin forever, so the loop gives up instead.
+ ///
+ public static int Run()
+ {
+ while (true)
+ {
+ int result = GetMessage(out Message message, IntPtr.Zero, 0, 0);
+ if (result is 0 or -1)
+ {
+ return result == 0 ? (int)message.wParam : 1;
+ }
+
+ TranslateMessage(ref message);
+ DispatchMessage(ref message);
+ }
+ }
+
+ /// Asks the loop on this thread to finish.
+ public static void Quit(int exitCode = 0) => PostQuitMessage(exitCode);
+
+
+ [DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "GetMessageW")]
+ private static extern int GetMessage(out Message lpMsg, IntPtr hWnd, uint filterMin, uint filterMax);
+
+ [DllImport("user32.dll")]
+ private static extern bool TranslateMessage(ref Message lpMsg);
+
+ [DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "DispatchMessageW")]
+ private static extern IntPtr DispatchMessage(ref Message lpMsg);
+
+ [DllImport("user32.dll")]
+ private static extern void PostQuitMessage(int exitCode);
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct Message
+ {
+ public IntPtr hwnd;
+ public uint message;
+ public IntPtr wParam;
+ public IntPtr lParam;
+ public uint time;
+ public int x;
+ public int y;
+ public uint lPrivate;
+ }
+}
diff --git a/CursorLang.Core/Threading/MessageTimer.cs b/CursorLang.Core/Threading/MessageTimer.cs
new file mode 100644
index 0000000..b974efa
--- /dev/null
+++ b/CursorLang.Core/Threading/MessageTimer.cs
@@ -0,0 +1,70 @@
+using System.Runtime.InteropServices;
+
+namespace CursorLang.Core.Threading;
+
+///
+/// A timer that ticks on the message loop — the agent's stand-in for
+/// DispatcherTimer.
+///
+///
+/// SetTimer with a null window binds the timer to the thread rather than to a
+/// window, and DispatchMessage calls the callback straight from the loop. The
+/// upshot is the same as with a dispatcher timer: the tick arrives on the thread that
+/// owns the hook and the popup, so nothing needs marshalling and nothing races.
+///
+/// The callback lives in a field for the reason a hook procedure does: the only
+/// reference to it is held by Win32, and a collected delegate takes the process down
+/// with it at the first tick.
+///
+internal sealed class MessageTimer : IDisposable
+{
+ /// Windows will not go below this, and pretending otherwise misleads.
+ private const uint MinimumIntervalMilliseconds = 10;
+
+ private readonly TimerProc _callback;
+
+ private nuint _id;
+
+ internal MessageTimer() => _callback = OnTimer;
+
+ internal event EventHandler? Tick;
+
+ internal TimeSpan Interval { get; set; } = TimeSpan.FromMilliseconds(100);
+
+ internal bool IsRunning => _id != 0;
+
+ /// Starts the timer, or restarts it from zero when it is already running.
+ internal void Start()
+ {
+ Stop();
+
+ var milliseconds = (uint)Math.Clamp(
+ Math.Round(Interval.TotalMilliseconds), MinimumIntervalMilliseconds, int.MaxValue);
+
+ _id = SetTimer(IntPtr.Zero, 0, milliseconds, _callback);
+ }
+
+ internal void Stop()
+ {
+ if (_id == 0)
+ {
+ return;
+ }
+
+ KillTimer(IntPtr.Zero, _id);
+ _id = 0;
+ }
+
+ public void Dispose() => Stop();
+
+ private void OnTimer(IntPtr window, uint message, nuint id, uint time) =>
+ Tick?.Invoke(this, EventArgs.Empty);
+
+ private delegate void TimerProc(IntPtr hWnd, uint message, nuint idEvent, uint time);
+
+ [DllImport("user32.dll", SetLastError = true)]
+ private static extern nuint SetTimer(IntPtr hWnd, nuint nIDEvent, uint uElapse, TimerProc lpTimerFunc);
+
+ [DllImport("user32.dll", SetLastError = true)]
+ private static extern bool KillTimer(IntPtr hWnd, nuint uIDEvent);
+}
diff --git a/CursorLang.Tests/AppTests.cs b/CursorLang.Settings.Tests/AppTests.cs
similarity index 61%
rename from CursorLang.Tests/AppTests.cs
rename to CursorLang.Settings.Tests/AppTests.cs
index 11ad39f..71ee512 100644
--- a/CursorLang.Tests/AppTests.cs
+++ b/CursorLang.Settings.Tests/AppTests.cs
@@ -1,15 +1,17 @@
-using CursorLang.Models;
-using CursorLang.Services;
-using CursorLang.ViewModels;
-using CursorLang.Views;
+using CursorLang.Core.Models;
+using CursorLang.Core.Services;
+using CursorLang.Settings.Services;
+using CursorLang.Settings.ViewModels;
+using CursorLang.Settings.Views;
using Microsoft.Extensions.DependencyInjection;
-namespace CursorLang.Tests;
+namespace CursorLang.Settings.Tests;
///
-/// What the container is made of. The tests cannot build the application whole
-/// — it would raise windows and take the place of the single instance — but
-/// checking that everything needed is declared and resolvable works without that.
+/// What the container of the settings process is made of. The tests cannot build the
+/// application whole — it would raise a window and take the place of the single
+/// instance — but checking that everything needed is declared and resolvable works
+/// without that.
///
public sealed class AppTests
{
@@ -18,34 +20,41 @@ 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))]
[InlineData(typeof(UpdateOptions))]
- [InlineData(typeof(IKeyboardLayoutService))]
- [InlineData(typeof(ILayoutPopupService))]
- [InlineData(typeof(ICapsLockHotkeyService))]
- [InlineData(typeof(ILayoutPopupWindow))]
- [InlineData(typeof(LayoutNotificationCoordinator))]
- [InlineData(typeof(CapsLockSwitchCoordinator))]
- [InlineData(typeof(LayoutPopupViewModel))]
[InlineData(typeof(SettingsViewModel))]
[InlineData(typeof(UpdateViewModel))]
- [InlineData(typeof(LayoutPopupWindow))]
[InlineData(typeof(MainWindow))]
- [InlineData(typeof(KeyboardLayoutOptions))]
[InlineData(typeof(AppSettings))]
- public void Everything_the_app_needs_is_declared_in_the_container(Type service)
+ public void Everything_the_window_needs_is_declared_in_the_container(Type service)
{
Assert.Contains(Describe(), descriptor => descriptor.ServiceType == service);
}
- // The settings, the tooltip and the layout watch have to be shared by the
- // whole application: a second copy of them would mean a second
- // tooltip or lost settings
+ ///
+ /// The background half is not in here, and must not be.
+ ///
+ ///
+ /// The hook, the popup and the layout polling belong to the agent process now. A
+ /// registration of any of them here would mean two applications watching the
+ /// keyboard at once — and the second of them holding WPF while it did so.
+ ///
+ [Theory]
+ [InlineData("KeyboardLayoutService")]
+ [InlineData("LayoutPopupService")]
+ [InlineData("CapsLockHotkeyService")]
+ [InlineData("LayoutNotificationCoordinator")]
+ [InlineData("CapsLockSwitchCoordinator")]
+ [InlineData("TrayIcon")]
+ public void The_background_half_is_not_in_the_settings_container(string name)
+ {
+ Assert.DoesNotContain(Describe(), descriptor => descriptor.ServiceType.Name.Contains(name));
+ }
+
+ // The settings and the theme have to be shared by the whole window: a second copy
+ // of them would mean lost edits or half the controls in the wrong colours
[Fact]
public void Everything_in_the_container_is_declared_as_a_single_copy()
{
@@ -94,24 +103,6 @@ public sealed class AppTests
Assert.NotNull(settings.ImplementationFactory);
}
- [Fact]
- public void The_tooltip_window_and_its_interface_are_one_window()
- {
- ServiceDescriptor window = Describe()
- .Single(descriptor => descriptor.ServiceType == typeof(ILayoutPopupWindow));
-
- 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/CursorLang.Tests.csproj b/CursorLang.Settings.Tests/CursorLang.Settings.Tests.csproj
similarity index 69%
rename from CursorLang.Tests/CursorLang.Tests.csproj
rename to CursorLang.Settings.Tests/CursorLang.Settings.Tests.csproj
index baad7b2..98b0206 100644
--- a/CursorLang.Tests/CursorLang.Tests.csproj
+++ b/CursorLang.Settings.Tests/CursorLang.Settings.Tests.csproj
@@ -13,9 +13,14 @@
$(NoWarn);CS1591falsetrue
- $(NoWarn);IDE0130
+ CursorLang.Settings.Tests
+ CursorLang.Settings.Tests
+
+
+
+
@@ -24,22 +29,15 @@
-
-
-
-
-
- <_Parameter1>CursorLangExecutable
- <_Parameter2>$(MSBuildThisFileDirectory)..\CursorLang\bin\$(Configuration)\$(TargetFramework)\CursorLang.exe
-
-
-
-
-
+
+
+
+
- $(MSBuildThisFileDirectory)..\CursorLang\Views\MainWindow.xaml
+ $(MSBuildThisFileDirectory)..\CursorLang.Settings\Views\MainWindow.xaml
@@ -52,8 +50,7 @@
SkipUnchangedFiles="true" />
-
+
diff --git a/CursorLang.Settings.Tests/Infrastructure/SettingsFakes.cs b/CursorLang.Settings.Tests/Infrastructure/SettingsFakes.cs
new file mode 100644
index 0000000..29148b0
--- /dev/null
+++ b/CursorLang.Settings.Tests/Infrastructure/SettingsFakes.cs
@@ -0,0 +1,28 @@
+using CursorLang.Core.Models;
+using CursorLang.Core.Services;
+using CursorLang.Settings.Services;
+using CursorLang.Settings.ViewModels;
+using CursorLang.Tests.Shared;
+
+namespace CursorLang.Settings.Tests.Infrastructure;
+
+///
+/// Parts every test needs but few tests care about.
+///
+internal static class Fake
+{
+ internal static UpdateViewModel Updates() =>
+ new(new FakeUpdateService(), new FakeLocalizationService(), new AppSettings(), new UpdateOptions());
+}
+
+///
+/// A theme that paints nothing and only remembers the windows attached to it.
+///
+internal sealed class FakeThemeService : IThemeService
+{
+ public AppTheme CurrentTheme { get; set; } = AppTheme.Light;
+
+ internal List Registered { get; } = [];
+
+ public void Register(System.Windows.Window window) => Registered.Add(window);
+}
diff --git a/CursorLang.Tests/Infrastructure/Sta.cs b/CursorLang.Settings.Tests/Infrastructure/Sta.cs
similarity index 97%
rename from CursorLang.Tests/Infrastructure/Sta.cs
rename to CursorLang.Settings.Tests/Infrastructure/Sta.cs
index 9ce03c1..7223f28 100644
--- a/CursorLang.Tests/Infrastructure/Sta.cs
+++ b/CursorLang.Settings.Tests/Infrastructure/Sta.cs
@@ -1,7 +1,7 @@
using System.Windows;
using System.Windows.Threading;
-namespace CursorLang.Tests.Infrastructure;
+namespace CursorLang.Settings.Tests.Infrastructure;
///
/// The user interface thread for the tests.
@@ -159,7 +159,7 @@ internal static class Sta
application.Resources.MergedDictionaries.Add(new ResourceDictionary
{
Source = new Uri(
- "pack://application:,,,/CursorLang;component/Themes/Controls.xaml",
+ "pack://application:,,,/CursorLang.Settings;component/Themes/Controls.xaml",
UriKind.Absolute),
});
diff --git a/CursorLang.Tests/Interop/ForegroundWindowTests.cs b/CursorLang.Settings.Tests/Interop/ForegroundWindowTests.cs
similarity index 81%
rename from CursorLang.Tests/Interop/ForegroundWindowTests.cs
rename to CursorLang.Settings.Tests/Interop/ForegroundWindowTests.cs
index 6a7c2f7..7649fb4 100644
--- a/CursorLang.Tests/Interop/ForegroundWindowTests.cs
+++ b/CursorLang.Settings.Tests/Interop/ForegroundWindowTests.cs
@@ -1,13 +1,11 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Interop;
-using CursorLang.Interop;
-using CursorLang.Models;
-using CursorLang.Tests.Infrastructure;
-using CursorLang.ViewModels;
-using CursorLang.Views;
+using CursorLang.Core.Interop;
+using CursorLang.Settings.Interop;
+using CursorLang.Settings.Tests.Infrastructure;
-namespace CursorLang.Tests.Interop;
+namespace CursorLang.Settings.Tests.Interop;
///
/// Checks that need a real foreground window with an input field: the caret
@@ -59,31 +57,11 @@ public sealed class ForegroundWindowTests
Assert.Skip("The input field did not report the caret position");
}
- var settings = new AppSettings
- {
- PlacementMode = PopupPlacementMode.AtCaret,
- CaretSide = AnchorSide.BottomRight,
- CaretOffset = 8,
- };
+ PopupWindowNative.Rect field =
+ WindowPlacementNative.TryGetBounds(input.Handle)!.Value;
- var viewModel = new LayoutPopupViewModel(settings) { ShortName = "RU" };
- var popup = new LayoutPopupWindow(viewModel, settings);
-
- try
- {
- popup.ShowPopup();
-
- PopupWindowNative.Rect bounds =
- WindowPlacementNative.TryGetBounds(new WindowInteropHelper(popup).Handle)!.Value;
-
- // The tooltip landed to the right of and below the caret — as asked
- Assert.True(bounds.Left >= caret.Value.Right);
- Assert.True(bounds.Top >= caret.Value.Bottom);
- }
- finally
- {
- popup.Close();
- }
+ Assert.True(CaretNative.IsInside(caret.Value, field),
+ "the caret reported by the input field is outside that field");
});
}
diff --git a/CursorLang.Tests/Interop/NativeWrappersTests.cs b/CursorLang.Settings.Tests/Interop/NativeWrappersTests.cs
similarity index 85%
rename from CursorLang.Tests/Interop/NativeWrappersTests.cs
rename to CursorLang.Settings.Tests/Interop/NativeWrappersTests.cs
index 79c6374..1da9e54 100644
--- a/CursorLang.Tests/Interop/NativeWrappersTests.cs
+++ b/CursorLang.Settings.Tests/Interop/NativeWrappersTests.cs
@@ -1,9 +1,10 @@
using System.Windows;
using System.Windows.Interop;
-using CursorLang.Interop;
-using CursorLang.Tests.Infrastructure;
+using CursorLang.Core.Interop;
+using CursorLang.Settings.Interop;
+using CursorLang.Settings.Tests.Infrastructure;
-namespace CursorLang.Tests.Interop;
+namespace CursorLang.Settings.Tests.Interop;
///
/// The Win32 wrappers: what is checked is that the calls are put together
@@ -93,23 +94,6 @@ public sealed class NativeWrappersTests
});
}
- [Fact]
- public void The_window_bounds_are_set_as_a_whole()
- {
- Sta.Run(() =>
- {
- using var window = new HandleWindow();
-
- PopupWindowNative.SetBounds(window.Handle, 60, 70, 320, 240);
-
- PopupWindowNative.Rect bounds = WindowPlacementNative.TryGetBounds(window.Handle)!.Value;
- Assert.Equal(60, bounds.Left);
- Assert.Equal(70, bounds.Top);
- Assert.Equal(380, bounds.Right);
- Assert.Equal(310, bounds.Bottom);
- });
- }
-
[Fact]
public void The_work_area_is_found_by_the_rectangle_of_the_window()
{
@@ -138,21 +122,6 @@ public sealed class NativeWrappersTests
Assert.True(work.Value.Right > work.Value.Left);
}
- [Fact]
- public void A_window_becomes_invisible_to_the_focus_and_the_switcher()
- {
- Sta.Run(() =>
- {
- using var window = new HandleWindow();
-
- PopupWindowNative.MakePassive(window.Handle);
-
- // Checked through the same wrapper: the style has to stick and not
- // be reset by a repeated call
- PopupWindowNative.MakePassive(window.Handle);
- });
- }
-
[Fact]
public void The_layout_of_the_foreground_window_is_read()
{
diff --git a/CursorLang.Tests/Services/MainWindowPlacementTests.cs b/CursorLang.Settings.Tests/Services/MainWindowPlacementTests.cs
similarity index 98%
rename from CursorLang.Tests/Services/MainWindowPlacementTests.cs
rename to CursorLang.Settings.Tests/Services/MainWindowPlacementTests.cs
index e3e0f1c..b7043b9 100644
--- a/CursorLang.Tests/Services/MainWindowPlacementTests.cs
+++ b/CursorLang.Settings.Tests/Services/MainWindowPlacementTests.cs
@@ -1,10 +1,11 @@
using System.Windows;
using System.Windows.Interop;
-using CursorLang.Interop;
-using CursorLang.Services;
-using CursorLang.Tests.Infrastructure;
+using CursorLang.Core.Interop;
+using CursorLang.Settings.Interop;
+using CursorLang.Settings.Services;
+using CursorLang.Settings.Tests.Infrastructure;
-namespace CursorLang.Tests.Services;
+namespace CursorLang.Settings.Tests.Services;
///
/// Placing the settings window: the centre maths and bringing the window back
diff --git a/CursorLang.Tests/Services/ThemeServiceTests.cs b/CursorLang.Settings.Tests/Services/ThemeServiceTests.cs
similarity index 97%
rename from CursorLang.Tests/Services/ThemeServiceTests.cs
rename to CursorLang.Settings.Tests/Services/ThemeServiceTests.cs
index 7e614df..a624a34 100644
--- a/CursorLang.Tests/Services/ThemeServiceTests.cs
+++ b/CursorLang.Settings.Tests/Services/ThemeServiceTests.cs
@@ -3,12 +3,12 @@ using System.Windows.Controls;
using System.Windows.Interop;
using System.Windows.Media;
using System.Windows.Shapes;
-using CursorLang.Models;
-using CursorLang.Services;
-using CursorLang.Tests.Infrastructure;
+using CursorLang.Core.Models;
+using CursorLang.Settings.Services;
+using CursorLang.Settings.Tests.Infrastructure;
using Microsoft.Win32;
-namespace CursorLang.Tests.Services;
+namespace CursorLang.Settings.Tests.Services;
///
/// The look of the windows. The palette lives in the application resources,
@@ -325,7 +325,11 @@ public sealed class ThemeServiceTests
[Fact]
public void The_palette_address_names_the_application_assembly()
{
- Assert.Contains("CursorLang;component", ThemeService.PaletteUri(AppTheme.Dark).ToString(), StringComparison.Ordinal);
+ // The palettes live with the settings window, not with the agent next to it
+ Assert.Contains(
+ "CursorLang.Settings;component",
+ ThemeService.PaletteUri(AppTheme.Dark).ToString(),
+ StringComparison.Ordinal);
}
[Fact]
diff --git a/CursorLang.Tests/ViewModels/EnumOptionTests.cs b/CursorLang.Settings.Tests/ViewModels/EnumOptionTests.cs
similarity index 93%
rename from CursorLang.Tests/ViewModels/EnumOptionTests.cs
rename to CursorLang.Settings.Tests/ViewModels/EnumOptionTests.cs
index d86c731..e6d31c7 100644
--- a/CursorLang.Tests/ViewModels/EnumOptionTests.cs
+++ b/CursorLang.Settings.Tests/ViewModels/EnumOptionTests.cs
@@ -1,7 +1,7 @@
-using CursorLang.Models;
-using CursorLang.ViewModels;
+using CursorLang.Core.Models;
+using CursorLang.Settings.ViewModels;
-namespace CursorLang.Tests.ViewModels;
+namespace CursorLang.Settings.Tests.ViewModels;
public sealed class EnumOptionTests
{
diff --git a/CursorLang.Tests/ViewModels/SettingsViewModelTests.cs b/CursorLang.Settings.Tests/ViewModels/SettingsViewModelTests.cs
similarity index 97%
rename from CursorLang.Tests/ViewModels/SettingsViewModelTests.cs
rename to CursorLang.Settings.Tests/ViewModels/SettingsViewModelTests.cs
index f04888f..5731177 100644
--- a/CursorLang.Tests/ViewModels/SettingsViewModelTests.cs
+++ b/CursorLang.Settings.Tests/ViewModels/SettingsViewModelTests.cs
@@ -1,10 +1,10 @@
-using System.Windows.Media;
-using CursorLang.Models;
-using CursorLang.Services;
-using CursorLang.Tests.Infrastructure;
-using CursorLang.ViewModels;
+using CursorLang.Core.Models;
+using CursorLang.Core.Services;
+using CursorLang.Settings.Tests.Infrastructure;
+using CursorLang.Settings.ViewModels;
+using CursorLang.Tests.Shared;
-namespace CursorLang.Tests.ViewModels;
+namespace CursorLang.Settings.Tests.ViewModels;
///
/// The settings window in terms of what it shows and what it is in charge of.
@@ -133,7 +133,7 @@ public sealed class SettingsViewModelTests
{
using SettingsViewModel viewModel = Create();
- Assert.All(viewModel.BackgroundPalette, color => Assert.IsType(color));
+ Assert.All(viewModel.BackgroundPalette, color => Assert.IsType(color));
}
[Fact]
diff --git a/CursorLang.Tests/ViewModels/UpdateViewModelTests.cs b/CursorLang.Settings.Tests/ViewModels/UpdateViewModelTests.cs
similarity index 98%
rename from CursorLang.Tests/ViewModels/UpdateViewModelTests.cs
rename to CursorLang.Settings.Tests/ViewModels/UpdateViewModelTests.cs
index de9c80c..165505e 100644
--- a/CursorLang.Tests/ViewModels/UpdateViewModelTests.cs
+++ b/CursorLang.Settings.Tests/ViewModels/UpdateViewModelTests.cs
@@ -1,11 +1,11 @@
using System.IO;
using System.Net.Http;
-using CursorLang.Models;
-using CursorLang.Services;
-using CursorLang.Tests.Infrastructure;
-using CursorLang.ViewModels;
+using CursorLang.Core.Models;
+using CursorLang.Core.Services;
+using CursorLang.Settings.ViewModels;
+using CursorLang.Tests.Shared;
-namespace CursorLang.Tests.ViewModels;
+namespace CursorLang.Settings.Tests.ViewModels;
///
/// The updates section of the settings window: what it shows at every step and
diff --git a/CursorLang.Tests/Views/ConvertersTests.cs b/CursorLang.Settings.Tests/Views/ConvertersTests.cs
similarity index 81%
rename from CursorLang.Tests/Views/ConvertersTests.cs
rename to CursorLang.Settings.Tests/Views/ConvertersTests.cs
index d1e7239..e57e739 100644
--- a/CursorLang.Tests/Views/ConvertersTests.cs
+++ b/CursorLang.Settings.Tests/Views/ConvertersTests.cs
@@ -2,14 +2,16 @@ using System.Globalization;
using System.Windows;
using System.Windows.Data;
using System.Windows.Media;
-using CursorLang.Models;
-using CursorLang.Views;
+using CursorLang.Core.Models;
+using CursorLang.Settings.Views;
+using DrawingColor = System.Drawing.Color;
-namespace CursorLang.Tests.Views;
+namespace CursorLang.Settings.Tests.Views;
///
/// The binding converters: they decide what the settings window shows and what
-/// it keeps out of sight.
+/// it keeps out of sight — and they are the border between the colours the
+/// settings hold, which are GDI ones, and the brushes WPF paints with.
///
public sealed class ConvertersTests
{
@@ -85,7 +87,7 @@ public sealed class ConvertersTests
{
var converter = new ColorToHexConverter();
- Assert.Equal("#0A1B2C", converter.Convert(Color.FromRgb(0x0A, 0x1B, 0x2C), typeof(string), null, Culture));
+ Assert.Equal("#0A1B2C", converter.Convert(DrawingColor.FromArgb(0x0A, 0x1B, 0x2C), typeof(string), null, Culture));
}
[Fact]
@@ -95,7 +97,7 @@ public sealed class ConvertersTests
Assert.Equal(
"#102030",
- converter.Convert(Color.FromArgb(0x80, 0x10, 0x20, 0x30), typeof(string), null, Culture));
+ converter.Convert(DrawingColor.FromArgb(0x80, 0x10, 0x20, 0x30), typeof(string), null, Culture));
}
[Theory]
@@ -114,18 +116,18 @@ public sealed class ConvertersTests
{
var converter = new ColorToHexConverter();
- Assert.Equal(Binding.DoNothing, converter.ConvertBack("#102030", typeof(Color), null, Culture));
+ Assert.Equal(Binding.DoNothing, converter.ConvertBack("#102030", typeof(DrawingColor), null, Culture));
}
[Fact]
public void A_colour_turns_into_a_brush()
{
var converter = new ColorToBrushConverter();
- Color color = Color.FromRgb(0x10, 0x20, 0x30);
+ DrawingColor color = DrawingColor.FromArgb(0x10, 0x20, 0x30);
var brush = Assert.IsType(converter.Convert(color, typeof(Brush), null, Culture));
- Assert.Equal(color, brush.Color);
+ Assert.Equal(Color.FromArgb(color.A, color.R, color.G, color.B), brush.Color);
}
[Theory]
@@ -143,9 +145,10 @@ public sealed class ConvertersTests
public void A_brush_converts_back_into_a_colour()
{
var converter = new ColorToBrushConverter();
- Color color = Color.FromRgb(0x10, 0x20, 0x30);
+ DrawingColor color = DrawingColor.FromArgb(0x10, 0x20, 0x30);
+ var brush = new SolidColorBrush(Color.FromArgb(color.A, color.R, color.G, color.B));
- Assert.Equal(color, converter.ConvertBack(new SolidColorBrush(color), typeof(Color), null, Culture));
+ Assert.Equal(color, converter.ConvertBack(brush, typeof(DrawingColor), null, Culture));
}
[Theory]
@@ -155,7 +158,7 @@ public sealed class ConvertersTests
{
var converter = new ColorToBrushConverter();
- Assert.Equal(Binding.DoNothing, converter.ConvertBack(value, typeof(Color), null, Culture));
+ Assert.Equal(Binding.DoNothing, converter.ConvertBack(value, typeof(DrawingColor), null, Culture));
}
[Fact]
diff --git a/CursorLang.Tests/Views/MainWindowTests.cs b/CursorLang.Settings.Tests/Views/MainWindowTests.cs
similarity index 94%
rename from CursorLang.Tests/Views/MainWindowTests.cs
rename to CursorLang.Settings.Tests/Views/MainWindowTests.cs
index b8c0dde..bfe6ba5 100644
--- a/CursorLang.Tests/Views/MainWindowTests.cs
+++ b/CursorLang.Settings.Tests/Views/MainWindowTests.cs
@@ -3,13 +3,15 @@ using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Data;
using System.Windows.Media;
-using CursorLang.Models;
-using CursorLang.Services;
-using CursorLang.Tests.Infrastructure;
-using CursorLang.ViewModels;
-using CursorLang.Views;
+using CursorLang.Core.Models;
+using CursorLang.Core.Services;
+using CursorLang.Settings.Services;
+using CursorLang.Settings.Tests.Infrastructure;
+using CursorLang.Settings.ViewModels;
+using CursorLang.Settings.Views;
+using CursorLang.Tests.Shared;
-namespace CursorLang.Tests.Views;
+namespace CursorLang.Settings.Tests.Views;
///
/// The settings window as a whole: the markup, the bindings and the hook-up
@@ -141,7 +143,7 @@ public sealed class MainWindowTests
// The chosen colour is shown as a swatch with a caption — in the
// same notation the settings file uses
- Color chosen = viewModel.BackgroundPalette[2];
+ System.Drawing.Color chosen = viewModel.BackgroundPalette[2];
settings.BackgroundColor = chosen;
string expected = $"#{chosen.R:X2}{chosen.G:X2}{chosen.B:X2}";
@@ -267,8 +269,12 @@ 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
+ ///
+ /// Closing the window closes it. The tray it used to hide into belongs to another
+ /// process now, and there is nothing shared left to keep alive.
+ ///
[Fact]
- public void The_window_hooks_up_to_the_tray()
+ public void Closing_the_window_really_closes_it()
{
Sta.Run(() =>
{
@@ -280,9 +286,7 @@ public sealed class MainWindowTests
Assert.False(window.IsVisible);
- // A closed window would refuse this
- window.Show();
- Assert.True(window.IsVisible);
+ Assert.Throws(window.Show);
});
});
}
@@ -327,9 +331,7 @@ public sealed class MainWindowTests
MainWindowPlacement placement,
Action check)
{
- var presenter = new MainWindowPresenter(placement);
-
- var window = new MainWindow(viewModel, theme, placement, presenter)
+ var window = new MainWindow(viewModel, theme, placement)
{
// The window is needed alive, but not in sight
Opacity = 0,
@@ -346,9 +348,6 @@ 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.Settings.Tests/Views/MarkupStringsTests.cs b/CursorLang.Settings.Tests/Views/MarkupStringsTests.cs
new file mode 100644
index 0000000..2929c21
--- /dev/null
+++ b/CursorLang.Settings.Tests/Views/MarkupStringsTests.cs
@@ -0,0 +1,78 @@
+using System.Collections;
+using System.Globalization;
+using System.IO;
+using System.Reflection;
+using System.Resources;
+using System.Text.RegularExpressions;
+using CursorLang.Core.Services;
+
+namespace CursorLang.Settings.Tests.Views;
+
+///
+/// The settings window markup against the resource strings.
+///
+///
+/// The resources live in Core and the markup lives here, so the check that the two
+/// agree has to live here too. What the resources say among themselves — that every
+/// key is translated, that the placeholders match — is checked by StringsTests, next
+/// to the resources.
+///
+/// The markup is read as an embedded copy rather than off disk: a test that walks up
+/// the folder tree looking for a .xaml file breaks the moment anything moves.
+///
+public sealed partial class MarkupStringsTests
+{
+ private static readonly ResourceManager Resources =
+ new("CursorLang.Core.Resources.Strings", typeof(LocalizationService).Assembly);
+
+ ///
+ /// Every key the settings window markup asks for has to exist in the
+ /// resources: otherwise the user sees the key itself in its place.
+ ///
+ [Fact]
+ public void Every_key_from_the_settings_window_markup_exists_in_the_resources()
+ {
+ HashSet known = [.. NeutralKeys()];
+ List missing = [];
+
+ foreach (Match match in LocalizationBinding().Matches(ReadSettingsWindowMarkup()))
+ {
+ string key = match.Groups["key"].Value;
+ if (!known.Contains(key))
+ {
+ missing.Add(key);
+ }
+ }
+
+ Assert.Empty(missing);
+ }
+
+ /// The markup does ask for strings — otherwise the check above means nothing.
+ [Fact]
+ public void The_settings_window_markup_asks_for_resource_strings()
+ {
+ Assert.NotEmpty(LocalizationBinding().Matches(ReadSettingsWindowMarkup()));
+ }
+
+ private static IEnumerable NeutralKeys()
+ {
+ ResourceSet set = Resources.GetResourceSet(CultureInfo.InvariantCulture, true, true)!;
+
+ foreach (DictionaryEntry entry in set)
+ {
+ yield return (string)entry.Key;
+ }
+ }
+
+ private static string ReadSettingsWindowMarkup()
+ {
+ using Stream stream = Assembly.GetExecutingAssembly().GetManifestResourceStream("MainWindow.xaml")
+ ?? throw new InvalidOperationException("The settings window markup is not embedded in the test assembly");
+
+ using var reader = new StreamReader(stream);
+ return reader.ReadToEnd();
+ }
+
+ [GeneratedRegex(@"Localization\[(?\w+)\]")]
+ private static partial Regex LocalizationBinding();
+}
diff --git a/CursorLang/App.xaml b/CursorLang.Settings/App.xaml
similarity index 91%
rename from CursorLang/App.xaml
rename to CursorLang.Settings/App.xaml
index 6045535..8b3b393 100644
--- a/CursorLang/App.xaml
+++ b/CursorLang.Settings/App.xaml
@@ -1,4 +1,4 @@
-
diff --git a/CursorLang/App.xaml.cs b/CursorLang.Settings/App.xaml.cs
similarity index 50%
rename from CursorLang/App.xaml.cs
rename to CursorLang.Settings/App.xaml.cs
index d289e09..f7ea8b4 100644
--- a/CursorLang/App.xaml.cs
+++ b/CursorLang.Settings/App.xaml.cs
@@ -1,27 +1,38 @@
using System.Windows;
-using CursorLang.Services;
-using CursorLang.ViewModels;
-using CursorLang.Views;
+using CursorLang.Core.Services;
+using CursorLang.Settings.Services;
+using CursorLang.Settings.ViewModels;
+using CursorLang.Settings.Views;
using Microsoft.Extensions.DependencyInjection;
-namespace CursorLang;
+namespace CursorLang.Settings;
+///
+/// The settings window as a process of its own.
+///
+///
+/// It is started by the agent — from the tray menu, or straight away when the user
+/// launches the application themselves — and it ends when the window is closed. That
+/// is the whole point of the split: WPF costs around a hundred megabytes, and this way
+/// the system gets all of it back the moment the user is done, instead of the
+/// background process carrying it until sign-out.
+///
+/// Nothing here talks to the agent directly. Every edit goes into settings.json, and
+/// nudges the agent to re-read it once the file is
+/// written. An agent that is not running is a normal case: the window works the same.
+///
// ReSharper disable once RedundantExtendsListEntry
public partial class App : Application
{
private ServiceProvider? _services;
private SingleInstanceGate? _instanceGate;
- 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(showRunningInstance: !automatic))
+ var gate = new SingleInstanceGate(SingleInstanceGate.SettingsName);
+ if (!gate.TryAcquire(showRunningInstance: true))
{
gate.Dispose();
Shutdown();
@@ -35,43 +46,20 @@ public partial class App : Application
ConfigureServices(services);
_services = services.BuildServiceProvider();
+ _services.GetRequiredService().TrackChanges();
_services.GetRequiredService();
- _presenter = _services.GetRequiredService();
-
MainWindow = _services.GetRequiredService();
- _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;
- }
+ ShutdownMode = ShutdownMode.OnMainWindowClose;
+ MainWindow.Show();
_ = _services.GetRequiredService().InitializeAsync();
_ = _services.GetRequiredService().StartAsync();
- _services.GetRequiredService().Start();
- _services.GetRequiredService().Start();
}
protected override void OnExit(ExitEventArgs e)
{
- if (_tray is not null)
- {
- _tray.OpenRequested -= OnOpenRequested;
- _tray.ExitRequested -= OnExitRequested;
- }
-
_services?.Dispose();
if (_instanceGate is not null)
@@ -83,19 +71,8 @@ public partial class App : Application
base.OnExit(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)
- {
- _presenter?.AllowClose();
- Shutdown();
- }
-
internal static void ConfigureServices(IServiceCollection services)
{
- services.AddSingleton(new KeyboardLayoutOptions());
services.AddSingleton(new UpdateOptions());
services.AddSingleton();
@@ -105,26 +82,35 @@ 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();
services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton(provider => provider.GetRequiredService());
services.AddSingleton();
}
+
+ // A second launch — another click on "Settings" in the tray menu, say. The gate
+ // answers on a thread pool thread, and a window obeys only its own
+ private void OnActivationRequested(object? sender, EventArgs e) =>
+ Dispatcher.BeginInvoke(ShowMainWindow);
+
+ private void ShowMainWindow()
+ {
+ if (MainWindow is not { } window)
+ {
+ return;
+ }
+
+ if (window.WindowState == WindowState.Minimized)
+ {
+ window.WindowState = WindowState.Normal;
+ }
+
+ window.Show();
+ window.Activate();
+ }
}
diff --git a/CursorLang/AssemblyInfo.cs b/CursorLang.Settings/AssemblyInfo.cs
similarity index 72%
rename from CursorLang/AssemblyInfo.cs
rename to CursorLang.Settings/AssemblyInfo.cs
index 7b12e6b..d750994 100644
--- a/CursorLang/AssemblyInfo.cs
+++ b/CursorLang.Settings/AssemblyInfo.cs
@@ -2,4 +2,4 @@ using System.Runtime.CompilerServices;
using System.Windows;
[assembly: ThemeInfo(ResourceDictionaryLocation.None, ResourceDictionaryLocation.SourceAssembly)]
-[assembly: InternalsVisibleTo("CursorLang.Tests")]
+[assembly: InternalsVisibleTo("CursorLang.Settings.Tests")]
diff --git a/CursorLang/CursorLang.csproj b/CursorLang.Settings/CursorLang.Settings.csproj
similarity index 69%
rename from CursorLang/CursorLang.csproj
rename to CursorLang.Settings/CursorLang.Settings.csproj
index e358423..409a83c 100644
--- a/CursorLang/CursorLang.csproj
+++ b/CursorLang.Settings/CursorLang.Settings.csproj
@@ -10,11 +10,13 @@
truetrue$(NoWarn);CS1591
+ CursorLang.Settings
+ CursorLang.Settingstrueapp.manifest
- Resources\CursorLang.ico
- win-x64;win-arm64
+ ..\CursorLang.Core\Resources\CursorLang.icoAnyCPU
+ win-x64;win-arm64truefalsetrue
@@ -23,13 +25,17 @@
1.0.0.0CursorLangAleksandr Neychev
- Shows the keyboard layout at the cursor
+ Settings window of CursorLangCopyright (c) 2026
-
-
+
+
+
+
+
+
diff --git a/CursorLang/Interop/WindowPlacementNative.cs b/CursorLang.Settings/Interop/WindowPlacementNative.cs
similarity index 96%
rename from CursorLang/Interop/WindowPlacementNative.cs
rename to CursorLang.Settings/Interop/WindowPlacementNative.cs
index d4fff30..960c96f 100644
--- a/CursorLang/Interop/WindowPlacementNative.cs
+++ b/CursorLang.Settings/Interop/WindowPlacementNative.cs
@@ -1,6 +1,7 @@
using System.Runtime.InteropServices;
+using CursorLang.Core.Interop;
-namespace CursorLang.Interop;
+namespace CursorLang.Settings.Interop;
///
/// Win32 API for placing the settings window: its own bounds and the work area
diff --git a/CursorLang/Interop/WindowThemeNative.cs b/CursorLang.Settings/Interop/WindowThemeNative.cs
similarity index 96%
rename from CursorLang/Interop/WindowThemeNative.cs
rename to CursorLang.Settings/Interop/WindowThemeNative.cs
index 2ed1f71..63942bf 100644
--- a/CursorLang/Interop/WindowThemeNative.cs
+++ b/CursorLang.Settings/Interop/WindowThemeNative.cs
@@ -1,6 +1,6 @@
using System.Runtime.InteropServices;
-namespace CursorLang.Interop;
+namespace CursorLang.Settings.Interop;
///
/// Window frame styling by the system: the title bar is drawn by Windows,
diff --git a/CursorLang.Settings/Properties/launchSettings.json b/CursorLang.Settings/Properties/launchSettings.json
new file mode 100644
index 0000000..a653a23
--- /dev/null
+++ b/CursorLang.Settings/Properties/launchSettings.json
@@ -0,0 +1,7 @@
+{
+ "profiles": {
+ "Settings": {
+ "commandName": "Project"
+ }
+ }
+}
diff --git a/CursorLang/Services/IThemeService.cs b/CursorLang.Settings/Services/IThemeService.cs
similarity index 87%
rename from CursorLang/Services/IThemeService.cs
rename to CursorLang.Settings/Services/IThemeService.cs
index 8c1a6d3..0afa33d 100644
--- a/CursorLang/Services/IThemeService.cs
+++ b/CursorLang.Settings/Services/IThemeService.cs
@@ -1,7 +1,7 @@
using System.Windows;
-using CursorLang.Models;
+using CursorLang.Core.Models;
-namespace CursorLang.Services;
+namespace CursorLang.Settings.Services;
///
/// Applies the light or the dark look to the windows of the application.
diff --git a/CursorLang/Services/MainWindowPlacement.cs b/CursorLang.Settings/Services/MainWindowPlacement.cs
similarity index 98%
rename from CursorLang/Services/MainWindowPlacement.cs
rename to CursorLang.Settings/Services/MainWindowPlacement.cs
index 196d20a..17b288f 100644
--- a/CursorLang/Services/MainWindowPlacement.cs
+++ b/CursorLang.Settings/Services/MainWindowPlacement.cs
@@ -1,8 +1,9 @@
using System.Windows;
using System.Windows.Interop;
-using CursorLang.Interop;
+using CursorLang.Core.Interop;
+using CursorLang.Settings.Interop;
-namespace CursorLang.Services;
+namespace CursorLang.Settings.Services;
///
/// Decides where the settings window shows up: for the first time in a session — in
diff --git a/CursorLang/Services/ThemeService.cs b/CursorLang.Settings/Services/ThemeService.cs
similarity index 89%
rename from CursorLang/Services/ThemeService.cs
rename to CursorLang.Settings/Services/ThemeService.cs
index 133d96c..634b420 100644
--- a/CursorLang/Services/ThemeService.cs
+++ b/CursorLang.Settings/Services/ThemeService.cs
@@ -1,11 +1,11 @@
using System.ComponentModel;
using System.Windows;
using System.Windows.Interop;
-using CursorLang.Interop;
-using CursorLang.Models;
+using CursorLang.Core.Models;
+using CursorLang.Settings.Interop;
using Microsoft.Win32;
-namespace CursorLang.Services;
+namespace CursorLang.Settings.Services;
///
/// Keeps the palette of the chosen theme in the application resources and swaps it
@@ -132,9 +132,12 @@ public sealed class ThemeService : IThemeService, IDisposable
// The assembly name in the address is there on purpose: without it the dictionary
// is looked up in the assembly the process started from, which is not always the
- // application itself
- internal static Uri PaletteUri(AppTheme theme) =>
- new($"pack://application:,,,/CursorLang;component/Themes/{theme}.xaml", UriKind.Absolute);
+ // one holding the palettes. It is taken from the type rather than spelt out —
+ // the name of this assembly has changed once already, and a wrong one here is a
+ // crash on startup rather than a build error
+ internal static Uri PaletteUri(AppTheme theme) => new(
+ $"pack://application:,,,/{typeof(ThemeService).Assembly.GetName().Name};component/Themes/{theme}.xaml",
+ UriKind.Absolute);
private void ApplyTitleBar(Window window) =>
WindowThemeNative.SetDarkTitleBar(
diff --git a/CursorLang/Themes/Controls.xaml b/CursorLang.Settings/Themes/Controls.xaml
similarity index 100%
rename from CursorLang/Themes/Controls.xaml
rename to CursorLang.Settings/Themes/Controls.xaml
diff --git a/CursorLang/Themes/Dark.xaml b/CursorLang.Settings/Themes/Dark.xaml
similarity index 100%
rename from CursorLang/Themes/Dark.xaml
rename to CursorLang.Settings/Themes/Dark.xaml
diff --git a/CursorLang/Themes/Light.xaml b/CursorLang.Settings/Themes/Light.xaml
similarity index 100%
rename from CursorLang/Themes/Light.xaml
rename to CursorLang.Settings/Themes/Light.xaml
diff --git a/CursorLang/ViewModels/SettingsViewModel.cs b/CursorLang.Settings/ViewModels/SettingsViewModel.cs
similarity index 88%
rename from CursorLang/ViewModels/SettingsViewModel.cs
rename to CursorLang.Settings/ViewModels/SettingsViewModel.cs
index c19afcc..68ffad1 100644
--- a/CursorLang/ViewModels/SettingsViewModel.cs
+++ b/CursorLang.Settings/ViewModels/SettingsViewModel.cs
@@ -1,11 +1,11 @@
using System.ComponentModel;
+using System.Drawing;
using System.Windows.Data;
-using System.Windows.Media;
using CommunityToolkit.Mvvm.ComponentModel;
-using CursorLang.Models;
-using CursorLang.Services;
+using CursorLang.Core.Models;
+using CursorLang.Core.Services;
-namespace CursorLang.ViewModels;
+namespace CursorLang.Settings.ViewModels;
///
/// An option in a list. The caption changes together with the language, while the
@@ -42,26 +42,26 @@ public sealed partial class SettingsViewModel : ObservableObject, IDisposable
{
private static readonly Color[] Palette =
[
- Color.FromRgb(0x20, 0x20, 0x20),
- Color.FromRgb(0x00, 0x00, 0x00),
- Color.FromRgb(0x1E, 0x3A, 0x8A),
- Color.FromRgb(0x0F, 0x76, 0x6E),
- Color.FromRgb(0x7C, 0x2D, 0x12),
- Color.FromRgb(0x86, 0x19, 0x8F),
- Color.FromRgb(0xB9, 0x1C, 0x1C),
- Color.FromRgb(0xF5, 0xF5, 0xF5),
+ Color.FromArgb(0x20, 0x20, 0x20),
+ Color.FromArgb(0x00, 0x00, 0x00),
+ Color.FromArgb(0x1E, 0x3A, 0x8A),
+ Color.FromArgb(0x0F, 0x76, 0x6E),
+ Color.FromArgb(0x7C, 0x2D, 0x12),
+ Color.FromArgb(0x86, 0x19, 0x8F),
+ Color.FromArgb(0xB9, 0x1C, 0x1C),
+ Color.FromArgb(0xF5, 0xF5, 0xF5),
];
private static readonly Color[] ForegroundPalette =
[
- Color.FromRgb(0xFF, 0xFF, 0xFF),
- Color.FromRgb(0xD4, 0xD4, 0xD4),
- Color.FromRgb(0x00, 0x00, 0x00),
- Color.FromRgb(0xFA, 0xCC, 0x15),
- Color.FromRgb(0x4A, 0xDE, 0x80),
- Color.FromRgb(0x60, 0xA5, 0xFA),
- Color.FromRgb(0xF9, 0x73, 0x16),
- Color.FromRgb(0xF8, 0x71, 0x71),
+ Color.FromArgb(0xFF, 0xFF, 0xFF),
+ Color.FromArgb(0xD4, 0xD4, 0xD4),
+ Color.FromArgb(0x00, 0x00, 0x00),
+ Color.FromArgb(0xFA, 0xCC, 0x15),
+ Color.FromArgb(0x4A, 0xDE, 0x80),
+ Color.FromArgb(0x60, 0xA5, 0xFA),
+ Color.FromArgb(0xF9, 0x73, 0x16),
+ Color.FromArgb(0xF8, 0x71, 0x71),
];
private readonly IStartupService _startup;
diff --git a/CursorLang/ViewModels/UpdateViewModel.cs b/CursorLang.Settings/ViewModels/UpdateViewModel.cs
similarity index 90%
rename from CursorLang/ViewModels/UpdateViewModel.cs
rename to CursorLang.Settings/ViewModels/UpdateViewModel.cs
index f017cd8..15c1fb7 100644
--- a/CursorLang/ViewModels/UpdateViewModel.cs
+++ b/CursorLang.Settings/ViewModels/UpdateViewModel.cs
@@ -6,19 +6,19 @@ using System.Text.Json;
using System.Windows.Data;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
-using CursorLang.Models;
-using CursorLang.Services;
+using CursorLang.Core.Models;
+using CursorLang.Core.Services;
-namespace CursorLang.ViewModels;
+namespace CursorLang.Settings.ViewModels;
///
/// The updates section of the settings window.
///
///
-/// A failed check at startup passes in silence: the application does not always start
-/// with a live network, and there is no point complaining about it to a user who did
-/// not ask about updates. A check started by the button does report a failure — it is
-/// awaited and watched.
+/// A check made on opening the window passes its failures in silence: the machine does
+/// not always have a live network, and there is no point complaining about it to a user
+/// who came to change the popup colour. A check started by the button does report a
+/// failure — it is awaited and watched.
///
public sealed partial class UpdateViewModel : ObservableObject, IDisposable
{
@@ -120,8 +120,14 @@ public sealed partial class UpdateViewModel : ObservableObject, IDisposable
///
/// Checks for updates when the user has not forbidden it and enough time has
- /// passed since the previous check. Called once at startup.
+ /// passed since the previous check. Called once when the window opens.
///
+ ///
+ /// It used to be called when the application started, which back then meant when
+ /// the machine was switched on. The background half is a separate process now and
+ /// does not go to the network at all — nothing in it could show the answer — so the
+ /// question is asked when there is a window to answer into.
+ ///
public async Task StartAsync()
{
if (!IsSupported || !_settings.CheckForUpdates)
diff --git a/CursorLang/Views/Converters.cs b/CursorLang.Settings/Views/Converters.cs
similarity index 67%
rename from CursorLang/Views/Converters.cs
rename to CursorLang.Settings/Views/Converters.cs
index ae9ac5b..74dcc56 100644
--- a/CursorLang/Views/Converters.cs
+++ b/CursorLang.Settings/Views/Converters.cs
@@ -2,8 +2,9 @@ using System.Globalization;
using System.Windows;
using System.Windows.Data;
using System.Windows.Media;
+using DrawingColor = System.Drawing.Color;
-namespace CursorLang.Views;
+namespace CursorLang.Settings.Views;
///
/// Shows an element when the value matches one of those listed in the parameter,
@@ -30,10 +31,16 @@ public sealed class EnumToVisibilityConverter : IValueConverter
/// A colour into a "#RRGGBB" notation: the alpha is not shown, because
/// transparency is a separate setting.
///
+///
+/// The settings hold their colours as — the agent
+/// reads the same file and must not be made to load WindowsBase for a colour. Turning
+/// them into something WPF can paint with is the job of this file and of
+/// , and of nothing else.
+///
public sealed class ColorToHexConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
- value is Color color ? $"#{color.R:X2}{color.G:X2}{color.B:X2}" : string.Empty;
+ value is DrawingColor color ? $"#{color.R:X2}{color.G:X2}{color.B:X2}" : string.Empty;
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
Binding.DoNothing;
@@ -45,8 +52,12 @@ public sealed class ColorToHexConverter : IValueConverter
public sealed class ColorToBrushConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
- value is Color color ? new SolidColorBrush(color) : Brushes.Transparent;
+ value is DrawingColor color
+ ? new SolidColorBrush(Color.FromArgb(color.A, color.R, color.G, color.B))
+ : Brushes.Transparent;
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
- value is SolidColorBrush brush ? brush.Color : Binding.DoNothing;
+ value is SolidColorBrush brush
+ ? DrawingColor.FromArgb(brush.Color.A, brush.Color.R, brush.Color.G, brush.Color.B)
+ : Binding.DoNothing;
}
diff --git a/CursorLang/Views/MainWindow.xaml b/CursorLang.Settings/Views/MainWindow.xaml
similarity index 99%
rename from CursorLang/Views/MainWindow.xaml
rename to CursorLang.Settings/Views/MainWindow.xaml
index d42dad6..8439924 100644
--- a/CursorLang/Views/MainWindow.xaml
+++ b/CursorLang.Settings/Views/MainWindow.xaml
@@ -1,10 +1,10 @@
-
/// The settings window of the application.
///
+///
+/// Both buttons in the title bar mean what they say now: the window used to hide into
+/// the tray, because closing it would have thrown away the visual tree the background
+/// half was still using. The background half is a separate process and no longer cares,
+/// so closing closes and the process ends with it.
+///
public partial class MainWindow : Window
{
public MainWindow(
SettingsViewModel viewModel,
IThemeService theme,
- MainWindowPlacement placement,
- MainWindowPresenter presenter)
+ MainWindowPlacement placement)
{
InitializeComponent();
DataContext = viewModel;
theme.Register(this);
placement.Attach(this);
- presenter.Attach(this);
}
///
diff --git a/CursorLang.Settings/app.manifest b/CursorLang.Settings/app.manifest
new file mode 100644
index 0000000..b9d3d60
--- /dev/null
+++ b/CursorLang.Settings/app.manifest
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ PerMonitorV2
+ true/pm
+
+
+
diff --git a/CursorLang.Tests.Shared/CursorLang.Tests.Shared.csproj b/CursorLang.Tests.Shared/CursorLang.Tests.Shared.csproj
new file mode 100644
index 0000000..f3f18dc
--- /dev/null
+++ b/CursorLang.Tests.Shared/CursorLang.Tests.Shared.csproj
@@ -0,0 +1,25 @@
+
+
+
+ net10.0-windows10.0.19041.0
+ 10.0.17763.0
+ enable
+ enable
+ true
+ true
+ $(NoWarn);CS1591
+ false
+
+ CursorLang.Tests.Shared
+ CursorLang.Tests.Shared
+
+
+
+
+
+
+
+
+
+
+
diff --git a/CursorLang.Tests/Infrastructure/FakeHttp.cs b/CursorLang.Tests.Shared/FakeHttp.cs
similarity index 52%
rename from CursorLang.Tests/Infrastructure/FakeHttp.cs
rename to CursorLang.Tests.Shared/FakeHttp.cs
index 9e8cb96..0292e49 100644
--- a/CursorLang.Tests/Infrastructure/FakeHttp.cs
+++ b/CursorLang.Tests.Shared/FakeHttp.cs
@@ -1,35 +1,34 @@
using System.Net;
-using System.Net.Http;
using System.Text;
-namespace CursorLang.Tests.Infrastructure;
+namespace CursorLang.Tests.Shared;
///
/// The network as the test writes it: the answer is decided here rather than
/// by a repository somewhere.
///
-internal sealed class FakeHttpHandler : HttpMessageHandler
+public sealed class FakeHttpHandler : HttpMessageHandler
{
private readonly Func _reply;
- internal FakeHttpHandler(Func reply) => _reply = reply;
+ public FakeHttpHandler(Func reply) => _reply = reply;
- internal List Requests { get; } = [];
+ public List Requests { get; } = [];
- internal static FakeHttpHandler Json(string json) => new(_ => new HttpResponseMessage(HttpStatusCode.OK)
+ public static FakeHttpHandler Json(string json) => new(_ => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(json, Encoding.UTF8, "application/json"),
});
- internal static FakeHttpHandler Status(HttpStatusCode status) =>
+ public static FakeHttpHandler Status(HttpStatusCode status) =>
new(_ => new HttpResponseMessage(status));
- internal static FakeHttpHandler Bytes(byte[] content) => new(_ => new HttpResponseMessage(HttpStatusCode.OK)
+ public static FakeHttpHandler Bytes(byte[] content) => new(_ => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(content),
});
- internal HttpClient CreateClient() => new(this);
+ public HttpClient CreateClient() => new(this);
protected override Task SendAsync(
HttpRequestMessage request,
diff --git a/CursorLang.Tests/Infrastructure/Fakes.cs b/CursorLang.Tests.Shared/Fakes.cs
similarity index 56%
rename from CursorLang.Tests/Infrastructure/Fakes.cs
rename to CursorLang.Tests.Shared/Fakes.cs
index 5c0ab16..8544edd 100644
--- a/CursorLang.Tests/Infrastructure/Fakes.cs
+++ b/CursorLang.Tests.Shared/Fakes.cs
@@ -1,32 +1,21 @@
using System.ComponentModel;
-using System.Net.Http;
-using CursorLang.Models;
-using CursorLang.Services;
-using CursorLang.ViewModels;
+using CursorLang.Core.Models;
+using CursorLang.Core.Services;
-namespace CursorLang.Tests.Infrastructure;
-
-///
-/// Parts every test needs but few tests care about.
-///
-internal static class Fake
-{
- internal static UpdateViewModel Updates() =>
- new(new FakeUpdateService(), new FakeLocalizationService(), new AppSettings(), new UpdateOptions());
-}
+namespace CursorLang.Tests.Shared;
///
/// The keyboard layout, with the test in charge of it.
///
-internal sealed class FakeKeyboardLayoutService : IKeyboardLayoutService
+public sealed class FakeKeyboardLayoutService : IKeyboardLayoutService
{
- internal int StartCalls { get; private set; }
+ public int StartCalls { get; private set; }
- internal int StopCalls { get; private set; }
+ public int StopCalls { get; private set; }
- internal int SwitchCalls { get; private set; }
+ public int SwitchCalls { get; private set; }
- internal KeyboardLayout CurrentLayout { get; set; } = KeyboardLayout.FromLocaleId(0x0409);
+ public KeyboardLayout CurrentLayout { get; set; } = KeyboardLayout.FromLocaleId(0x0409);
public KeyboardLayout Current => CurrentLayout;
@@ -38,22 +27,22 @@ internal sealed class FakeKeyboardLayoutService : IKeyboardLayoutService
public void SwitchToNext() => SwitchCalls++;
- internal void RaiseLayoutChanged(KeyboardLayout layout, LayoutChangeReason reason) =>
+ public void RaiseLayoutChanged(KeyboardLayout layout, LayoutChangeReason reason) =>
LayoutChanged?.Invoke(this, new LayoutChangedEventArgs(layout, reason));
- internal bool HasSubscribers => LayoutChanged is not null;
+ public bool HasSubscribers => LayoutChanged is not null;
}
///
/// A tooltip that pops up nowhere and merely remembers what it was asked for.
///
-internal sealed class FakeLayoutPopupService : ILayoutPopupService
+public sealed class FakeLayoutPopupService : ILayoutPopupService
{
- internal List Shown { get; } = [];
+ public List Shown { get; } = [];
- internal List ShownUntilHidden { get; } = [];
+ public List ShownUntilHidden { get; } = [];
- internal int HideCalls { get; private set; }
+ public int HideCalls { get; private set; }
public void Show(KeyboardLayout layout) => Shown.Add(layout);
@@ -65,7 +54,7 @@ internal sealed class FakeLayoutPopupService : ILayoutPopupService
///
/// The Caps Lock interception without intercepting anything: the test supplies the presses.
///
-internal sealed class FakeCapsLockHotkeyService : ICapsLockHotkeyService
+public sealed class FakeCapsLockHotkeyService : ICapsLockHotkeyService
{
public event EventHandler? Tapped;
@@ -75,9 +64,9 @@ internal sealed class FakeCapsLockHotkeyService : ICapsLockHotkeyService
public bool IsRunning { get; private set; }
- internal int StartCalls { get; private set; }
+ public int StartCalls { get; private set; }
- internal int StopCalls { get; private set; }
+ public int StopCalls { get; private set; }
public void Start()
{
@@ -91,27 +80,27 @@ internal sealed class FakeCapsLockHotkeyService : ICapsLockHotkeyService
IsRunning = false;
}
- internal void RaiseTapped() => Tapped?.Invoke(this, EventArgs.Empty);
+ public void RaiseTapped() => Tapped?.Invoke(this, EventArgs.Empty);
- internal void RaiseHoldStarted() => HoldStarted?.Invoke(this, EventArgs.Empty);
+ public void RaiseHoldStarted() => HoldStarted?.Invoke(this, EventArgs.Empty);
- internal void RaiseHoldEnded() => HoldEnded?.Invoke(this, EventArgs.Empty);
+ public void RaiseHoldEnded() => HoldEnded?.Invoke(this, EventArgs.Empty);
- internal bool HasSubscribers => Tapped is not null || HoldStarted is not null || HoldEnded is not null;
+ public bool HasSubscribers => Tapped is not null || HoldStarted is not null || HoldEnded is not null;
}
///
/// Startup whose state the test assigns.
///
-internal sealed class FakeStartupService : IStartupService
+public sealed class FakeStartupService : IStartupService
{
- internal StartupState State { get; set; } = StartupState.Disabled;
+ public StartupState State { get; set; } = StartupState.Disabled;
- internal StartupState? AnswerOnEnable { get; set; }
+ public StartupState? AnswerOnEnable { get; set; }
- internal List Requests { get; } = [];
+ public List Requests { get; } = [];
- internal int GetStateCalls { get; private set; }
+ public int GetStateCalls { get; private set; }
public Task GetStateAsync()
{
@@ -134,19 +123,19 @@ internal sealed class FakeStartupService : IStartupService
///
/// Releases the test writes itself, with no repository behind them.
///
-internal sealed class FakeUpdateService : IUpdateService
+public sealed class FakeUpdateService : IUpdateService
{
- internal ReleaseInfo? Release { get; set; }
+ public ReleaseInfo? Release { get; set; }
- internal Exception? Failure { get; set; }
+ public Exception? Failure { get; set; }
- internal TaskCompletionSource? DownloadGate { get; set; }
+ public TaskCompletionSource? DownloadGate { get; set; }
- internal string PackagePath { get; set; } = string.Empty;
+ public string PackagePath { get; set; } = string.Empty;
- internal int CheckCalls { get; private set; }
+ public int CheckCalls { get; private set; }
- internal List Installed { get; } = [];
+ public List Installed { get; } = [];
public bool IsSupported { get; set; } = true;
@@ -186,13 +175,13 @@ internal sealed class FakeUpdateService : IUpdateService
///
/// A release list the test fills in, with no repository behind it.
///
-internal sealed class FakeReleaseFeed : IReleaseFeed
+public sealed class FakeReleaseFeed : IReleaseFeed
{
- internal ReleaseInfo? Release { get; set; }
+ public ReleaseInfo? Release { get; set; }
- internal Exception? Failure { get; set; }
+ public Exception? Failure { get; set; }
- internal List Authorized { get; } = [];
+ public List Authorized { get; } = [];
public Task GetLatestAsync(CancellationToken cancellationToken) =>
Failure is null ? Task.FromResult(Release) : Task.FromException(Failure);
@@ -203,13 +192,13 @@ internal sealed class FakeReleaseFeed : IReleaseFeed
///
/// Interface strings without resources: the key comes back as is, tagged with the language.
///
-internal sealed class FakeLocalizationService : ILocalizationService
+public sealed class FakeLocalizationService : ILocalizationService
{
private string _currentLanguage = "en";
public event PropertyChangedEventHandler? PropertyChanged;
- internal List RequestedKeys { get; } = [];
+ public List RequestedKeys { get; } = [];
public string this[string key]
{
@@ -239,39 +228,30 @@ internal sealed class FakeLocalizationService : ILocalizationService
_currentLanguage = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(CurrentLanguage)));
- PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(System.Windows.Data.Binding.IndexerName));
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(LocalizationService.IndexerName));
}
}
}
-///
-/// A theme that paints nothing and only remembers the windows attached to it.
-///
-internal sealed class FakeThemeService : IThemeService
-{
- public AppTheme CurrentTheme { get; set; } = AppTheme.Light;
-
- internal List Registered { get; } = [];
-
- public void Register(System.Windows.Window window) => Registered.Add(window);
-}
-
///
/// A tooltip window that shows nothing.
///
-internal sealed class FakeLayoutPopupWindow : ILayoutPopupWindow
+public sealed class FakeLayoutPopupWindow : ILayoutPopupWindow
{
- internal int ShowCalls { get; private set; }
+ public int ShowCalls { get; private set; }
- internal int HideCalls { get; private set; }
+ public int HideCalls { get; private set; }
- internal int CloseCalls { get; private set; }
+ public int CloseCalls { get; private set; }
- internal List Calls { get; } = [];
+ public List Calls { get; } = [];
- public void ShowPopup()
+ public string? ShownText { get; private set; }
+
+ public void ShowPopup(string shortName)
{
ShowCalls++;
+ ShownText = shortName;
Calls.Add("show");
}
diff --git a/CursorLang.Tests.Shared/Pump.cs b/CursorLang.Tests.Shared/Pump.cs
new file mode 100644
index 0000000..a4a6a13
--- /dev/null
+++ b/CursorLang.Tests.Shared/Pump.cs
@@ -0,0 +1,324 @@
+using System.Collections.Concurrent;
+using System.Runtime.InteropServices;
+
+namespace CursorLang.Tests.Shared;
+
+///
+/// A thread with a Win32 message loop on it — what the tests have instead of a
+/// dispatcher.
+///
+///
+/// Core's timers are SetTimer timers, and they only tick where messages are
+/// pumped. WPF used to provide that thread; Core must not depend on WPF, so the tests
+/// provide it themselves. The loop is a real one, so timers fire on their own and a
+/// test only has to await the consequences through .
+///
+/// One thread for the whole run: starting and stopping message loops between tests
+/// costs more than it proves, and the hook, the timers and the windows under test are
+/// happy to share.
+///
+/// Waiting is allowed from the pump thread itself, and that is the delicate part. A
+/// plain wait there would stop the queue and with it everything being waited for, so
+/// on that thread the waiting is done by a nested loop that keeps dispatching.
+///
+public static class Pump
+{
+ private static readonly Lock Gate = new();
+ private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(5);
+ private static readonly ConcurrentQueue Posted = new();
+
+ private static uint _threadId;
+
+ /// Runs an action on the pump thread and waits for it to finish.
+ public static void Run(Action action) => Run