lightweight variant (#1)

Reviewed-on: #1
Co-authored-by: Aleksandr Neychev <alexnejchev73@gmail.com>
This commit was merged in pull request #1.
This commit is contained in:
2026-08-12 13:37:30 +00:00
committed by alex
parent a0d3098fe4
commit 55ac8e6556
147 changed files with 4055 additions and 2349 deletions
+1 -1
View File
@@ -79,4 +79,4 @@ jobs:
--configuration Release
--no-build
--nologo
--settings CursorLang.Tests/coverage.runsettings
--settings coverage.runsettings
+1 -1
View File
@@ -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
@@ -0,0 +1,43 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0-windows10.0.19041.0</TargetFramework>
<SupportedOSPlatformVersion>10.0.17763.0</SupportedOSPlatformVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<OutputType>Exe</OutputType>
<PlatformTarget>AnyCPU</PlatformTarget>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>CursorLang.Agent.Tests</RootNamespace>
<AssemblyName>CursorLang.Agent.Tests</AssemblyName>
</PropertyGroup>
<ItemGroup>
<Using Include="Xunit"/>
</ItemGroup>
<ItemGroup>
<PackageReference Include="xunit.v3" Version="3.2.2"/>
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.8.1"/>
<PackageReference Include="coverlet.collector" Version="10.0.1"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\CursorLang.Core\CursorLang.Core.csproj"/>
<ProjectReference Include="..\CursorLang.Tests.Shared\CursorLang.Tests.Shared.csproj"/>
<ProjectReference Include="..\CursorLang.Agent\CursorLang.Agent.csproj"/>
</ItemGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Reflection.AssemblyMetadataAttribute">
<_Parameter1>CursorLangExecutable</_Parameter1>
<_Parameter2>$(MSBuildThisFileDirectory)..\CursorLang.Agent\bin\$(Configuration)\$(TargetFramework)\CursorLang.exe</_Parameter2>
</AssemblyAttribute>
</ItemGroup>
</Project>
@@ -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;
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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
/// <summary>How long "the application went on working" is worth watching for.</summary>
private static readonly TimeSpan StayTimeout = TimeSpan.FromSeconds(3);
/// <summary>
/// The whole point of the background process, as a test rather than as a promise.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[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<ProcessModule>()
.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();
/// <summary>
/// Started by the user, the application shows the settings window — which lives in
/// a process of its own and is started by the agent.
/// </summary>
[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
/// <summary>
/// Closing the settings window ends that process and leaves the agent alone.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[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);
}
/// <summary>A started application that shuts down together with the check.</summary>
@@ -102,7 +150,7 @@ public sealed class EndToEndTests
internal Process Process { get; }
/// <summary>Starts the application first — making sure the place is free.</summary>
/// <summary>Starts the agent first — making sure the place is free.</summary>
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));
}
/// <summary>Starts the application the way the user — or Windows — does.</summary>
/// <summary>Starts the agent the way the user — or Windows — does.</summary>
internal static Process StartProcess(params string[] arguments)
{
string path = ExecutablePath();
@@ -138,11 +186,14 @@ public sealed class EndToEndTests
return Process.Start(start)!;
}
/// <summary>The settings window processes running right now, if any.</summary>
internal static Process[] SettingsProcesses() => Process.GetProcessesByName("CursorLang.Settings");
/// <summary>
/// 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.
/// </summary>
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();
}
}
}
@@ -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;
/// <summary>
/// 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
});
}
/// <summary>
/// A hold is never announced for a key that has already been let go.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[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);
}
/// <summary>
/// The service together with its settings and the list of events that happened.
/// </summary>
@@ -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);
/// <summary>Delivers the hold countdown by hand, the way a late WM_TIMER does.</summary>
internal void ForceHoldTick() => Pump.Run(Service.HandleHoldElapsed);
public void Dispose() => Pump.Run(Service.Dispose);
}
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
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);
});
@@ -0,0 +1,57 @@
using CursorLang.Agent.Windows;
using CursorLang.Core.Services;
using CursorLang.Tests.Shared;
namespace CursorLang.Agent.Tests.Windows;
/// <summary>
/// The one thing the settings window says to the agent.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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();
}
}
+129
View File
@@ -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;
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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);
}
/// <summary>Starts everything and pumps messages until the user asks to quit.</summary>
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();
}
+54
View File
@@ -0,0 +1,54 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows10.0.19041.0</TargetFramework>
<SupportedOSPlatformVersion>10.0.17763.0</SupportedOSPlatformVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<RootNamespace>CursorLang.Agent</RootNamespace>
<AssemblyName>CursorLang</AssemblyName>
<ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>..\CursorLang.Core\Resources\CursorLang.ico</ApplicationIcon>
<PlatformTarget>AnyCPU</PlatformTarget>
<RuntimeIdentifiers>win-x64;win-arm64</RuntimeIdentifiers>
<SelfContained Condition="'$(RuntimeIdentifier)' != ''">true</SelfContained>
<PublishTrimmed>false</PublishTrimmed>
<PublishReadyToRun Condition="'$(RuntimeIdentifier)' == 'win-x64'">true</PublishReadyToRun>
<Version>1.0.0</Version>
<AssemblyVersion>1.0.0.0</AssemblyVersion>
<FileVersion>1.0.0.0</FileVersion>
<Product>CursorLang</Product>
<Company>Aleksandr Neychev</Company>
<Description>Shows the keyboard layout at the cursor</Description>
<Copyright>Copyright (c) 2026</Copyright>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\CursorLang.Core\CursorLang.Core.csproj"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\CursorLang.Settings\CursorLang.Settings.csproj"
ReferenceOutputAssembly="false"
Private="false"/>
</ItemGroup>
<Target Name="PlaceTheSettingsWindowBesideTheAgent" AfterTargets="Build">
<ItemGroup>
<SettingsOutput Include="..\CursorLang.Settings\bin\$(Configuration)\$(TargetFramework)\**\*"/>
</ItemGroup>
<Copy SourceFiles="@(SettingsOutput)"
DestinationFolder="$(OutDir)%(RecursiveDir)"
SkipUnchangedFiles="true"/>
</Target>
<ItemGroup>
<InternalsVisibleTo Include="CursorLang.Agent.Tests"/>
</ItemGroup>
</Project>
+161
View File
@@ -0,0 +1,161 @@
using System.Drawing;
using System.Runtime.InteropServices;
using CursorLang.Core.Interop;
namespace CursorLang.Agent.Interop;
/// <summary>
/// Plain GDI: a font, text, and an off-screen bitmap to draw them into.
/// </summary>
/// <remarks>
/// GDI rather than GDI+ on purpose. <c>System.Drawing.Common</c> 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.
/// </remarks>
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;
/// <summary>
/// The face the popup is written in.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
private const string SemiBoldFace = "Segoe UI Semibold";
private const int FW_DONTCARE = 0;
/// <summary>
/// A font of the given size in physical pixels.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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);
}
/// <summary>The size of a single line of text with the font selected into the context.</summary>
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);
}
/// <summary>A colour as GDI wants it: 0x00BBGGRR, the alpha carried elsewhere.</summary>
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);
/// <summary>
/// A 32-bit surface to draw the popup into before anyone can see it.
/// </summary>
/// <remarks>
/// Top-down — a negative height — so that the first row of <paramref name="bits"/>
/// is the top row of the picture and the alpha fixing up afterwards can walk the
/// memory straight through.
/// </remarks>
internal static IntPtr CreateSurface(IntPtr deviceContext, int width, int height, out IntPtr bits)
{
var header = new BitmapInfoHeader
{
biSize = Marshal.SizeOf<BitmapInfoHeader>(),
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;
}
}
+98
View File
@@ -0,0 +1,98 @@
using System.Runtime.InteropServices;
using CursorLang.Core.Interop;
namespace CursorLang.Agent.Interop;
/// <summary>
/// The system context menu — the one the tray icon raises.
/// </summary>
/// <remarks>
/// 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
/// <c>ContextMenu</c> costs the whole rendering stack in the background process.
/// </remarks>
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;
/// <summary>An item of the menu being built.</summary>
/// <param name="Id">What <see cref="Track"/> gives back when the item is chosen.</param>
/// <param name="Caption">The text, or <c>null</c> for a separator.</param>
/// <param name="IsEnabled">A greyed item is shown but cannot be chosen.</param>
internal readonly record struct Item(int Id, string? Caption, bool IsEnabled = true)
{
internal static Item Separator => new(0, null);
}
/// <summary>
/// Raises the menu at a screen point and returns the identifier of the chosen item,
/// or zero when the user dismissed it.
/// </summary>
/// <remarks>
/// <c>TPM_RETURNCMD</c> means the answer comes back from the call itself instead of
/// as a <c>WM_COMMAND</c> 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.
/// </remarks>
internal static int Track(IntPtr owner, PopupWindowNative.Point at, IReadOnlyList<Item> 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);
}
@@ -1,6 +1,7 @@
using System.Runtime.InteropServices;
using CursorLang.Core.Interop;
namespace CursorLang.Interop;
namespace CursorLang.Agent.Interop;
/// <summary>
/// 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;
}
/// <summary>Replaces the image and the tooltip of an icon already there.</summary>
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);
}
/// <summary>Takes the icon away. A forgotten icon stays in the tray until hovered.</summary>
internal static void Remove(IntPtr window, int id)
{
@@ -134,6 +127,16 @@ internal static class TrayIconNative
/// <summary>The notification the icon has sent: it sits in the low word of lParam.</summary>
internal static int NotificationOf(IntPtr lParam) => (int)(lParam.ToInt64() & 0xFFFF);
/// <summary>
/// The point of the click. Version 4 of the protocol reports it in screen pixels
/// in <c>wParam</c> — exactly what <c>TrackPopupMenuEx</c> expects.
/// </summary>
internal static PopupWindowNative.Point PointOf(IntPtr wParam) => new()
{
X = (short)(wParam.ToInt64() & 0xFFFF),
Y = (short)((wParam.ToInt64() >> 16) & 0xFFFF),
};
/// <summary>
/// Brings the window to the foreground.
/// </summary>
+180
View File
@@ -0,0 +1,180 @@
using System.Runtime.InteropServices;
using CursorLang.Core.Interop;
namespace CursorLang.Agent.Interop;
/// <summary>
/// The Win32 pieces a window needs when there is no framework to make one:
/// the class, the window itself, the message loop.
/// </summary>
internal static class WindowNative
{
/// <summary>The window procedure. Windows keeps the only reference to it.</summary>
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;
/// <summary>WM_APP and up belong to the application; the tray takes WM_APP + 1.</summary>
internal const uint WM_APP = 0x8000;
/// <summary>
/// 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.
/// </summary>
internal static void RegisterClass(string className, WindowProc windowProc)
{
var description = new WindowClass
{
cbSize = Marshal.SizeOf<WindowClass>(),
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;
/// <summary>Creates a window of a registered class. It is not shown.</summary>
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;
}
/// <summary>
/// Puts a finished picture into a layered window, together with where it goes and
/// how see-through it is.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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;
/// <summary>
/// BLENDFUNCTION. <c>BlendFlags</c> 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.
/// </summary>
[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;
}
}
+38
View File
@@ -0,0 +1,38 @@
using CursorLang.Core.Services;
namespace CursorLang.Agent;
internal static class Program
{
/// <summary>
/// A single-threaded apartment because of the caret: <c>AccessibleObjectFromWindow</c>
/// and UI Automation both go through COM, and both expect the thread that calls them
/// to be an STA one.
/// </summary>
[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();
}
}
}
@@ -0,0 +1,11 @@
{
"profiles": {
"Agent": {
"commandName": "Project"
},
"Agent (started by Windows)": {
"commandName": "Project",
"commandLineArgs": "--startup"
}
}
}
@@ -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;
/// <summary>
/// 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
/// <see cref="MessageTimer"/>, and the event reaches its subscribers through a message
/// posted to the agent's window rather than through the dispatcher.
/// </remarks>
public sealed class CapsLockHotkeyService : ICapsLockHotkeyService, IDisposable
{
private const int VirtualKeyCapsLock = 0x14;
private readonly AppSettings _settings;
private readonly Action<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)
/// <param name="settings">Where the hold threshold is read from, on every press.</param>
/// <param name="post">
/// 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.
/// </param>
public CapsLockHotkeyService(AppSettings settings, Action<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();
/// <summary>
/// The hold countdown has run out.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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));
}
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// The same service it always was, with <c>DispatcherTimer</c> swapped for
/// <see cref="MessageTimer"/>: both tick on the thread that owns the window, so
/// nothing else about the logic had to move.
/// </remarks>
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();
}
@@ -0,0 +1,43 @@
using System.ComponentModel;
using System.Diagnostics;
using CursorLang.Core.Services;
namespace CursorLang.Agent.Services;
/// <summary>
/// Starts the settings window.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
internal static class SettingsLauncher
{
/// <summary>
/// Opens the settings window. Returns <c>false</c> when the executable is not
/// where it should be — a half-copied installation, or the agent run from a build
/// folder of its own.
/// </summary>
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;
}
}
}
+85
View File
@@ -0,0 +1,85 @@
using System.Collections.Concurrent;
using CursorLang.Agent.Interop;
namespace CursorLang.Agent.Windows;
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// A window with no <c>WS_VISIBLE</c> 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.
/// </remarks>
internal sealed class AgentWindow : NativeWindow
{
/// <summary>
/// A message hook. Returning <c>true</c> means the message has been dealt with.
/// </summary>
internal delegate bool MessageFilter(uint message, IntPtr wParam, IntPtr lParam);
private const string ClassName = "CursorLang.Agent.Window";
/// <summary>Drain the queue of posted work. WM_APP is free for the application.</summary>
private const uint WM_INVOKE = WindowNative.WM_APP + 100;
private readonly List<MessageFilter> _filters = [];
private readonly ConcurrentQueue<Action> _posted = new();
internal AgentWindow()
: base(ClassName, "CursorLang agent", WindowNative.WS_POPUP, WindowNative.WS_EX_TOOLWINDOW)
{
}
internal void AddFilter(MessageFilter filter) => _filters.Add(filter);
/// <summary>
/// Runs the action on the message loop, after the current message is done with.
/// </summary>
/// <remarks>
/// This is what the agent has instead of <c>Dispatcher.BeginInvoke</c>. 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.
/// </remarks>
internal void Post(Action action)
{
_posted.Enqueue(action);
WindowNative.PostMessage(Handle, WM_INVOKE, IntPtr.Zero, IntPtr.Zero);
}
/// <summary>Asks the message loop to finish.</summary>
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;
}
}
@@ -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;
/// <summary>
/// The popup with the short name of the layout, drawn by Win32 alone.
/// </summary>
/// <remarks>
/// 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
/// <c>UpdateLayeredWindow</c>. Painting on demand instead — a <c>WM_PAINT</c> 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
/// <c>SetLayeredWindowAttributes</c>. 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 <see cref="LayoutPopupService"/>.
/// </remarks>
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;
}
/// <summary>
/// Shows the popup with the given text at the place set by the settings.
/// </summary>
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);
}
}
/// <summary>Destroys the window. The agent only does this on the way out.</summary>
public void Close() => Dispose();
public override void Dispose()
{
ReleaseFont();
base.Dispose();
}
/// <summary>
/// Draws the popup off screen and hands the finished picture to the window.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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);
}
}
/// <summary>
/// Cuts the four corners to a radius, fading the edge rather than stepping it.
/// </summary>
/// <remarks>
/// 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
/// <c>UpdateLayeredWindow</c> expects.
/// </remarks>
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;
}
}
}
+128
View File
@@ -0,0 +1,128 @@
using CursorLang.Agent.Interop;
using CursorLang.Core.Interop;
using CursorLang.Core.Services;
namespace CursorLang.Agent.Windows;
/// <summary>
/// The icon in the notification area: the way to the settings window and the only way
/// to quit the application.
/// </summary>
/// <remarks>
/// 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 <c>ContextMenu</c> obeyed the
/// theme and the language chosen in the settings, and a <c>TrackPopupMenuEx</c> 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.
/// </remarks>
internal sealed class NativeTrayIcon : IDisposable
{
/// <summary>Windows shows it under the pointer. The name of the app says enough.</summary>
private const string Tooltip = "CursorLang";
/// <summary>Distinguishes the icon among those of the same window; we have one.</summary>
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;
}
}
/// <summary>Raises the menu of the icon where the pointer is.</summary>
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;
}
}
}
+104
View File
@@ -0,0 +1,104 @@
using CursorLang.Agent.Interop;
namespace CursorLang.Agent.Windows;
/// <summary>
/// A window with no framework behind it: a registered class, a handle and a window
/// procedure that lands in <see cref="OnMessage"/>.
/// </summary>
/// <remarks>
/// 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
/// <c>CreateWindowExW</c> 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.
/// </remarks>
internal abstract class NativeWindow : IDisposable
{
private static readonly Dictionary<IntPtr, NativeWindow> Live = [];
private static readonly HashSet<string> 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;
}
/// <summary>The window handle. Zero once the window is gone.</summary>
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);
}
/// <summary>
/// A message for this window. Returning <c>false</c> passes it to
/// <c>DefWindowProcW</c>, 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.
/// </summary>
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);
}
}
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="CursorLang.app" />
<assemblyIdentity version="1.0.0.0" name="CursorLang.Agent.app" />
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
@@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0-windows10.0.19041.0</TargetFramework>
<SupportedOSPlatformVersion>10.0.17763.0</SupportedOSPlatformVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<OutputType>Exe</OutputType>
<PlatformTarget>AnyCPU</PlatformTarget>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>CursorLang.Core.Tests</RootNamespace>
<AssemblyName>CursorLang.Core.Tests</AssemblyName>
</PropertyGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="xunit.v3" Version="3.2.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
<PackageReference Include="coverlet.collector" Version="10.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\CursorLang.Core\CursorLang.Core.csproj" />
<ProjectReference Include="..\CursorLang.Tests.Shared\CursorLang.Tests.Shared.csproj" />
</ItemGroup>
</Project>
@@ -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;
/// <summary>
/// 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)
{
@@ -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;
/// <summary>
/// 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();
@@ -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),
@@ -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
{
@@ -1,6 +1,6 @@
using CursorLang.Models;
using CursorLang.Core.Models;
namespace CursorLang.Tests.Models;
namespace CursorLang.Core.Tests.Models;
public sealed class LayoutChangedEventArgsTests
{
@@ -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;
/// <summary>
/// Checks of the resources themselves: they carry every caption in the settings
/// window, and a missing key only shows on a live window.
/// </summary>
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
}
}
/// <summary>
/// Every key the settings window markup asks for has to exist in the
/// resources: otherwise the user sees the key itself in its place.
/// </summary>
[Fact]
public void Every_key_from_the_settings_window_markup_exists_in_the_resources()
{
HashSet<string> known = [.. NeutralKeys()];
List<string> missing = [];
foreach (Match match in LocalizationBinding().Matches(ReadSettingsWindowMarkup()))
{
string key = match.Groups["key"].Value;
if (!known.Contains(key))
{
missing.Add(key);
}
}
Assert.Empty(missing);
}
/// <summary>
/// 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);
}
/// <summary>The markup does ask for strings — otherwise the check above means nothing.</summary>
[Fact]
public void The_settings_window_markup_asks_for_resource_strings()
{
Assert.NotEmpty(LocalizationBinding().Matches(ReadSettingsWindowMarkup()));
}
public static TheoryData<string> EnumKeys()
{
var data = new TheoryData<string>();
@@ -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\[(?<key>\w+)\]")]
private static partial Regex LocalizationBinding();
}
@@ -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;
/// <summary>
/// What happens on Caps Lock presses and how the interception follows the setting.
@@ -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;
/// <summary>
/// Reading the release list of Gitea. The answer of the server is not ours to
@@ -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;
/// <summary>
/// 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);
}
}
}
@@ -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;
/// <summary>
/// The link between watching the layout and showing the tooltip.
@@ -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
{
@@ -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;
/// <summary>
/// The placement maths for the tooltip. This is the easiest place to get a sign
@@ -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;
/// <summary>
/// Startup of a build unpacked into a folder: a value under the Run key. The tests
@@ -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;
/// <summary>
/// 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.
/// </summary>
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");
});
}
/// <summary>
/// Asking to track changes before reading the file still tracks them.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[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());
});
}
/// <summary>
/// Re-reading pours the file into the instance everything is already bound to.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[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
@@ -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;
/// <summary>
/// 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<EventArgs> 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<EventArgs> 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<EventArgs> 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);
@@ -1,6 +1,7 @@
using CursorLang.Services;
using CursorLang.Core.Services;
using CursorLang.Tests.Shared;
namespace CursorLang.Tests.Services;
namespace CursorLang.Core.Tests.Services;
/// <summary>
/// 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);
}
/// <summary>
/// Windows must start the agent, whoever asked for it.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[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());
}
}
@@ -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;
/// <summary>
/// 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());
@@ -1,6 +1,6 @@
using CursorLang.Services;
using CursorLang.Core.Services;
namespace CursorLang.Tests.Services;
namespace CursorLang.Core.Tests.Services;
/// <summary>
/// Where the app looks for its releases. The values belong to the build, and a
@@ -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;
/// <summary>
/// What the app does with a release once it has found one: whether it is newer
@@ -0,0 +1,129 @@
using CursorLang.Core.Threading;
using CursorLang.Tests.Shared;
namespace CursorLang.Core.Tests.Threading;
/// <summary>
/// The timer the agent has instead of a dispatcher timer.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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);
});
}
}
+10
View File
@@ -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")]
+40
View File
@@ -0,0 +1,40 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0-windows10.0.19041.0</TargetFramework>
<SupportedOSPlatformVersion>10.0.17763.0</SupportedOSPlatformVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<RootNamespace>CursorLang.Core</RootNamespace>
<AssemblyName>CursorLang.Core</AssemblyName>
<PlatformTarget>AnyCPU</PlatformTarget>
<RuntimeIdentifiers>win-x64;win-arm64</RuntimeIdentifiers>
<Version>1.0.0</Version>
<AssemblyVersion>1.0.0.0</AssemblyVersion>
<FileVersion>1.0.0.0</FileVersion>
<Product>CursorLang</Product>
<Company>Aleksandr Neychev</Company>
<Description>Shared part of CursorLang: models, settings, layout tracking, updates</Description>
<Copyright>Copyright (c) 2026</Copyright>
</PropertyGroup>
<PropertyGroup>
<CaretUiAutomation Condition="'$(CaretUiAutomation)' == ''">true</CaretUiAutomation>
</PropertyGroup>
<PropertyGroup Condition="'$(CaretUiAutomation)' == 'true'">
<DefineConstants>$(DefineConstants);CARET_UI_AUTOMATION</DefineConstants>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.WindowsDesktop.App.WPF" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
</ItemGroup>
</Project>
@@ -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;
/// <summary>
/// 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 <c>CARET_UI_AUTOMATION</c>: 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.
/// </remarks>
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
/// <summary>How long we wait for another application to answer over UI Automation.</summary>
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)
@@ -1,6 +1,6 @@
using System.Runtime.InteropServices;
namespace CursorLang.Interop;
namespace CursorLang.Core.Interop;
/// <summary>
/// Input details of the active application: which window holds keyboard focus
@@ -1,6 +1,6 @@
using System.Runtime.InteropServices;
namespace CursorLang.Interop;
namespace CursorLang.Core.Interop;
/// <summary>
/// The right to bring a window to the foreground.
@@ -1,6 +1,6 @@
using System.Runtime.InteropServices;
namespace CursorLang.Interop;
namespace CursorLang.Core.Interop;
/// <summary>
/// Win32 API for reading the layout of the active application.
@@ -1,6 +1,6 @@
using System.Runtime.InteropServices;
namespace CursorLang.Interop;
namespace CursorLang.Core.Interop;
/// <summary>
/// A system keyboard hook (WH_KEYBOARD_LL): sees key presses in every application
@@ -1,6 +1,6 @@
using System.Runtime.InteropServices;
namespace CursorLang.Interop;
namespace CursorLang.Core.Interop;
/// <summary>
/// Answers whether the application runs from an MSIX package.
@@ -1,6 +1,6 @@
using System.Runtime.InteropServices;
namespace CursorLang.Interop;
namespace CursorLang.Core.Interop;
/// <summary>
/// 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;
}
/// <summary>
/// The popup shows up on top of other applications, so it must neither
/// activate itself nor steal input focus from the active window.
/// </summary>
internal static void MakePassive(IntPtr hWnd)
{
int exStyle = GetWindowLong(hWnd, GWL_EXSTYLE);
SetWindowLong(hWnd, GWL_EXSTYLE, exStyle | WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW);
}
/// <summary>
/// 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);
}
/// <summary>
/// Sets the window position and size in physical pixels.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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);
}
/// <summary>The scale of the monitor the point is on (1.0 at 96 DPI).</summary>
internal static double GetScaleAt(Point point) =>
GetScaleOf(MonitorFromPoint(point, MONITOR_DEFAULTTONEAREST));
@@ -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;
/// <summary>
/// The application settings. Every change applies on the fly: the popup and the
/// settings window are bound to these properties, and <c>SettingsService</c> saves
/// them to disk.
/// The application settings, as the settings window writes them to settings.json.
/// </summary>
/// <remarks>
/// The colours are <see cref="System.Drawing.Color"/> rather than
/// <c>System.Windows.Media.Color</c>. 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 <see cref="CopyFrom"/>.
/// </remarks>
public sealed partial class AppSettings : ObservableObject
{
/// <summary>The interface language as a culture code: "ru", "en".</summary>
@@ -94,11 +103,11 @@ public sealed partial class AppSettings : ObservableObject
/// <summary>The fill of the popup. The opacity is set by <see cref="Opacity"/>.</summary>
[ObservableProperty]
private Color _backgroundColor = Color.FromRgb(0x20, 0x20, 0x20);
private Color _backgroundColor = Color.FromArgb(0xFF, 0x20, 0x20, 0x20);
/// <summary>The colour of the layout name in the popup.</summary>
[ObservableProperty]
private Color _foregroundColor = Color.FromRgb(0xFF, 0xFF, 0xFF);
private Color _foregroundColor = Color.FromArgb(0xFF, 0xFF, 0xFF, 0xFF);
/// <summary><see cref="DurationMilliseconds"/> as a <see cref="TimeSpan"/>.</summary>
[JsonIgnore]
@@ -107,4 +116,35 @@ public sealed partial class AppSettings : ObservableObject
/// <summary><see cref="CapsLockHoldMilliseconds"/> as a <see cref="TimeSpan"/>.</summary>
[JsonIgnore]
public TimeSpan CapsLockHoldDelay => TimeSpan.FromMilliseconds(CapsLockHoldMilliseconds);
/// <summary>
/// Takes the values of another instance over, raising a change notification for
/// every property that has actually moved.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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;
}
}
@@ -1,4 +1,4 @@
namespace CursorLang.Models;
namespace CursorLang.Core.Models;
/// <summary>
/// The look of the settings window. By default the application follows the Windows
@@ -1,6 +1,6 @@
using System.Globalization;
namespace CursorLang.Models;
namespace CursorLang.Core.Models;
/// <summary>
/// A keyboard layout in a form convenient for display.
@@ -1,4 +1,4 @@
namespace CursorLang.Models;
namespace CursorLang.Core.Models;
/// <summary>
/// Why the current layout has changed.
@@ -1,4 +1,4 @@
namespace CursorLang.Models;
namespace CursorLang.Core.Models;
/// <summary>
/// How the place for the popup is chosen.
@@ -1,4 +1,4 @@
namespace CursorLang.Models;
namespace CursorLang.Core.Models;
/// <summary>
/// A file attached to a release.
@@ -1,4 +1,4 @@
namespace CursorLang.Models;
namespace CursorLang.Core.Models;
/// <summary>
/// The state of startup. Follows <c>StartupTaskState</c> of Windows: the user and
@@ -1,4 +1,4 @@
namespace CursorLang.Models;
namespace CursorLang.Core.Models;
/// <summary>
/// Which step the update is at. One value — one state of the interface:
@@ -215,7 +215,7 @@
<value>Check for updates</value>
</data>
<data name="UpdateAutoCheck" xml:space="preserve">
<value>Check for updates at startup</value>
<value>Check for updates</value>
</data>
<data name="UpdateChecking" xml:space="preserve">
<value>Checking for updates…</value>
@@ -215,7 +215,7 @@
<value>Проверить обновления</value>
</data>
<data name="UpdateAutoCheck" xml:space="preserve">
<value>Проверять обновления при запуске</value>
<value>Проверять обновления</value>
</data>
<data name="UpdateChecking" xml:space="preserve">
<value>Идёт проверка обновлений…</value>
@@ -0,0 +1,35 @@
namespace CursorLang.Core.Services;
/// <summary>
/// Where the background half of the application lives on disk.
/// </summary>
/// <remarks>
/// 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.
/// <c>Environment.ProcessPath</c> answers the wrong question for both, so the paths
/// are worked out from the folder the assemblies were loaded from.
/// </remarks>
internal static class AgentExecutable
{
/// <summary>The background process — the one Windows starts at sign-in.</summary>
internal const string AgentFileName = "CursorLang.exe";
/// <summary>The settings window, started on demand and gone when closed.</summary>
internal const string SettingsFileName = "CursorLang.Settings.exe";
/// <summary>
/// The full path of the agent, or <c>null</c> when it is not next to us — which
/// happens in the tests and would happen to a half-copied installation.
/// </summary>
internal static string? AgentPath => Beside(AgentFileName);
/// <summary>The full path of the settings window, on the same terms.</summary>
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;
}
}
@@ -1,7 +1,7 @@
using System.ComponentModel;
using CursorLang.Models;
using CursorLang.Core.Models;
namespace CursorLang.Services;
namespace CursorLang.Core.Services;
/// <summary>
/// Turns Caps Lock presses into actions: a short one switches the layout, a long one
@@ -0,0 +1,56 @@
using System.Drawing;
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace CursorLang.Core.Services;
/// <summary>
/// Reads and writes a colour as "#AARRGGBB".
/// </summary>
/// <remarks>
/// That is the form earlier versions wrote, when the colours were WPF ones and
/// <c>Color.ToString()</c> produced it, so files already on disk keep working. The
/// parsing is done here rather than by <c>ColorConverter</c> 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.
/// </remarks>
internal sealed class ColorJsonConverter : JsonConverter<Color>
{
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<char> 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}");
}
@@ -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;
/// <summary>
/// Gitea releases.
@@ -1,4 +1,4 @@
namespace CursorLang.Services;
namespace CursorLang.Core.Services;
/// <summary>
/// Intercepts Caps Lock at the system level and splits the presses into short and
@@ -1,6 +1,6 @@
using CursorLang.Models;
using CursorLang.Core.Models;
namespace CursorLang.Services;
namespace CursorLang.Core.Services;
/// <summary>
/// Tracks the layout of the active window — including in other applications —
@@ -1,6 +1,6 @@
using CursorLang.Models;
using CursorLang.Core.Models;
namespace CursorLang.Services;
namespace CursorLang.Core.Services;
/// <summary>
/// Shows the layout popup at the cursor.
@@ -1,4 +1,4 @@
namespace CursorLang.Services;
namespace CursorLang.Core.Services;
/// <summary>
/// The popup window as seen by whoever decides when it is shown.
@@ -10,8 +10,15 @@ namespace CursorLang.Services;
/// </remarks>
public interface ILayoutPopupWindow
{
/// <summary>Shows the window at the place set by the settings.</summary>
void ShowPopup();
/// <summary>
/// Shows the window with the given text at the place set by the settings.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
void ShowPopup(string shortName);
/// <summary>Takes the window off the screen without destroying it.</summary>
void Hide();
@@ -1,6 +1,6 @@
using System.ComponentModel;
namespace CursorLang.Services;
namespace CursorLang.Core.Services;
/// <summary>An interface language to choose from in the settings.</summary>
/// <param name="Code">The culture code: "ru", "en".</param>
@@ -1,7 +1,6 @@
using System.Net.Http;
using CursorLang.Models;
using CursorLang.Core.Models;
namespace CursorLang.Services;
namespace CursorLang.Core.Services;
/// <summary>
/// The release list of the repository.
@@ -1,6 +1,6 @@
using CursorLang.Models;
using CursorLang.Core.Models;
namespace CursorLang.Services;
namespace CursorLang.Core.Services;
/// <summary>
/// Starting the app together with Windows.
@@ -1,6 +1,6 @@
using CursorLang.Models;
using CursorLang.Core.Models;
namespace CursorLang.Services;
namespace CursorLang.Core.Services;
/// <summary>
/// Checking for and installing new versions of the application.
@@ -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;
/// <summary>
/// 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.
/// </remarks>
public sealed class KeyboardLayoutService : IKeyboardLayoutService, IDisposable
{
private readonly DispatcherTimer _pollTimer;
private readonly MessageTimer _pollTimer;
private readonly Func<IntPtr> _getForegroundWindow;
private readonly Func<int> _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();
@@ -1,6 +1,6 @@
using CursorLang.Models;
using CursorLang.Core.Models;
namespace CursorLang.Services;
namespace CursorLang.Core.Services;
/// <summary>
/// Ties layout tracking to showing the popup.
@@ -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;
/// <summary>
/// Takes the strings from the resources and, when the language changes, asks WPF to
/// re-read every binding.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public sealed class LocalizationService : ObservableObject, ILocalizationService
{
/// <summary>
/// The name WPF reports when an indexer changes. Spelt out rather than taken from
/// <c>Binding.IndexerName</c>: that constant lives in PresentationFramework, and
/// Core is read by the agent, which does not load WPF.
/// </summary>
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);
}
}
}
@@ -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;
/// <summary>
/// Computes the screen point to show the popup at.
@@ -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;
/// <summary>
/// Startup for a build that is not a package: a value under the Run key.
@@ -112,16 +111,20 @@ internal sealed class RegistryStartup
}
/// <summary>
/// What Windows is to run. <c>null</c> — the path of the running program is
/// unknown, and there is nothing to write down.
/// What Windows is to run. <c>null</c> — the agent is not where it should be, and
/// there is nothing to write down.
/// </summary>
/// <remarks>
/// The argument is how the application recognises a launch of this kind and goes
/// straight to the tray: see <see cref="StartupLaunch"/>. The user starting the
/// application themselves passes no such thing and gets the window.
/// The agent by name rather than <c>Environment.ProcessPath</c>: 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 <see cref="StartupLaunch"/>. The
/// user starting the application themselves passes no such thing and gets the window.
/// </remarks>
internal static string? GetCommand() =>
Environment.ProcessPath is { Length: > 0 } path
AgentExecutable.AgentPath is { Length: > 0 } path
? $"\"{path}\" {StartupLaunch.Argument}"
: null;
}
@@ -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;
/// <summary>
/// Keeps the settings in the settings.json file.
/// </summary>
/// <remarks>
/// 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 <see cref="TrackChanges"/> 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;
}
/// <summary>
/// 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.
/// </summary>
public AppSettings Load()
/// <remarks>
/// 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.
/// </remarks>
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;
}
/// <summary>
/// Starts saving every change, after a pause. For the settings window: it is the
/// only process allowed to write.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public void TrackChanges()
{
if (_isTrackingChanges)
{
return;
}
Load().PropertyChanged += OnSettingsChanged;
_isTrackingChanges = true;
}
/// <summary>
/// Re-reads the file. For the agent, when the settings window says it has written.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public void Reload()
{
if (_settings is not null && ReadFile(_filePath) is { } fresh)
{
_settings.CopyFrom(fresh);
}
}
/// <summary>
/// Writes the settings and tells the agent to pick them up.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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<Color>
{
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();
}
@@ -0,0 +1,52 @@
using System.Runtime.InteropServices;
namespace CursorLang.Core.Services;
/// <summary>
/// Tells the agent that settings.json has changed.
/// </summary>
/// <remarks>
/// 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 <c>WM_APP + n</c>: the identifier is unique across
/// the system, so it cannot be confused with anything else that finds its way to that
/// window.
/// </remarks>
internal static class SettingsSignal
{
/// <summary>The window class the agent registers for its hidden window.</summary>
internal const string AgentWindowClass = "CursorLang.Agent.Window";
/// <summary>The message both sides agree on.</summary>
internal static uint Message { get; } = RegisterWindowMessage("CursorLang.SettingsChanged");
/// <summary>
/// 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.
/// </summary>
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);
}
@@ -1,7 +1,6 @@
using System.Windows.Threading;
using CursorLang.Interop;
using CursorLang.Core.Interop;
namespace CursorLang.Services;
namespace CursorLang.Core.Services;
/// <summary>
/// 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.
/// </remarks>
public sealed class SingleInstanceGate : IDisposable
{
/// <summary>The agent's slot — one background process per session.</summary>
public const string AgentName = ".Agent";
/// <summary>The settings window's slot — one window per session.</summary>
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)
{
}
/// <summary>
/// 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.
/// </summary>
internal SingleInstanceGate(string nameSuffix)
public SingleInstanceGate(string nameSuffix)
{
_mutexName = MutexName + nameSuffix;
_activationEventName = ActivationEventName + nameSuffix;
}
/// <summary>Another launch asks for the window to be shown.</summary>
/// <summary>
/// Another launch asks for the window to be shown.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public event EventHandler? ActivationRequested;
/// <summary>
@@ -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);
}
@@ -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;
/// <summary>
/// Whether Windows started the application by itself.
@@ -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;
/// <summary>
/// Startup, arranged by whatever means the current build has.
@@ -1,4 +1,4 @@
namespace CursorLang.Services;
namespace CursorLang.Core.Services;
/// <summary>
/// Where the application learns about new versions from.
@@ -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;
/// <summary>
/// Learns about new versions from the repository and hands the downloaded package
+66
View File
@@ -0,0 +1,66 @@
using System.Runtime.InteropServices;
namespace CursorLang.Core.Threading;
/// <summary>
/// The Win32 message loop — what the application has instead of a dispatcher.
/// </summary>
/// <remarks>
/// It lives in Core rather than in the agent because the things that need a pumping
/// thread do: <see cref="MessageTimer"/> 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.
/// </remarks>
public static class MessageLoop
{
/// <summary>
/// Pumps messages until <c>WM_QUIT</c> and returns its exit code.
/// </summary>
/// <remarks>
/// A -1 from GetMessage means the window handle has already gone; going round
/// again would spin forever, so the loop gives up instead.
/// </remarks>
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);
}
}
/// <summary>Asks the loop on this thread to finish.</summary>
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;
}
}
+70
View File
@@ -0,0 +1,70 @@
using System.Runtime.InteropServices;
namespace CursorLang.Core.Threading;
/// <summary>
/// A timer that ticks on the message loop — the agent's stand-in for
/// <c>DispatcherTimer</c>.
/// </summary>
/// <remarks>
/// <c>SetTimer</c> with a null window binds the timer to the thread rather than to a
/// window, and <c>DispatchMessage</c> 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.
/// </remarks>
internal sealed class MessageTimer : IDisposable
{
/// <summary>Windows will not go below this, and pretending otherwise misleads.</summary>
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;
/// <summary>Starts the timer, or restarts it from zero when it is already running.</summary>
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);
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
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
/// <summary>
/// The background half is not in here, and must not be.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[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()
{
@@ -13,9 +13,14 @@
<NoWarn>$(NoWarn);CS1591</NoWarn>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<NoWarn>$(NoWarn);IDE0130</NoWarn>
<RootNamespace>CursorLang.Settings.Tests</RootNamespace>
<AssemblyName>CursorLang.Settings.Tests</AssemblyName>
</PropertyGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="xunit.v3" Version="3.2.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
@@ -24,22 +29,15 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\CursorLang\CursorLang.csproj" />
</ItemGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Reflection.AssemblyMetadataAttribute">
<_Parameter1>CursorLangExecutable</_Parameter1>
<_Parameter2>$(MSBuildThisFileDirectory)..\CursorLang\bin\$(Configuration)\$(TargetFramework)\CursorLang.exe</_Parameter2>
</AssemblyAttribute>
</ItemGroup>
<ItemGroup>
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
<ProjectReference Include="..\CursorLang.Core\CursorLang.Core.csproj" />
<ProjectReference Include="..\CursorLang.Tests.Shared\CursorLang.Tests.Shared.csproj" />
<ProjectReference Include="..\CursorLang.Settings\CursorLang.Settings.csproj" />
</ItemGroup>
<!-- The markup is embedded as plain text so that the check for missing resource
keys reads what the window really says, without walking the folder tree -->
<PropertyGroup>
<MainWindowMarkup>$(MSBuildThisFileDirectory)..\CursorLang\Views\MainWindow.xaml</MainWindowMarkup>
<MainWindowMarkup>$(MSBuildThisFileDirectory)..\CursorLang.Settings\Views\MainWindow.xaml</MainWindowMarkup>
</PropertyGroup>
<Target Name="EmbedMainWindowMarkup" BeforeTargets="PrepareForBuild">
@@ -52,8 +50,7 @@
SkipUnchangedFiles="true" />
<ItemGroup>
<EmbeddedResource Include="$(MainWindowMarkupCopy)"
LogicalName="MainWindow.xaml" />
<EmbeddedResource Include="$(MainWindowMarkupCopy)" LogicalName="MainWindow.xaml" />
<FileWrites Include="$(MainWindowMarkupCopy)" />
</ItemGroup>
</Target>
@@ -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;
/// <summary>
/// Parts every test needs but few tests care about.
/// </summary>
internal static class Fake
{
internal static UpdateViewModel Updates() =>
new(new FakeUpdateService(), new FakeLocalizationService(), new AppSettings(), new UpdateOptions());
}
/// <summary>
/// A theme that paints nothing and only remembers the windows attached to it.
/// </summary>
internal sealed class FakeThemeService : IThemeService
{
public AppTheme CurrentTheme { get; set; } = AppTheme.Light;
internal List<System.Windows.Window> Registered { get; } = [];
public void Register(System.Windows.Window window) => Registered.Add(window);
}
@@ -1,7 +1,7 @@
using System.Windows;
using System.Windows.Threading;
namespace CursorLang.Tests.Infrastructure;
namespace CursorLang.Settings.Tests.Infrastructure;
/// <summary>
/// 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),
});
@@ -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;
/// <summary>
/// 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");
});
}
@@ -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;
/// <summary>
/// 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()
{
@@ -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;
/// <summary>
/// Placing the settings window: the centre maths and bringing the window back
@@ -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;
/// <summary>
/// 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]
@@ -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
{
@@ -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;
/// <summary>
/// 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>(color));
Assert.All(viewModel.BackgroundPalette, color => Assert.IsType<System.Drawing.Color>(color));
}
[Fact]

Some files were not shown because too many files have changed in this diff Show More