lightweight variant
Pull request / build (pull_request) Successful in 53s

This commit is contained in:
2026-08-12 16:01:54 +05:00
parent a0d3098fe4
commit 6259dbd6b3
146 changed files with 3930 additions and 2275 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();
@@ -254,8 +254,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 +267,10 @@ 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);
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>
+137
View File
@@ -0,0 +1,137 @@
using System.Drawing;
using System.Runtime.InteropServices;
using CursorLang.Core.Interop;
namespace CursorLang.Agent.Interop;
/// <summary>
/// Plain GDI: a font, a brush, a rounded region and text on a device context.
/// </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);
[DllImport("gdi32.dll")]
internal static extern IntPtr CreateSolidBrush(uint color);
[DllImport("gdi32.dll")]
internal static extern IntPtr CreateRoundRectRgn(int x1, int y1, int x2, int y2, int w, int h);
[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")]
internal static extern int FillRect(IntPtr hdc, ref PopupWindowNative.Rect lprc, IntPtr hbr);
[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);
[DllImport("user32.dll")]
internal static extern IntPtr BeginPaint(IntPtr hWnd, out PaintStruct lpPaint);
[DllImport("user32.dll")]
internal static extern bool EndPaint(IntPtr hWnd, ref PaintStruct lpPaint);
[StructLayout(LayoutKind.Sequential)]
internal struct PaintStruct
{
public IntPtr hdc;
public bool fErase;
public PopupWindowNative.Rect rcPaint;
public bool fRestore;
public bool fIncUpdate;
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 32)]
public byte[] rgbReserved;
}
}
+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
@@ -134,6 +135,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>
+150
View File
@@ -0,0 +1,150 @@
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_DISABLED = 0x08000000;
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_PAINT = 0x000F;
internal const uint WM_CLOSE = 0x0010;
internal const uint WM_QUIT = 0x0012;
internal const uint WM_NULL = 0x0000;
internal const uint WM_DISPLAYCHANGE = 0x007E;
internal const uint WM_DPICHANGED = 0x02E0;
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;
private const uint LWA_ALPHA = 0x00000002;
/// <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>The whole opacity of a layered window, 0 to 255.</summary>
internal static void SetAlpha(IntPtr window, byte alpha) =>
SetLayeredWindowAttributes(window, 0, alpha, LWA_ALPHA);
[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")]
internal static extern bool InvalidateRect(IntPtr hWnd, IntPtr lpRect, bool bErase);
[DllImport("user32.dll")]
internal static extern bool UpdateWindow(IntPtr hWnd);
[DllImport("user32.dll")]
internal static extern bool SetWindowRgn(IntPtr hWnd, IntPtr hRgn, bool bRedraw);
[DllImport("user32.dll")]
internal static extern bool GetClientRect(IntPtr hWnd, out PopupWindowNative.Rect lpRect);
[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("user32.dll")]
private static extern bool SetLayeredWindowAttributes(IntPtr hWnd, uint crKey, byte bAlpha, uint dwFlags);
[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();
@@ -117,7 +128,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,321 @@
using System.Drawing;
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. Measured against it side by side, the two agreed to the pixel in
/// position and width.
///
/// The opacity comes from <c>SetLayeredWindowAttributes</c> and the rounded corners from
/// a window region, which is the cheaper of the two ways of doing it: the text keeps
/// ClearType, and no GDI+ or Direct2D is needed. The price is that a region is a binary
/// mask — the corners are cut without antialiasing. At radius 4 that is a stepped arc of
/// some fourteen pixels per corner, visible at eight times magnification and not at
/// natural size, which is why this way was kept.
///
/// 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 string _text = string.Empty;
private IntPtr _font;
private IntPtr _background;
private double _fontSize;
private double _fontScale;
private uint _backgroundColorRef;
private byte? _alpha;
private Size _regionSize;
private int _regionRadius = -1;
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;
}
_text = text;
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();
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);
PopupWindowNative.SetBounds(Handle, position.X, position.Y, width, height);
ApplyRegion(width, height, PopupLayout.ToPixels(CornerRadius, scale));
ApplyOpacity();
if (!WindowNative.IsWindowVisible(Handle))
{
WindowNative.ShowWindow(Handle, WindowNative.SW_SHOWNOACTIVATE);
}
// The repaint is forced rather than left to the queue: a layout switch is
// followed at once by the user looking at the popup, and a WM_PAINT waiting its
// turn behind a slow message is exactly how stale text gets on screen
WindowNative.InvalidateRect(Handle, IntPtr.Zero, false);
WindowNative.UpdateWindow(Handle);
}
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();
if (_background != IntPtr.Zero)
{
GdiNative.DeleteObject(_background);
_background = IntPtr.Zero;
}
base.Dispose();
}
protected override bool OnMessage(uint message, IntPtr wParam, IntPtr lParam, out IntPtr result)
{
result = IntPtr.Zero;
if (message != WindowNative.WM_PAINT)
{
return false;
}
Paint();
return true;
}
// 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 void Paint()
{
IntPtr deviceContext = GdiNative.BeginPaint(Handle, out GdiNative.PaintStruct paint);
if (deviceContext == IntPtr.Zero)
{
return;
}
try
{
WindowNative.GetClientRect(Handle, out PopupWindowNative.Rect client);
GdiNative.FillRect(deviceContext, ref client, EnsureBackground());
if (_font == IntPtr.Zero || _text.Length == 0)
{
return;
}
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 client,
GdiNative.DT_SINGLELINE | GdiNative.DT_CENTER | GdiNative.DT_VCENTER |
GdiNative.DT_NOPREFIX | GdiNative.DT_NOCLIP);
GdiNative.SelectObject(deviceContext, previousFont);
}
finally
{
GdiNative.EndPaint(Handle, ref paint);
}
}
private Size MeasureText()
{
IntPtr deviceContext = GdiNative.GetDC(Handle);
if (deviceContext == IntPtr.Zero)
{
return Size.Empty;
}
try
{
IntPtr previousFont = GdiNative.SelectObject(deviceContext, _font);
Size size = GdiNative.MeasureText(deviceContext, _text);
GdiNative.SelectObject(deviceContext, previousFont);
return size;
}
finally
{
GdiNative.ReleaseDC(Handle, deviceContext);
}
}
// The font is rebuilt only when the size in the settings or the monitor changes:
// creating one costs a trip to the font mapper, and the popup is shown often
private void EnsureFont(double scale)
{
if (_font != IntPtr.Zero &&
Math.Abs(_fontSize - _settings.FontSize) < 0.001 &&
Math.Abs(_fontScale - scale) < 0.001)
{
return;
}
ReleaseFont();
_fontSize = _settings.FontSize;
_fontScale = scale;
_font = GdiNative.CreateFont(_fontSize, scale);
}
private void ReleaseFont()
{
if (_font == IntPtr.Zero)
{
return;
}
GdiNative.DeleteObject(_font);
_font = IntPtr.Zero;
}
private IntPtr EnsureBackground()
{
uint colorRef = GdiNative.ToColorRef(_settings.BackgroundColor);
if (_background != IntPtr.Zero && _backgroundColorRef == colorRef)
{
return _background;
}
if (_background != IntPtr.Zero)
{
GdiNative.DeleteObject(_background);
}
_backgroundColorRef = colorRef;
_background = GdiNative.CreateSolidBrush(colorRef);
return _background;
}
// A region is in window coordinates, so a resize invalidates it. Reapplying it
// every show would do no harm, but the system frees the old region each time and
// the popup is shown far more often than it changes size
private void ApplyRegion(int width, int height, int radius)
{
if (_regionSize.Width == width && _regionSize.Height == height && _regionRadius == radius)
{
return;
}
// The right and bottom edges of CreateRoundRectRgn are exclusive, and the
// ellipse the corners are cut with is twice the radius across
IntPtr region = GdiNative.CreateRoundRectRgn(0, 0, width + 1, height + 1, radius * 2, radius * 2);
if (region == IntPtr.Zero)
{
return;
}
if (!WindowNative.SetWindowRgn(Handle, region, false))
{
GdiNative.DeleteObject(region);
return;
}
_regionSize = new Size(width, height);
_regionRadius = radius;
}
private void ApplyOpacity()
{
var alpha = (byte)Math.Clamp(Math.Round(_settings.Opacity * 255), 0, 255);
if (alpha == _alpha)
{
return;
}
_alpha = alpha;
WindowNative.SetAlpha(Handle, alpha);
}
}
+131
View File
@@ -0,0 +1,131 @@
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;
/// <summary>Whether the shell accepted the icon. Reported by the diagnostics.</summary>
internal bool IsInstalled => _isInstalled;
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;
}
}
}
+99
View File
@@ -0,0 +1,99 @@
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.
/// </summary>
protected abstract bool OnMessage(uint message, IntPtr wParam, IntPtr lParam, out IntPtr result);
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
@@ -17,6 +17,7 @@ public sealed class KeyboardLayoutServiceTests
private const int English = 0x0409;
private const int Russian = 0x0419;
private const int German = 0x0407;
[Fact]
public void The_current_layout_is_taken_from_the_foreground_window()
@@ -48,9 +49,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.LocaleId = Russian;
Sta.Run(service.Poll);
Pump.Run(service.Poll);
Pump.Run(service.Poll);
LayoutChangedEventArgs change = Assert.Single(world.Changes);
Assert.Equal(LayoutChangeReason.UserSwitched, change.Reason);
@@ -63,11 +65,12 @@ 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);
Pump.Run(service.Poll);
LayoutChangedEventArgs change = Assert.Single(world.Changes);
Assert.Equal(LayoutChangeReason.ApplicationSwitched, change.Reason);
@@ -80,10 +83,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 +97,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,33 +114,114 @@ 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);
Pump.Run(service.Poll);
Assert.Single(world.Changes);
}
/// <summary>
/// A value the switch is only passing through never reaches the subscribers.
/// </summary>
/// <remarks>
/// A layout change is rarely a single step: the Windows language switcher takes the
/// focus while it is up, and applications with a rendering engine of their own move
/// a helper thread first. Reporting what polling caught in between meant the popup
/// coming up with one layout and turning into another in front of the user.
/// </remarks>
[Fact]
public void A_layout_the_switch_only_passes_through_is_never_reported()
{
using var world = new World { LocaleId = English };
KeyboardLayoutService service = world.CreateService();
Pump.Run(service.Start);
world.LocaleId = German;
Pump.Run(service.Poll);
Assert.Empty(world.Changes);
world.LocaleId = Russian;
Pump.Run(service.Poll);
Pump.Run(service.Poll);
LayoutChangedEventArgs change = Assert.Single(world.Changes);
Assert.Equal("RU", change.Layout.ShortName);
}
// A switch that goes out and comes back is not a switch at all
[Fact]
public void A_layout_that_returns_to_where_it_was_yields_no_events()
{
using var world = new World { LocaleId = English };
KeyboardLayoutService service = world.CreateService();
Pump.Run(service.Start);
world.LocaleId = Russian;
Pump.Run(service.Poll);
world.LocaleId = English;
Pump.Run(service.Poll);
Pump.Run(service.Poll);
Assert.Empty(world.Changes);
}
/// <summary>
/// A window the switch passes through does not make it an application switch.
/// </summary>
/// <remarks>
/// The Windows language switcher is a window of its own and holds the foreground
/// while it is up. Judging by the window seen on the previous tick would call that
/// an application switch and swallow the popup, so the judgement is made against
/// the window that was there before the whole transition started.
/// </remarks>
[Fact]
public void A_window_the_switch_passes_through_is_not_an_application_switch()
{
using var world = new World { LocaleId = English };
KeyboardLayoutService service = world.CreateService();
Pump.Run(service.Start);
world.ForegroundWindow = SecondWindow;
world.LocaleId = German;
Pump.Run(service.Poll);
world.ForegroundWindow = FirstWindow;
world.LocaleId = Russian;
Pump.Run(service.Poll);
Pump.Run(service.Poll);
LayoutChangedEventArgs change = Assert.Single(world.Changes);
Assert.Equal(LayoutChangeReason.UserSwitched, change.Reason);
Assert.Equal("RU", change.Layout.ShortName);
}
[Fact]
public void One_change_yields_exactly_one_event()
{
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);
Pump.Run(service.Poll);
Assert.Single(world.Changes);
}
@@ -149,8 +233,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 +246,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 +258,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 +273,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 +288,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 +344,14 @@ 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
TimeSpan interval = pollInterval ?? TimeSpan.FromHours(1);
var options = new KeyboardLayoutOptions
{
PollInterval = pollInterval ?? TimeSpan.FromHours(1),
PollInterval = interval,
SettleInterval = interval,
};
_service = Sta.Run(() => new KeyboardLayoutService(
_service = Pump.Run(() => new KeyboardLayoutService(
options,
() => ForegroundWindow,
() => LocaleId,
@@ -281,7 +366,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
+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
@@ -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.
@@ -11,6 +11,16 @@ public sealed class KeyboardLayoutOptions
{
/// <summary>How often to check the layout of the active window.</summary>
public TimeSpan PollInterval { get; init; } = TimeSpan.FromMilliseconds(150);
/// <summary>
/// How long to wait for a layout that has just changed to stop changing.
/// </summary>
/// <remarks>
/// Short on purpose: it is added to the delay before the popup shows, and it only
/// applies while a switch is in flight. See the settling in
/// <see cref="KeyboardLayoutService"/>.
/// </remarks>
public TimeSpan SettleInterval { get; init; } = TimeSpan.FromMilliseconds(40);
}
/// <summary>
@@ -23,16 +33,33 @@ 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.
///
/// A switch is not reported the moment it is first seen but once the value has stopped
/// moving. A layout change is rarely a single step: the language switcher of Windows
/// takes the focus while it is up, and applications with a rendering engine of their
/// own change the layout of a helper thread before that of the input window. Polling
/// catches those in-between values, and reporting them meant the popup appearing with
/// one layout and turning into another in front of the user.
///
/// The price is that the popup comes up one short tick later. While a switch is in
/// flight the timer runs at <see cref="KeyboardLayoutOptions.SettleInterval"/> rather
/// than at the polling interval, so that tick is a few tens of milliseconds and not
/// another whole poll.
/// </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;
private readonly KeyboardLayoutOptions _options;
private int _lastLocaleId = -1;
private IntPtr _lastForegroundWindow;
private int _settlingLocaleId = -1;
public KeyboardLayoutService(KeyboardLayoutOptions options)
: this(
@@ -56,8 +83,9 @@ public sealed class KeyboardLayoutService : IKeyboardLayoutService, IDisposable
_getForegroundWindow = getForegroundWindow;
_getActiveLocaleId = getActiveLocaleId;
_requestNextLayout = requestNextLayout;
_options = options;
_pollTimer = new DispatcherTimer { Interval = options.PollInterval };
_pollTimer = new MessageTimer { Interval = options.PollInterval };
_pollTimer.Tick += OnTick;
}
@@ -69,6 +97,8 @@ public sealed class KeyboardLayoutService : IKeyboardLayoutService, IDisposable
{
_lastForegroundWindow = _getForegroundWindow();
_lastLocaleId = _getActiveLocaleId();
_settlingLocaleId = -1;
_pollTimer.Interval = _options.PollInterval;
_pollTimer.Start();
}
@@ -78,8 +108,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();
@@ -95,16 +125,28 @@ public sealed class KeyboardLayoutService : IKeyboardLayoutService, IDisposable
return;
}
bool appSwitched = foreground != _lastForegroundWindow;
_lastForegroundWindow = foreground;
int localeId = _getActiveLocaleId();
if (localeId == _lastLocaleId)
{
_lastForegroundWindow = foreground;
Settle(inFlight: false);
return;
}
if (localeId != _settlingLocaleId)
{
_settlingLocaleId = localeId;
Settle(inFlight: true);
return;
}
bool appSwitched = foreground != _lastForegroundWindow;
_lastLocaleId = localeId;
_lastForegroundWindow = foreground;
Settle(inFlight: false);
// Moving to another application with a layout of its own is not the same as
// the user switching the layout, and the subscribers are free to react to
@@ -115,4 +157,23 @@ public sealed class KeyboardLayoutService : IKeyboardLayoutService, IDisposable
LayoutChanged?.Invoke(this, new LayoutChangedEventArgs(KeyboardLayout.FromLocaleId(localeId), reason));
}
// While a switch is in flight the next look comes sooner: that wait is added to the
// delay before the popup, and a whole polling interval there would be felt
private void Settle(bool inFlight)
{
if (!inFlight)
{
_settlingLocaleId = -1;
}
TimeSpan wanted = inFlight ? _options.SettleInterval : _options.PollInterval;
if (_pollTimer.Interval == wanted || !_pollTimer.IsRunning)
{
return;
}
_pollTimer.Interval = wanted;
_pollTimer.Start();
}
}
@@ -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,24 @@ public sealed class SettingsService : IDisposable
_filePath = filePath;
_inheritedFilePath = inheritedFilePath;
_saveTimer = new DispatcherTimer { Interval = saveDelay };
_saveTimer = new MessageTimer { Interval = saveDelay };
_saveTimer.Tick += OnSaveTimerTick;
}
/// <summary>The file being read. The agent's diagnostics report it.</summary>
public string FilePath => _filePath;
/// <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 +99,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 +111,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 a reader never meets a half-written file —
/// and there is a reader, in another process, watching this very file.
/// </remarks>
public void Save()
{
if (_settings is null)
@@ -105,25 +164,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 +248,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. All this saves
/// is the wait — the agent watches the file anyway and would notice on its own, just
/// later and less predictably.
///
/// 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
+94
View File
@@ -0,0 +1,94 @@
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);
/// <summary>
/// Runs everything already waiting in the queue and returns.
/// </summary>
/// <remarks>
/// For the tests and for the rare place that has to let a posted message through
/// without giving up control for good.
/// </remarks>
public static void DrainQueue()
{
while (PeekMessage(out Message message, IntPtr.Zero, 0, 0, PM_REMOVE))
{
if (message.message == WM_QUIT)
{
PostQuitMessage((int)message.wParam);
return;
}
TranslateMessage(ref message);
DispatchMessage(ref message);
}
}
private const uint WM_QUIT = 0x0012;
private const uint PM_REMOVE = 0x0001;
[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", CharSet = CharSet.Unicode, EntryPoint = "PeekMessageW")]
private static extern bool PeekMessage(
out Message lpMsg, IntPtr hWnd, uint filterMin, uint filterMax, uint removeMsg);
[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
@@ -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]
@@ -1,11 +1,11 @@
using System.IO;
using System.Net.Http;
using CursorLang.Models;
using CursorLang.Services;
using CursorLang.Tests.Infrastructure;
using CursorLang.ViewModels;
using CursorLang.Core.Models;
using CursorLang.Core.Services;
using CursorLang.Settings.ViewModels;
using CursorLang.Tests.Shared;
namespace CursorLang.Tests.ViewModels;
namespace CursorLang.Settings.Tests.ViewModels;
/// <summary>
/// The updates section of the settings window: what it shows at every step and

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