25 Commits
Author SHA1 Message Date
alex 246751a4fd Merge pull request 'Fix certification comment' (#15) from fix-certification-comment into master
Release / release (push) Failing after 7m3s
Reviewed-on: #15
2026-09-03 05:00:16 +00:00
alex 53720e95db fixed width resize when change screen scale
Pull request / build (pull_request) Successful in 37s
2026-09-03 09:55:29 +05:00
alex ccd914d6fe fix resize on different screens try -1 2026-09-03 09:40:58 +05:00
alex cd63644a83 added material for certification 2026-09-03 08:18:05 +05:00
alex 1bad7785d0 add Store listing images: 9:16 poster and 300x300 logo
Partner Center's screenshots and logos steps needed sizes the MSIX package
doesn't produce, so they are drawn separately with the same mark.
2026-09-01 11:01:45 +05:00
alex 0ba581946f add site address to og:url, og:image and canonical link
The site is now live at cursor-lang.alrakis.kz, so these tags can be filled in.
2026-09-01 10:19:07 +05:00
alex 9b4ac00da9 Merge pull request 'add landing site for the Microsoft Store submission' (#14) from landing-site into master
Reviewed-on: #14
2026-09-01 04:46:20 +00:00
alex 936e53d55b add landing site for the Microsoft Store submission
Pull request / build (pull_request) Successful in 47s
A bilingual (EN/RU) static page in site/, split into index.html, styles.css,
boot.js and main.js, with a favicon.svg. Documents placement modes, per-mode
look, the Caps Lock shortcut and a privacy policy — the last is meant to
double as the Partner Center privacy policy URL.

STORE_URL is still a placeholder in both download buttons until the Store
listing exists.
2026-09-01 09:36:37 +05:00
alex 42e93346ab Merge pull request 'fixed a bug with displaying the application version in the window title' (#13) from fix-app-version into master
Release / release (push) Successful in 3m32s
Reviewed-on: #13
2026-08-31 06:52:05 +00:00
alex 9ab26afc16 fixed a bug with displaying the application version in the window title
Pull request / build (pull_request) Successful in 29s
2026-08-31 11:51:13 +05:00
alex 743974a0c1 added comments to readme (#12)
Release / release (push) Successful in 3m47s
Reviewed-on: #12
Co-authored-by: Aleksandr Neychev <alexnejchev73@gmail.com>
2026-08-15 02:56:58 +00:00
alex 9c6ec489e1 modified caret mode (#11)
Reviewed-on: #11
Co-authored-by: Aleksandr Neychev <alexnejchev73@gmail.com>
2026-08-14 23:07:20 +00:00
alex b2c7b31eb5 take the artifact action from a fork of our own (#10)
Release / release (push) Successful in 4m31s
Upstream v4 refuses to run anywhere but github.com: it reads
GITHUB_SERVER_URL, calls everything else a GitHub Enterprise Server and
throws before making a request. A probe run settled the rest — the name
cannot be overridden from a step, because the runner sets the GITHUB_
ones last. So the action now comes from this Gitea, forked with that one
check removed, on a fixed tag rather than a floating one.

Reviewed-on: #10
Co-authored-by: Aleksandr Neychev <alexnejchev73@gmail.com>
2026-08-14 00:26:27 +00:00
alex 5670ad0e64 fixed isGhes func in release pipeline (#9)
Release / release (push) Failing after 4m12s
Reviewed-on: #9
Co-authored-by: Aleksandr Neychev <alexnejchev73@gmail.com>
2026-08-13 23:34:38 +00:00
alex 1540be3b79 fix wix installer bug (#8)
Release / release (push) Failing after 4m3s
Reviewed-on: #8
Co-authored-by: Aleksandr Neychev <alexnejchev73@gmail.com>
2026-08-13 22:02:29 +00:00
alex 88dd931cb4 added sending closing event message to settings windows (#7)
Release / release (push) Successful in 5m7s
Reviewed-on: #7
Co-authored-by: Aleksandr Neychev <alexnejchev73@gmail.com>
2026-08-13 21:20:03 +00:00
alex 70b83a5fec reflow to vertical (#6)
Reviewed-on: #6
Co-authored-by: Aleksandr Neychev <alexnejchev73@gmail.com>
2026-08-13 21:13:28 +00:00
alex 0682f2b875 fixed warning 2026-08-14 02:07:56 +05:00
alex 5dea510bbc fixed msi pipeline
Release / release (push) Successful in 5m10s
2026-08-13 17:13:51 +05:00
alex 03a3dd4c6e Add package sign to pipeline (#5)
Release / release (push) Failing after 4m39s
leftovers

Reviewed-on: #5
Co-authored-by: Aleksandr Neychev <alexnejchev73@gmail.com>
2026-08-13 11:55:20 +00:00
alex 42974ecc8f modified release pipeline (#4)
Release / release (push) Canceled after 47s
await certificate from ssl.com

Reviewed-on: #4
Co-authored-by: Aleksandr Neychev <alexnejchev73@gmail.com>
2026-08-13 11:17:22 +00:00
alex 5e11b16758 changed settings saving (#3)
Reviewed-on: #3
Co-authored-by: Aleksandr Neychev <alexnejchev73@gmail.com>
2026-08-12 17:15:12 +00:00
alex 68b3d544d2 modified layout (#2)
Reviewed-on: #2
Co-authored-by: Aleksandr Neychev <alexnejchev73@gmail.com>
2026-08-12 14:25:05 +00:00
alex 55ac8e6556 lightweight variant (#1)
Reviewed-on: #1
Co-authored-by: Aleksandr Neychev <alexnejchev73@gmail.com>
2026-08-12 13:37:30 +00:00
alex a0d3098fe4 added tray menu 2026-08-12 04:59:22 +05:00
180 changed files with 9819 additions and 4726 deletions
+1 -1
View File
@@ -79,4 +79,4 @@ jobs:
--configuration Release
--no-build
--nologo
--settings CursorLang.Tests/coverage.runsettings
--settings coverage.runsettings
+51 -10
View File
@@ -2,6 +2,13 @@
# packs the MSIX with the version taken from the tag — three numbers of the tag
# and a zero the Store keeps for itself.
#
# The package goes to the Store and nowhere else, so it leaves the run as an
# artifact: someone picks it up and uploads it to Partner Center, which puts its
# own signature on it. Nothing is signed here and nothing is attached to the
# release — a publicly trusted code signing certificate is not to be had, and an
# unsigned package would look like something to install and install nowhere.
# Gitea makes the release for the tag itself, and it carries the tag alone.
#
# The same requirements to the runner as in pull-request.yml apply: Windows, the
# .NET 10 SDK and an interactive desktop session for the tests. makeappx comes
# with a NuGet package (Packaging\Tools\SdkTools.csproj), so the Windows SDK does
@@ -77,6 +84,10 @@ jobs:
# reserves the last one, so it carries nothing the tag could tell
"version=$($tag.Substring(1)).0" | Out-File $env:GITHUB_OUTPUT -Append -Encoding utf8
# The installer answers to nobody about a fourth number and takes the
# tag as it is
"plain=$($tag.Substring(1))" | Out-File $env:GITHUB_OUTPUT -Append -Encoding utf8
- name: Show the toolchain
run: dotnet --info
@@ -92,7 +103,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
@@ -121,15 +132,45 @@ jobs:
./Packaging/build-msix.ps1 @arguments
- name: Keep the packages
uses: actions/upload-artifact@v4
# The other half of the release: the same application as an ordinary
# installer, for handing round outside the Store. Nobody signs it, so
# SmartScreen warns about it — see Packaging\installer.iss
- name: Build the installer
run: ./Packaging/build-installer.ps1 -Version ${{ steps.version.outputs.plain }}
# The artifact is where the package waits to be uploaded to Partner Center.
#
# The action comes from this Gitea rather than from github.com, and it is
# a fork of actions/upload-artifact with one line changed. Upstream reads
# GITHUB_SERVER_URL, takes every host but github.com and its enterprise
# ones for a GitHub Enterprise Server — where the v4 artifact backend does
# not exist — and throws before it makes a request. Here that backend does
# exist and answers, but the check runs on the runner ahead of the network
# and never lets it prove itself; nor can the name be corrected from this
# file, as the runner sets the GITHUB_ ones over the env of a step. The
# fork carries the reasoning in full in its GITEA-PATCH.md.
#
# The tag is a fixed one. A floating tag is how this broke before: the
# action is fetched at run time, and what arrives can change on its own
- name: Keep the package
uses: https://git.alrakis.kz/actions/upload-artifact@v4-gitea
with:
name: msix-${{ steps.version.outputs.version }}
path: artifacts/packages/
if-no-files-found: error
# Gitea creates a release of its own for a pushed tag, so the release is
# looked up first and only made when it is not there
# The .wixpdb next to each installer is left out on purpose: it is of use
# only when something has to be traced back to the WiX source
- name: Keep the installer
uses: https://git.alrakis.kz/actions/upload-artifact@v4-gitea
with:
name: installer-${{ steps.version.outputs.plain }}
path: artifacts/installers/*.msi
if-no-files-found: error
# Only the installers go into the release. The MSIX stays in the artifacts
# of the run: unsigned, it installs nowhere, and its one destination is
# Partner Center
- name: Publish the release
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
@@ -139,12 +180,12 @@ jobs:
# The GITHUB_ names are what Gitea itself hands to the workflow — its
# actions repeat those of GitHub, and the addresses in them point at
# this Gitea instance. GITHUB_API_URL used not to reach the steps at
# all, so the address is put together from the server one when empty
$root = if ($env:GITHUB_API_URL) { $env:GITHUB_API_URL } else { "$env:GITHUB_SERVER_URL/api/v1" }
$api = "$root/repos/$env:GITHUB_REPOSITORY/releases"
# this Gitea instance
$api = "$env:GITHUB_API_URL/repos/$env:GITHUB_REPOSITORY/releases"
$headers = @{ Authorization = "token $env:GITEA_TOKEN" }
# Gitea makes a release of its own for a pushed tag, so the release is
# looked up first and only made when it is not there
$release = $null
try { $release = Invoke-RestMethod "$api/tags/$env:TAG" -Headers $headers } catch { }
@@ -153,7 +194,7 @@ jobs:
$release = Invoke-RestMethod $api -Method Post -Headers $headers -ContentType 'application/json' -Body $body
}
foreach ($file in Get-ChildItem artifacts/packages -File) {
foreach ($file in Get-ChildItem artifacts/installers -File -Filter *.msi) {
# A tag can be pushed again after it was deleted; the old file of
# the same name is dropped, otherwise the upload is refused
$existing = $release.assets | Where-Object { $_.name -eq $file.Name }
@@ -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>
+317
View File
@@ -0,0 +1,317 @@
using System.Diagnostics;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.RegularExpressions;
using CursorLang.Core.Services;
namespace CursorLang.Agent.Tests;
/// <summary>
/// The application as a whole: the agent starting, the settings window it opens, the
/// single instance and the exit.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public sealed partial class EndToEndTests
{
private static readonly TimeSpan StartTimeout = TimeSpan.FromSeconds(30);
private static readonly TimeSpan ExitTimeout = TimeSpan.FromSeconds(15);
/// <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_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.WaitForSettingsWindow());
Assert.False(launch.Process.HasExited);
}
/// <summary>
/// Started by Windows itself, the application goes straight to the tray: the
/// user asked for it to be there, not for a window to greet them.
/// </summary>
[Fact]
public void A_launch_by_Windows_shows_no_window()
{
using Launch launch = Launch.Start(StartupLaunch.Argument);
// Nothing is expected to appear, so the wait is for the whole time
Assert.False(launch.Process.WaitForExit(StayTimeout), "the agent ended by itself");
Assert.Empty(Launch.SettingsProcesses());
}
// 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(StartupLaunch.Argument);
Assert.False(launch.Process.WaitForExit(StayTimeout), "the agent ended by itself");
using Process second = Launch.StartProcess();
Assert.True(second.WaitForExit(ExitTimeout), "the second run did not end by itself");
Assert.Equal(0, second.ExitCode);
// And the first one keeps running
Assert.False(launch.Process.HasExited);
}
/// <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_settings_window_leaves_the_agent_running()
{
using Launch launch = Launch.Start();
launch.WaitForSettingsWindow();
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();
}
Assert.False(launch.Process.HasExited);
}
/// <summary>
/// "Exit" in the tray menu ends the application whole: the settings window goes with
/// the agent instead of staying on the screen belonging to nothing.
/// </summary>
/// <remarks>
/// The menu itself is out of reach of a test — it is a <c>TrackPopupMenuEx</c> menu
/// with a modal loop of its own — so what is checked is the request the menu makes.
/// The settings window here is the real one, started by the agent, and it must be
/// listening by the time it is on the screen.
/// </remarks>
[Fact]
public void The_agents_exit_closes_the_settings_window()
{
using Launch launch = Launch.Start();
launch.WaitForSettingsWindow();
Process settings = Launch.SettingsProcesses().Single();
try
{
Assert.True(SettingsCloseSignal.RequestClose(), "the settings window was not listening");
Assert.True(settings.WaitForExit(ExitTimeout), "the settings window outlived the agent's exit");
}
finally
{
settings.Dispose();
}
}
/// <summary>A started application that shuts down together with the check.</summary>
private sealed class Launch : IDisposable
{
private const int UOI_NAME = 2;
private const string InteractiveWindowStation = "WinSta0";
[DllImport("user32.dll")]
private static extern IntPtr GetProcessWindowStation();
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
private static extern bool GetUserObjectInformation(IntPtr hObj, int nIndex,
StringBuilder pvInfo, int nLength, out int lpnLengthNeeded);
private Launch(Process process) => Process = process;
internal Process Process { get; }
/// <summary>Starts the agent first — making sure the place is free.</summary>
internal static Launch Start(params string[] arguments)
{
if (!HasInteractiveDesktop())
{
Assert.Skip("There is no interactive desktop here — the application has nowhere to show its window");
}
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");
}
return new Launch(StartProcess(arguments));
}
/// <summary>Starts the agent the way the user — or Windows — does.</summary>
internal static Process StartProcess(params string[] arguments)
{
string path = ExecutablePath();
if (!File.Exists(path))
{
Assert.Skip($"The application is not built: {path}");
}
var start = new ProcessStartInfo(path) { UseShellExecute = true };
foreach (string argument in arguments)
{
start.ArgumentList.Add(argument);
}
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. 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 WaitForSettingsWindow()
{
DateTime deadline = DateTime.UtcNow + StartTimeout;
while (DateTime.UtcNow < deadline)
{
Process.Refresh();
if (Process.HasExited)
{
Assert.Fail($"The agent exited while starting with code {Process.ExitCode}");
}
foreach (Process settings in SettingsProcesses())
{
settings.Refresh();
IntPtr window = settings.MainWindowHandle;
settings.Dispose();
if (window != IntPtr.Zero)
{
return window;
}
}
Thread.Sleep(100);
}
Assert.Fail("The settings window never appeared");
return IntPtr.Zero;
}
/// <summary>
/// Whether there is a desktop here to show a window on. A service gets a
/// window station of its own — "Service-0x0-3e7$" and the like: a window can
/// be created there, yet nothing shows it. Only "WinSta0" is the interactive one.
/// </summary>
private static bool HasInteractiveDesktop()
{
IntPtr station = GetProcessWindowStation();
if (station == IntPtr.Zero)
{
return false;
}
var name = new StringBuilder(256);
return GetUserObjectInformation(station, UOI_NAME, name, name.Capacity * sizeof(char), out _)
&& name.ToString().Equals(InteractiveWindowStation, StringComparison.OrdinalIgnoreCase);
}
private static string ExecutablePath()
{
string configured = Assembly.GetExecutingAssembly()
.GetCustomAttributes<AssemblyMetadataAttribute>()
.Single(attribute => attribute.Key == "CursorLangExecutable")
.Value!;
return Path.GetFullPath(configured);
}
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)
{
process.Kill(entireProcessTree: true);
process.WaitForExit(ExitTimeout);
}
}
catch (InvalidOperationException)
{
// The process has already ended on its own
}
finally
{
process.Dispose();
}
}
}
}
@@ -1,9 +1,9 @@
using System.Collections.Concurrent;
using CursorLang.Models;
using CursorLang.Services;
using CursorLang.Tests.Infrastructure;
using CursorLang.Agent.Services;
using CursorLang.Core.Models;
using CursorLang.Tests.Shared;
namespace CursorLang.Tests.Services;
namespace CursorLang.Agent.Tests.Services;
/// <summary>
/// Making sense of Caps Lock presses: a short one differs from a long one only
@@ -19,8 +19,8 @@ public sealed class CapsLockHotkeyServiceTests
{
using var harness = Harness.Create(holdMilliseconds: 10_000);
Assert.True(Sta.Run(() => harness.Service.HandleKeyEvent(CapsLock, isKeyDown: true)));
Assert.True(Sta.Run(() => harness.Service.HandleKeyEvent(CapsLock, isKeyDown: false)));
Assert.True(Pump.Run(() => harness.Service.HandleKeyEvent(CapsLock, isKeyDown: true)));
Assert.True(Pump.Run(() => harness.Service.HandleKeyEvent(CapsLock, isKeyDown: false)));
}
[Fact]
@@ -28,8 +28,8 @@ public sealed class CapsLockHotkeyServiceTests
{
using var harness = Harness.Create(holdMilliseconds: 10_000);
Assert.False(Sta.Run(() => harness.Service.HandleKeyEvent(LetterA, isKeyDown: true)));
Assert.False(Sta.Run(() => harness.Service.HandleKeyEvent(LetterA, isKeyDown: false)));
Assert.False(Pump.Run(() => harness.Service.HandleKeyEvent(LetterA, isKeyDown: true)));
Assert.False(Pump.Run(() => harness.Service.HandleKeyEvent(LetterA, isKeyDown: false)));
Assert.Empty(harness.Events);
}
@@ -41,7 +41,7 @@ public sealed class CapsLockHotkeyServiceTests
harness.Press();
harness.Release();
Sta.WaitFor(() => harness.Events.Count == 1, "the short press event arrived");
Pump.WaitFor(() => harness.Events.Count == 1, "the short press event arrived");
Assert.Equal(["tap"], harness.Events);
}
@@ -52,7 +52,7 @@ public sealed class CapsLockHotkeyServiceTests
harness.Press();
Sta.WaitFor(() => harness.Events.Contains("hold-start"), "the hold threshold was passed");
Pump.WaitFor(() => harness.Events.Contains("hold-start"), "the hold threshold was passed");
Assert.Equal(["hold-start"], harness.Events);
}
@@ -62,11 +62,11 @@ public sealed class CapsLockHotkeyServiceTests
using var harness = Harness.Create(holdMilliseconds: 20);
harness.Press();
Sta.WaitFor(() => harness.Events.Contains("hold-start"), "the hold threshold was passed");
Pump.WaitFor(() => harness.Events.Contains("hold-start"), "the hold threshold was passed");
harness.Release();
Sta.WaitFor(() => harness.Events.Count == 2, "the end of the hold arrived");
Pump.WaitFor(() => harness.Events.Count == 2, "the end of the hold arrived");
Assert.Equal(["hold-start", "hold-end"], harness.Events);
}
@@ -80,11 +80,11 @@ public sealed class CapsLockHotkeyServiceTests
for (int i = 0; i < 10; i++)
{
Sta.Pause(TimeSpan.FromMilliseconds(10));
Pump.Pause(TimeSpan.FromMilliseconds(10));
harness.Press();
}
Sta.WaitFor(() => harness.Events.Contains("hold-start"), "the hold was announced despite the repeats");
Pump.WaitFor(() => harness.Events.Contains("hold-start"), "the hold was announced despite the repeats");
}
[Fact]
@@ -95,7 +95,7 @@ public sealed class CapsLockHotkeyServiceTests
harness.Press();
harness.Release();
Sta.Pause(TimeSpan.FromMilliseconds(400));
Pump.Pause(TimeSpan.FromMilliseconds(400));
Assert.Equal(["tap"], harness.Events);
}
@@ -108,11 +108,11 @@ public sealed class CapsLockHotkeyServiceTests
using var harness = Harness.Create(holdMilliseconds: 20);
harness.Press();
Sta.WaitFor(() => harness.Events.Contains("hold-start"), "the hold threshold was passed");
Pump.WaitFor(() => harness.Events.Contains("hold-start"), "the hold threshold was passed");
Sta.Run(harness.Service.Stop);
Pump.Run(harness.Service.Stop);
Sta.WaitFor(() => harness.Events.Count == 2, "the end of the hold was announced");
Pump.WaitFor(() => harness.Events.Count == 2, "the end of the hold was announced");
Assert.Equal(["hold-start", "hold-end"], harness.Events);
}
@@ -122,8 +122,8 @@ public sealed class CapsLockHotkeyServiceTests
using var harness = Harness.Create(holdMilliseconds: 10_000);
harness.Press();
Sta.Run(harness.Service.Stop);
Sta.Pause(TimeSpan.FromMilliseconds(50));
Pump.Run(harness.Service.Stop);
Pump.Pause(TimeSpan.FromMilliseconds(50));
Assert.Empty(harness.Events);
}
@@ -135,9 +135,9 @@ public sealed class CapsLockHotkeyServiceTests
using var harness = Harness.Create(holdMilliseconds: 40);
harness.Press();
Sta.Run(harness.Service.Stop);
Pump.Run(harness.Service.Stop);
Sta.Pause(TimeSpan.FromMilliseconds(120));
Pump.Pause(TimeSpan.FromMilliseconds(120));
Assert.Empty(harness.Events);
}
@@ -148,12 +148,12 @@ public sealed class CapsLockHotkeyServiceTests
using var harness = Harness.Create(holdMilliseconds: 10_000);
harness.Press();
Sta.Run(harness.Service.Stop);
Pump.Run(harness.Service.Stop);
harness.Press();
harness.Release();
Sta.WaitFor(() => harness.Events.Count == 1, "a short press after the restart");
Pump.WaitFor(() => harness.Events.Count == 1, "a short press after the restart");
Assert.Equal(["tap"], harness.Events);
}
@@ -164,10 +164,10 @@ public sealed class CapsLockHotkeyServiceTests
var harness = Harness.Create(holdMilliseconds: 20);
harness.Press();
Sta.WaitFor(() => harness.Events.Contains("hold-start"), "the hold threshold was passed");
Pump.WaitFor(() => harness.Events.Contains("hold-start"), "the hold threshold was passed");
Sta.Run(harness.Service.Dispose);
Sta.Pause(TimeSpan.FromMilliseconds(80));
Pump.Run(harness.Service.Dispose);
Pump.Pause(TimeSpan.FromMilliseconds(80));
Assert.Equal(["hold-start"], harness.Events);
}
@@ -179,12 +179,12 @@ public sealed class CapsLockHotkeyServiceTests
harness.Press();
harness.Release();
Sta.WaitFor(() => harness.Events.Count == 1, "the first press counted as short");
Pump.WaitFor(() => harness.Events.Count == 1, "the first press counted as short");
harness.Settings.CapsLockHoldMilliseconds = 20;
harness.Press();
Sta.WaitFor(() => harness.Events.Contains("hold-start"), "the new threshold took effect");
Pump.WaitFor(() => harness.Events.Contains("hold-start"), "the new threshold took effect");
}
[Fact]
@@ -200,7 +200,7 @@ public sealed class CapsLockHotkeyServiceTests
public void The_interception_is_installed_and_removed()
{
using var harness = Harness.Create(holdMilliseconds: 10_000);
Sta.Run(() =>
Pump.Run(() =>
{
harness.Service.Start();
Assert.True(harness.Service.IsRunning);
@@ -223,7 +223,7 @@ public sealed class CapsLockHotkeyServiceTests
{
var harness = Harness.Create(holdMilliseconds: 10_000);
Sta.Run(() =>
Pump.Run(() =>
{
harness.Service.Start();
harness.Service.Dispose();
@@ -232,6 +232,32 @@ public sealed class CapsLockHotkeyServiceTests
});
}
/// <summary>
/// A hold is never announced for a key that has already been let go.
/// </summary>
/// <remarks>
/// The countdown started by the press can still be delivered just after the
/// release: Windows does not withdraw a WM_TIMER it has already posted. Taken at
/// face value it turned a tap into a hold — the popup came up showing the layout
/// the tap was about to change away from, and the switch followed it.
///
/// The tick is driven straight in here rather than waited for, because the point is
/// the one ordering a real clock will not reproduce on demand.
/// </remarks>
[Fact]
public void A_hold_is_not_announced_after_the_key_has_been_released()
{
using var harness = Harness.Create(holdMilliseconds: 10_000);
harness.Press();
harness.Release();
harness.ForceHoldTick();
Pump.Drain();
Assert.Equal(["tap"], harness.Events);
}
/// <summary>
/// The service together with its settings and the list of events that happened.
/// </summary>
@@ -254,8 +280,10 @@ public sealed class CapsLockHotkeyServiceTests
{
var settings = new AppSettings { CapsLockHoldMilliseconds = holdMilliseconds };
// The service remembers the dispatcher of the thread it was created on
CapsLockHotkeyService service = Sta.Run(() => new CapsLockHotkeyService(settings));
// Events reach their subscribers the way they do in the agent: posted back
// to the message loop, after the hook procedure has returned
CapsLockHotkeyService service =
Pump.Run(() => new CapsLockHotkeyService(settings, Pump.Post));
var harness = new Harness(service, settings);
service.Tapped += (_, _) => harness.Events.Enqueue("tap");
@@ -265,10 +293,13 @@ public sealed class CapsLockHotkeyServiceTests
return harness;
}
internal void Press() => Sta.Run(() => Service.HandleKeyEvent(CapsLock, isKeyDown: true));
internal void Press() => Pump.Run(() => Service.HandleKeyEvent(CapsLock, isKeyDown: true));
internal void Release() => Sta.Run(() => Service.HandleKeyEvent(CapsLock, isKeyDown: false));
internal void Release() => Pump.Run(() => Service.HandleKeyEvent(CapsLock, isKeyDown: false));
public void Dispose() => Sta.Run(Service.Dispose);
/// <summary>Delivers the hold countdown by hand, the way a late WM_TIMER does.</summary>
internal void ForceHoldTick() => Pump.Run(Service.HandleHoldElapsed);
public void Dispose() => Pump.Run(Service.Dispose);
}
}
@@ -1,13 +1,12 @@
using CursorLang.Models;
using CursorLang.Services;
using CursorLang.Tests.Infrastructure;
using CursorLang.ViewModels;
using CursorLang.Agent.Services;
using CursorLang.Core.Models;
using CursorLang.Tests.Shared;
namespace CursorLang.Tests.Services;
namespace CursorLang.Agent.Tests.Services;
/// <summary>
/// The lifetime of the tooltip. Its timer lives on the interface thread,
/// so everything happens there as well.
/// The lifetime of the tooltip. Its timer ticks on the message loop,
/// so everything happens on the pump thread as well.
/// </summary>
public sealed class LayoutPopupServiceTests
{
@@ -19,15 +18,14 @@ public sealed class LayoutPopupServiceTests
{
var window = new FakeLayoutPopupWindow();
var settings = new AppSettings { DurationMilliseconds = 10_000 };
var viewModel = new LayoutPopupViewModel(settings);
Sta.Run(() =>
Pump.Run(() =>
{
using var service = new LayoutPopupService(window, viewModel, settings);
using var service = new LayoutPopupService(window, settings);
service.Show(Russian);
});
Assert.Equal("RU", viewModel.ShortName);
Assert.Equal("RU", window.ShownText);
Assert.Equal(1, window.ShowCalls);
}
@@ -36,15 +34,14 @@ public sealed class LayoutPopupServiceTests
{
var window = new FakeLayoutPopupWindow();
var settings = new AppSettings { DurationMilliseconds = 30 };
var viewModel = new LayoutPopupViewModel(settings);
Sta.Run(() =>
Pump.Run(() =>
{
using var service = new LayoutPopupService(window, viewModel, settings);
using var service = new LayoutPopupService(window, settings);
service.Show(Russian);
Assert.Equal(0, window.HideCalls);
Sta.WaitFor(() => window.HideCalls == 1, "the tooltip hid itself");
Pump.WaitFor(() => window.HideCalls == 1, "the tooltip hid itself");
});
}
@@ -54,17 +51,16 @@ public sealed class LayoutPopupServiceTests
{
var window = new FakeLayoutPopupWindow();
var settings = new AppSettings { DurationMilliseconds = 10_000 };
var viewModel = new LayoutPopupViewModel(settings);
Sta.Run(() =>
Pump.Run(() =>
{
using var service = new LayoutPopupService(window, viewModel, settings);
using var service = new LayoutPopupService(window, settings);
service.Show(Russian);
settings.DurationMilliseconds = 30;
service.Show(English);
Sta.WaitFor(() => window.HideCalls == 1, "the tooltip hid by the new duration");
Pump.WaitFor(() => window.HideCalls == 1, "the tooltip hid by the new duration");
});
}
@@ -74,20 +70,19 @@ public sealed class LayoutPopupServiceTests
{
var window = new FakeLayoutPopupWindow();
var settings = new AppSettings { DurationMilliseconds = 60 };
var viewModel = new LayoutPopupViewModel(settings);
Sta.Run(() =>
Pump.Run(() =>
{
using var service = new LayoutPopupService(window, viewModel, settings);
using var service = new LayoutPopupService(window, settings);
for (int i = 0; i < 5; i++)
{
service.Show(i % 2 == 0 ? Russian : English);
Sta.Pause(TimeSpan.FromMilliseconds(20));
Pump.Pause(TimeSpan.FromMilliseconds(20));
Assert.Equal(0, window.HideCalls);
}
Sta.WaitFor(() => window.HideCalls == 1, "the tooltip hid after the last show");
Pump.WaitFor(() => window.HideCalls == 1, "the tooltip hid after the last show");
});
}
@@ -96,14 +91,13 @@ public sealed class LayoutPopupServiceTests
{
var window = new FakeLayoutPopupWindow();
var settings = new AppSettings { DurationMilliseconds = 20 };
var viewModel = new LayoutPopupViewModel(settings);
Sta.Run(() =>
Pump.Run(() =>
{
using var service = new LayoutPopupService(window, viewModel, settings);
using var service = new LayoutPopupService(window, settings);
service.ShowUntilHidden(Russian);
Sta.Pause(TimeSpan.FromMilliseconds(80));
Pump.Pause(TimeSpan.FromMilliseconds(80));
Assert.Equal(1, window.ShowCalls);
Assert.Equal(0, window.HideCalls);
@@ -119,18 +113,17 @@ public sealed class LayoutPopupServiceTests
{
var window = new FakeLayoutPopupWindow();
var settings = new AppSettings { DurationMilliseconds = 30 };
var viewModel = new LayoutPopupViewModel(settings);
Sta.Run(() =>
Pump.Run(() =>
{
using var service = new LayoutPopupService(window, viewModel, settings);
using var service = new LayoutPopupService(window, settings);
service.Show(Russian);
service.ShowUntilHidden(English);
Sta.Pause(TimeSpan.FromMilliseconds(100));
Pump.Pause(TimeSpan.FromMilliseconds(100));
Assert.Equal(0, window.HideCalls);
Assert.Equal("EN", viewModel.ShortName);
Assert.Equal("EN", window.ShownText);
});
}
@@ -139,15 +132,14 @@ public sealed class LayoutPopupServiceTests
{
var window = new FakeLayoutPopupWindow();
var settings = new AppSettings { DurationMilliseconds = 30 };
var viewModel = new LayoutPopupViewModel(settings);
Sta.Run(() =>
Pump.Run(() =>
{
using var service = new LayoutPopupService(window, viewModel, settings);
using var service = new LayoutPopupService(window, settings);
service.Show(Russian);
service.Hide();
Sta.Pause(TimeSpan.FromMilliseconds(100));
Pump.Pause(TimeSpan.FromMilliseconds(100));
// There must be no second hide from the timer
Assert.Equal(1, window.HideCalls);
@@ -159,15 +151,14 @@ public sealed class LayoutPopupServiceTests
{
var window = new FakeLayoutPopupWindow();
var settings = new AppSettings();
var viewModel = new LayoutPopupViewModel(settings);
Sta.Run(() =>
Pump.Run(() =>
{
var service = new LayoutPopupService(window, viewModel, settings);
var service = new LayoutPopupService(window, settings);
service.Show(Russian);
service.Dispose();
Sta.Pause(TimeSpan.FromMilliseconds(50));
Pump.Pause(TimeSpan.FromMilliseconds(50));
});
Assert.Equal(1, window.CloseCalls);
@@ -178,15 +169,14 @@ public sealed class LayoutPopupServiceTests
{
var window = new FakeLayoutPopupWindow();
var settings = new AppSettings { DurationMilliseconds = 20 };
var viewModel = new LayoutPopupViewModel(settings);
Sta.Run(() =>
Pump.Run(() =>
{
var service = new LayoutPopupService(window, viewModel, settings);
var service = new LayoutPopupService(window, settings);
service.Show(Russian);
service.Dispose();
Sta.Pause(TimeSpan.FromMilliseconds(80));
Pump.Pause(TimeSpan.FromMilliseconds(80));
Assert.Equal(0, window.HideCalls);
});
@@ -197,14 +187,13 @@ public sealed class LayoutPopupServiceTests
{
var window = new FakeLayoutPopupWindow();
var settings = new AppSettings { DurationMilliseconds = 20 };
var viewModel = new LayoutPopupViewModel(settings);
Sta.Run(() =>
Pump.Run(() =>
{
using var service = new LayoutPopupService(window, viewModel, settings);
using var service = new LayoutPopupService(window, settings);
service.Show(Russian);
Sta.WaitFor(() => window.HideCalls == 1, "the tooltip hid itself");
Pump.WaitFor(() => window.HideCalls == 1, "the tooltip hid itself");
Assert.Equal(["show", "hide"], window.Calls);
});
@@ -0,0 +1,57 @@
using CursorLang.Agent.Windows;
using CursorLang.Core.Services;
using CursorLang.Tests.Shared;
namespace CursorLang.Agent.Tests.Windows;
/// <summary>
/// The one thing the settings window says to the agent.
/// </summary>
/// <remarks>
/// Both halves of it live apart — the message and the window class name in Core, the
/// window that answers in the agent — and nothing but a matching pair makes it work.
/// A renamed class or a renamed message would leave the agent showing yesterday's
/// settings until it is restarted, and nothing else would complain.
///
/// The agent's own window is used rather than a stand-in: what is being checked is
/// that the window the agent really creates is the one the message reaches.
/// </remarks>
public sealed class SettingsSignalTests
{
[Fact]
public void The_signal_reaches_the_agents_window()
{
var delivered = 0;
using AgentWindow window = Pump.Run(() =>
{
var created = new AgentWindow();
created.AddFilter((message, _, _) =>
{
if (message != SettingsSignal.Message)
{
return false;
}
delivered++;
return true;
});
return created;
});
Pump.Run(SettingsSignal.NotifyAgent);
Pump.Drain();
Assert.Equal(1, delivered);
}
// Nobody is listening, and that is a normal state of affairs: the settings window
// works perfectly well with no agent behind it
[Fact]
public void Signalling_with_no_agent_running_passes_without_consequence()
{
Pump.Run(SettingsSignal.NotifyAgent);
Pump.Drain();
}
}
+136
View File
@@ -0,0 +1,136 @@
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();
// "Exit" means the application, not just the background half of it. A settings
// window left open would outlive the tray icon it was opened from, so it is asked
// to close first — it may be the very window the user is looking at
private void OnExitRequested(object? sender, EventArgs e)
{
SettingsCloseSignal.RequestClose();
_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</RuntimeIdentifiers>
<SelfContained Condition="'$(RuntimeIdentifier)' != ''">true</SelfContained>
<PublishTrimmed>false</PublishTrimmed>
<PublishReadyToRun Condition="'$(RuntimeIdentifier)' != ''">true</PublishReadyToRun>
<Version>1.0.0</Version>
<AssemblyVersion>$(Version)</AssemblyVersion>
<FileVersion>$(Version)</FileVersion>
<Product>CursorLang</Product>
<Company>Aleksandr Neichev</Company>
<Description>Shows the keyboard layout at the cursor</Description>
<Copyright>Copyright (c) 2026</Copyright>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\CursorLang.Core\CursorLang.Core.csproj"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\CursorLang.Settings\CursorLang.Settings.csproj"
ReferenceOutputAssembly="false"
Private="false"/>
</ItemGroup>
<Target Name="PlaceTheSettingsWindowBesideTheAgent" AfterTargets="Build">
<ItemGroup>
<SettingsOutput Include="..\CursorLang.Settings\bin\$(Configuration)\$(TargetFramework)\**\*"/>
</ItemGroup>
<Copy SourceFiles="@(SettingsOutput)"
DestinationFolder="$(OutDir)%(RecursiveDir)"
SkipUnchangedFiles="true"/>
</Target>
<ItemGroup>
<InternalsVisibleTo Include="CursorLang.Agent.Tests"/>
</ItemGroup>
</Project>
+161
View File
@@ -0,0 +1,161 @@
using System.Drawing;
using System.Runtime.InteropServices;
using CursorLang.Core.Interop;
namespace CursorLang.Agent.Interop;
/// <summary>
/// Plain GDI: a font, text, and an off-screen bitmap to draw them into.
/// </summary>
/// <remarks>
/// GDI rather than GDI+ on purpose. <c>System.Drawing.Common</c> would make the drawing
/// code shorter, but it is a separate assembly with a native GDI+ library behind it, and
/// how little this process weighs is the entire reason it exists apart from the window.
/// Everything the popup needs — one rounded rectangle and one line of text — GDI can do
/// on its own, and it draws the text with ClearType, exactly as Windows does everywhere else.
/// </remarks>
internal static class GdiNative
{
internal const int TRANSPARENT = 1;
internal const uint DT_SINGLELINE = 0x00000020;
internal const uint DT_CENTER = 0x00000001;
internal const uint DT_VCENTER = 0x00000004;
internal const uint DT_CALCRECT = 0x00000400;
internal const uint DT_NOPREFIX = 0x00000800;
internal const uint DT_NOCLIP = 0x00000100;
private const int DEFAULT_CHARSET = 1;
private const int OUT_TT_PRECIS = 4;
private const int CLIP_DEFAULT_PRECIS = 0;
private const int CLEARTYPE_QUALITY = 5;
private const int DEFAULT_PITCH = 0;
/// <summary>
/// The face the popup is written in.
/// </summary>
/// <remarks>
/// WPF is asked for "Segoe UI" at FontWeight SemiBold and resolves that to the
/// seguisb.ttf face. To GDI that face is a family of its own — "Segoe UI Semibold" —
/// and asking for the "Segoe UI" family at weight 600 lands on Bold instead, which
/// is visibly heavier. So the family is named outright and the weight is left to the
/// mapper: the family has one member and no synthetic emboldening happens.
/// </remarks>
private const string SemiBoldFace = "Segoe UI Semibold";
private const int FW_DONTCARE = 0;
/// <summary>
/// A font of the given size in physical pixels.
/// </summary>
/// <remarks>
/// The size in the settings is in WPF units, that is 1/96 inch, while GDI counts
/// pixels — hence the multiplication by the monitor scale. The height is negative:
/// that asks for the em size rather than the cell height, which is what a font size
/// means everywhere else.
/// </remarks>
internal static IntPtr CreateFont(double wpfFontSize, double scale)
{
var height = (int)Math.Round(wpfFontSize * scale);
return CreateFontW(
-height, 0, 0, 0, FW_DONTCARE,
false, false, false,
DEFAULT_CHARSET, OUT_TT_PRECIS, CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY, DEFAULT_PITCH,
SemiBoldFace);
}
/// <summary>The size of a single line of text with the font selected into the context.</summary>
internal static Size MeasureText(IntPtr deviceContext, string text)
{
var bounds = default(PopupWindowNative.Rect);
DrawText(deviceContext, text, text.Length, ref bounds,
DT_CALCRECT | DT_SINGLELINE | DT_NOPREFIX);
return new Size(bounds.Right - bounds.Left, bounds.Bottom - bounds.Top);
}
/// <summary>A colour as GDI wants it: 0x00BBGGRR, the alpha carried elsewhere.</summary>
internal static uint ToColorRef(Color color) =>
(uint)(color.R | (color.G << 8) | (color.B << 16));
[DllImport("gdi32.dll", CharSet = CharSet.Unicode, EntryPoint = "CreateFontW")]
private static extern IntPtr CreateFontW(int cHeight, int cWidth, int cEscapement, int cOrientation,
int cWeight, bool bItalic, bool bUnderline, bool bStrikeOut,
int iCharSet, int iOutPrecision, int iClipPrecision, int iQuality, int iPitchAndFamily,
string pszFaceName);
/// <summary>
/// A 32-bit surface to draw the popup into before anyone can see it.
/// </summary>
/// <remarks>
/// Top-down — a negative height — so that the first row of <paramref name="bits"/>
/// is the top row of the picture and the alpha fixing up afterwards can walk the
/// memory straight through.
/// </remarks>
internal static IntPtr CreateSurface(IntPtr deviceContext, int width, int height, out IntPtr bits)
{
var header = new BitmapInfoHeader
{
biSize = Marshal.SizeOf<BitmapInfoHeader>(),
biWidth = width,
biHeight = -height,
biPlanes = 1,
biBitCount = 32,
biCompression = BI_RGB,
};
return CreateDIBSection(deviceContext, ref header, DIB_RGB_COLORS, out bits, IntPtr.Zero, 0);
}
private const uint BI_RGB = 0;
private const uint DIB_RGB_COLORS = 0;
[DllImport("gdi32.dll")]
internal static extern IntPtr CreateCompatibleDC(IntPtr hdc);
[DllImport("gdi32.dll")]
internal static extern bool DeleteDC(IntPtr hdc);
[DllImport("gdi32.dll")]
private static extern IntPtr CreateDIBSection(IntPtr hdc, ref BitmapInfoHeader header, uint usage,
out IntPtr bits, IntPtr section, uint offset);
[DllImport("gdi32.dll")]
internal static extern IntPtr SelectObject(IntPtr hdc, IntPtr h);
[DllImport("gdi32.dll")]
internal static extern bool DeleteObject(IntPtr ho);
[DllImport("gdi32.dll")]
internal static extern int SetBkMode(IntPtr hdc, int mode);
[DllImport("gdi32.dll")]
internal static extern uint SetTextColor(IntPtr hdc, uint color);
[DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "DrawTextW")]
internal static extern int DrawText(IntPtr hdc, string lpchText, int cchText,
ref PopupWindowNative.Rect lprc, uint format);
[DllImport("user32.dll")]
internal static extern IntPtr GetDC(IntPtr hWnd);
[DllImport("user32.dll")]
internal static extern int ReleaseDC(IntPtr hWnd, IntPtr hDC);
[StructLayout(LayoutKind.Sequential)]
private struct BitmapInfoHeader
{
public int biSize;
public int biWidth;
public int biHeight;
public short biPlanes;
public short biBitCount;
public uint biCompression;
public uint biSizeImage;
public int biXPelsPerMeter;
public int biYPelsPerMeter;
public uint biClrUsed;
public uint biClrImportant;
}
}
+98
View File
@@ -0,0 +1,98 @@
using System.Runtime.InteropServices;
using CursorLang.Core.Interop;
namespace CursorLang.Agent.Interop;
/// <summary>
/// The system context menu — the one the tray icon raises.
/// </summary>
/// <remarks>
/// A menu built this way is drawn by Windows, so the theme and the language chosen in
/// the application no longer reach it. That is the accepted price of leaving WPF: a WPF
/// <c>ContextMenu</c> costs the whole rendering stack in the background process.
/// </remarks>
internal static class MenuNative
{
private const uint MF_STRING = 0x00000000;
private const uint MF_SEPARATOR = 0x00000800;
private const uint MF_GRAYED = 0x00000001;
private const uint TPM_LEFTALIGN = 0x0000;
private const uint TPM_RIGHTBUTTON = 0x0002;
private const uint TPM_RETURNCMD = 0x0100;
/// <summary>An item of the menu being built.</summary>
/// <param name="Id">What <see cref="Track"/> gives back when the item is chosen.</param>
/// <param name="Caption">The text, or <c>null</c> for a separator.</param>
/// <param name="IsEnabled">A greyed item is shown but cannot be chosen.</param>
internal readonly record struct Item(int Id, string? Caption, bool IsEnabled = true)
{
internal static Item Separator => new(0, null);
}
/// <summary>
/// Raises the menu at a screen point and returns the identifier of the chosen item,
/// or zero when the user dismissed it.
/// </summary>
/// <remarks>
/// <c>TPM_RETURNCMD</c> means the answer comes back from the call itself instead of
/// as a <c>WM_COMMAND</c> later, which keeps the whole menu in one place. The call
/// does not return until the user is done with the menu — that is how a modal menu
/// works, and the message loop keeps running inside it.
///
/// The window is brought to the foreground first and poked with an empty message
/// afterwards: without the first the menu never closes on a click elsewhere, and
/// without the second it stays on screen after the choice is made. Both are
/// long-standing quirks of a menu owned by a window the user cannot see.
/// </remarks>
internal static int Track(IntPtr owner, PopupWindowNative.Point at, IReadOnlyList<Item> items)
{
IntPtr menu = CreatePopupMenu();
if (menu == IntPtr.Zero)
{
return 0;
}
try
{
foreach (Item item in items)
{
if (item.Caption is null)
{
AppendMenu(menu, MF_SEPARATOR, IntPtr.Zero, null);
continue;
}
uint flags = MF_STRING | (item.IsEnabled ? 0 : MF_GRAYED);
AppendMenu(menu, flags, new IntPtr(item.Id), item.Caption);
}
TrayIconNative.BringToForeground(owner);
int chosen = TrackPopupMenuEx(
menu, TPM_LEFTALIGN | TPM_RIGHTBUTTON | TPM_RETURNCMD,
at.X, at.Y, owner, IntPtr.Zero);
WindowNative.PostMessage(owner, WindowNative.WM_NULL, IntPtr.Zero, IntPtr.Zero);
return chosen;
}
finally
{
DestroyMenu(menu);
}
}
[DllImport("user32.dll")]
private static extern IntPtr CreatePopupMenu();
[DllImport("user32.dll")]
private static extern bool DestroyMenu(IntPtr hMenu);
[DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "AppendMenuW")]
private static extern bool AppendMenu(IntPtr hMenu, uint uFlags, IntPtr uIDNewItem, string? lpNewItem);
[DllImport("user32.dll")]
private static extern int TrackPopupMenuEx(IntPtr hMenu, uint uFlags, int x, int y,
IntPtr hwnd, IntPtr lptpm);
}
+229
View File
@@ -0,0 +1,229 @@
using System.Runtime.InteropServices;
using CursorLang.Core.Interop;
namespace CursorLang.Agent.Interop;
/// <summary>
/// Win32 API for the notification area: the icon itself, the messages it sends
/// and the icon image taken from the executable.
/// </summary>
/// <remarks>
/// The icon is asked for at version 4 of the protocol. It is the only version that
/// reports a request for the context menu as such — by the keyboard as well as by
/// the right button — and passes the point of the click along with it. The
/// notification then arrives in the low word of <c>lParam</c>, and the point in
/// <c>wParam</c>, which is the opposite of the earlier versions.
/// </remarks>
internal static class TrayIconNative
{
/// <summary>The message the icon sends to its window. WM_APP is free for the app.</summary>
internal const int CallbackMessage = 0x8000 + 1;
/// <summary>The user chose the icon: a click of the left button or Enter on it.</summary>
internal const int SelectNotification = 0x0400;
/// <summary>The same by the space bar — Windows tells the two apart.</summary>
internal const int KeySelectNotification = 0x0403;
/// <summary>The context menu is asked for: the right button or the menu key.</summary>
internal const int ContextMenuNotification = 0x007B;
private const int NIM_ADD = 0x00000000;
private const int NIM_DELETE = 0x00000002;
private const int NIM_SETVERSION = 0x00000004;
private const uint NIF_MESSAGE = 0x00000001;
private const uint NIF_ICON = 0x00000002;
private const uint NIF_TIP = 0x00000004;
private const uint NIF_SHOWTIP = 0x00000080;
private const uint NotifyIconVersion4 = 4;
private const uint IMAGE_ICON = 1;
private const uint LR_DEFAULTCOLOR = 0x00000000;
private const int SM_CXSMICON = 49;
private const int SM_CYSMICON = 50;
/// <summary>The resource the .NET build puts the application icon under.</summary>
private const int ApplicationIconResource = 32512;
/// <summary>
/// Explorer says it has restarted this way. The icons of every application are
/// gone by then and have to be put back.
/// </summary>
internal static int TaskbarCreatedMessage { get; } = RegisterWindowMessage("TaskbarCreated");
/// <summary>Puts the icon into the notification area.</summary>
internal static bool Add(IntPtr window, int id, IntPtr icon, string tooltip)
{
NotifyIconData data = Describe(window, id, icon, tooltip);
if (!Shell_NotifyIcon(NIM_ADD, ref data))
{
return false;
}
// The version is asked for after the icon is added and applies to it alone
data.uVersion = NotifyIconVersion4;
Shell_NotifyIcon(NIM_SETVERSION, ref data);
return true;
}
/// <summary>Takes the icon away. A forgotten icon stays in the tray until hovered.</summary>
internal static void Remove(IntPtr window, int id)
{
var data = new NotifyIconData
{
cbSize = Marshal.SizeOf<NotifyIconData>(),
hWnd = window,
uID = (uint)id,
};
Shell_NotifyIcon(NIM_DELETE, ref data);
}
/// <summary>
/// The icon of the application at the size the tray asks for.
/// </summary>
/// <remarks>
/// The image comes from the executable itself, so the tray shows what the user
/// sees in Explorer. The build puts the icon under the standard resource; should
/// it end up elsewhere, the first icon of the file is taken, and failing that —
/// the icon Windows gives to an application without one. An icon is needed
/// either way: without it the tray shows an empty spot.
/// </remarks>
internal static IntPtr LoadApplicationIcon()
{
int width = GetSystemMetrics(SM_CXSMICON);
int height = GetSystemMetrics(SM_CYSMICON);
IntPtr icon = LoadImage(
GetModuleHandle(null), ApplicationIconResource, IMAGE_ICON, width, height, LR_DEFAULTCOLOR);
if (icon == IntPtr.Zero && Environment.ProcessPath is { Length: > 0 } path)
{
icon = ExtractIconEx(path, 0, out IntPtr large, out IntPtr small, 1) > 0 ? small : IntPtr.Zero;
if (large != IntPtr.Zero)
{
DestroyIcon(large);
}
}
return icon != IntPtr.Zero ? icon : LoadIcon(IntPtr.Zero, ApplicationIconResource);
}
/// <summary>Releases an icon loaded by <see cref="LoadApplicationIcon"/>.</summary>
internal static void ReleaseIcon(IntPtr icon)
{
if (icon != IntPtr.Zero)
{
DestroyIcon(icon);
}
}
/// <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>
/// <remarks>
/// Windows takes a menu down when its owner window loses the foreground. A tray
/// icon belongs to a window that is never shown, so the foreground has to be
/// asked for by hand — otherwise the menu stays on screen after the user has
/// clicked past it.
/// </remarks>
internal static void BringToForeground(IntPtr window) => SetForegroundWindow(window);
private static NotifyIconData Describe(IntPtr window, int id, IntPtr icon, string tooltip) => new()
{
cbSize = Marshal.SizeOf<NotifyIconData>(),
hWnd = window,
uID = (uint)id,
uFlags = NIF_MESSAGE | NIF_ICON | NIF_TIP | NIF_SHOWTIP,
uCallbackMessage = CallbackMessage,
hIcon = icon,
szTip = tooltip,
};
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
private static extern bool Shell_NotifyIcon(int dwMessage, ref NotifyIconData lpData);
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
private static extern int RegisterWindowMessage(string lpString);
[DllImport("user32.dll")]
private static extern IntPtr LoadImage(IntPtr hInst, IntPtr name, uint type, int cx, int cy, uint fuLoad);
private static IntPtr LoadImage(IntPtr hInst, int resource, uint type, int cx, int cy, uint fuLoad) =>
LoadImage(hInst, new IntPtr(resource), type, cx, cy, fuLoad);
[DllImport("user32.dll")]
private static extern IntPtr LoadIcon(IntPtr hInstance, IntPtr lpIconName);
private static IntPtr LoadIcon(IntPtr hInstance, int resource) =>
LoadIcon(hInstance, new IntPtr(resource));
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
private static extern int ExtractIconEx(string lpszFile, int nIconIndex,
out IntPtr phiconLarge, out IntPtr phiconSmall, int nIcons);
[DllImport("user32.dll")]
private static extern bool DestroyIcon(IntPtr hIcon);
[DllImport("user32.dll")]
private static extern int GetSystemMetrics(int nIndex);
[DllImport("user32.dll")]
private static extern bool SetForegroundWindow(IntPtr hWnd);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
private static extern IntPtr GetModuleHandle(string? lpModuleName);
/// <summary>
/// NOTIFYICONDATAW. The whole structure is described even though only its first
/// half is used: Windows reads its size and refuses one it does not know.
/// </summary>
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct NotifyIconData
{
public int cbSize;
public IntPtr hWnd;
public uint uID;
public uint uFlags;
public int uCallbackMessage;
public IntPtr hIcon;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)]
public string szTip;
public uint dwState;
public uint dwStateMask;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)]
public string szInfo;
/// <summary>A timeout in the older versions and the protocol version here.</summary>
public uint uVersion;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)]
public string szInfoTitle;
public uint dwInfoFlags;
public Guid guidItem;
public IntPtr hBalloonIcon;
}
}
+180
View File
@@ -0,0 +1,180 @@
using System.Runtime.InteropServices;
using CursorLang.Core.Interop;
namespace CursorLang.Agent.Interop;
/// <summary>
/// The Win32 pieces a window needs when there is no framework to make one:
/// the class, the window itself, the message loop.
/// </summary>
internal static class WindowNative
{
/// <summary>The window procedure. Windows keeps the only reference to it.</summary>
internal delegate IntPtr WindowProc(IntPtr hWnd, uint message, IntPtr wParam, IntPtr lParam);
internal const int WS_POPUP = unchecked((int)0x80000000);
internal const int WS_EX_LAYERED = 0x00080000;
internal const int WS_EX_TOOLWINDOW = 0x00000080;
internal const int WS_EX_NOACTIVATE = 0x08000000;
internal const int WS_EX_TRANSPARENT = 0x00000020;
internal const int WS_EX_TOPMOST = 0x00000008;
internal const int SW_HIDE = 0;
internal const int SW_SHOWNOACTIVATE = 4;
internal const uint WM_DESTROY = 0x0002;
internal const uint WM_CLOSE = 0x0010;
internal const uint WM_QUIT = 0x0012;
internal const uint WM_NULL = 0x0000;
internal const uint WM_ENDSESSION = 0x0016;
/// <summary>WM_APP and up belong to the application; the tray takes WM_APP + 1.</summary>
internal const uint WM_APP = 0x8000;
/// <summary>
/// Registers a window class. A class already there is not an error: the name is
/// unique per window kind, and a second agent in the same process would meet its
/// own registration.
/// </summary>
internal static void RegisterClass(string className, WindowProc windowProc)
{
var description = new WindowClass
{
cbSize = Marshal.SizeOf<WindowClass>(),
lpfnWndProc = windowProc,
hInstance = GetModuleHandle(null),
lpszClassName = className,
};
if (RegisterClassEx(ref description) == 0 &&
Marshal.GetLastWin32Error() != ErrorClassAlreadyExists)
{
throw new InvalidOperationException(
$"RegisterClassExW failed for '{className}': {Marshal.GetLastWin32Error()}");
}
}
private const int ErrorClassAlreadyExists = 1410;
/// <summary>Creates a window of a registered class. It is not shown.</summary>
internal static IntPtr CreateWindow(string className, string title, int style, int exStyle)
{
IntPtr window = CreateWindowEx(
exStyle, className, title, style,
0, 0, 0, 0,
IntPtr.Zero, IntPtr.Zero, GetModuleHandle(null), IntPtr.Zero);
if (window == IntPtr.Zero)
{
throw new InvalidOperationException(
$"CreateWindowExW failed for '{className}': {Marshal.GetLastWin32Error()}");
}
return window;
}
/// <summary>
/// Puts a finished picture into a layered window, together with where it goes and
/// how see-through it is.
/// </summary>
/// <remarks>
/// One call replaces moving the window, resizing it, painting it and setting its
/// opacity, and it works while the window is still hidden. That is the point: the
/// content is ready before anyone can see the window, so it can never be shown
/// holding the picture of the previous time.
/// </remarks>
internal static bool SetContent(
IntPtr window, PopupWindowNative.Point at, Size size, IntPtr sourceDc, byte alpha)
{
var source = new PopupWindowNative.Point { X = 0, Y = 0 };
var blend = new BlendFunction
{
BlendOp = AC_SRC_OVER,
SourceConstantAlpha = alpha,
AlphaFormat = AC_SRC_ALPHA,
};
return UpdateLayeredWindow(
window, IntPtr.Zero, ref at, ref size, sourceDc, ref source, 0, ref blend, ULW_ALPHA);
}
private const byte AC_SRC_OVER = 0;
private const byte AC_SRC_ALPHA = 1;
private const uint ULW_ALPHA = 0x00000002;
/// <summary>
/// BLENDFUNCTION. <c>BlendFlags</c> is never assigned and must stay all the same:
/// Windows reads the structure by its layout, and dropping a byte from the middle
/// of it would shift everything after.
/// </summary>
[StructLayout(LayoutKind.Sequential)]
private struct BlendFunction
{
public byte BlendOp;
public byte BlendFlags;
public byte SourceConstantAlpha;
public byte AlphaFormat;
}
[StructLayout(LayoutKind.Sequential)]
internal struct Size
{
public int Width;
public int Height;
}
[DllImport("user32.dll", SetLastError = true)]
private static extern bool UpdateLayeredWindow(IntPtr hWnd, IntPtr hdcDst,
ref PopupWindowNative.Point pptDst, ref Size psize, IntPtr hdcSrc,
ref PopupWindowNative.Point pptSrc, uint crKey, ref BlendFunction pblend, uint dwFlags);
[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern ushort RegisterClassEx(ref WindowClass lpwcx);
[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true, EntryPoint = "CreateWindowExW")]
private static extern IntPtr CreateWindowEx(int dwExStyle, string lpClassName, string lpWindowName,
int dwStyle, int x, int y, int nWidth, int nHeight,
IntPtr hWndParent, IntPtr hMenu, IntPtr hInstance, IntPtr lpParam);
[DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "DefWindowProcW")]
internal static extern IntPtr DefWindowProc(IntPtr hWnd, uint message, IntPtr wParam, IntPtr lParam);
[DllImport("user32.dll")]
internal static extern bool DestroyWindow(IntPtr hWnd);
[DllImport("user32.dll")]
internal static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
[DllImport("user32.dll")]
internal static extern bool IsWindowVisible(IntPtr hWnd);
[DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "PostMessageW")]
internal static extern bool PostMessage(IntPtr hWnd, uint message, IntPtr wParam, IntPtr lParam);
[DllImport("user32.dll")]
internal static extern void PostQuitMessage(int nExitCode);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
private static extern IntPtr GetModuleHandle(string? lpModuleName);
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct WindowClass
{
public int cbSize;
public uint style;
[MarshalAs(UnmanagedType.FunctionPtr)]
public WindowProc lpfnWndProc;
public int cbClsExtra;
public int cbWndExtra;
public IntPtr hInstance;
public IntPtr hIcon;
public IntPtr hCursor;
public IntPtr hbrBackground;
public string? lpszMenuName;
public string lpszClassName;
public IntPtr hIconSm;
}
}
+38
View File
@@ -0,0 +1,38 @@
using CursorLang.Core.Services;
namespace CursorLang.Agent;
internal static class Program
{
/// <summary>
/// A single-threaded apartment because of the caret: <c>AccessibleObjectFromWindow</c>
/// and UI Automation both go through COM, and both expect the thread that calls them
/// to be an STA one.
/// </summary>
[STAThread]
private static int Main(string[] arguments)
{
bool automatic = StartupLaunch.IsAutomatic(arguments);
var gate = new SingleInstanceGate(SingleInstanceGate.AgentName);
// A second launch is the user asking for the application, so the one already
// running opens the settings window and this one steps aside. A second launch
// by Windows at sign-in asks for nothing and gets nothing
if (!gate.TryAcquire(showRunningInstance: !automatic))
{
gate.Dispose();
return 0;
}
try
{
using var agent = new Agent(gate);
return agent.Run(automatic);
}
finally
{
gate.Dispose();
}
}
}
@@ -0,0 +1,11 @@
{
"profiles": {
"Agent": {
"commandName": "Project"
},
"Agent (started by Windows)": {
"commandName": "Project",
"commandLineArgs": "--startup"
}
}
}
@@ -1,8 +1,9 @@
using System.Windows.Threading;
using CursorLang.Interop;
using CursorLang.Models;
using CursorLang.Core.Interop;
using CursorLang.Core.Models;
using CursorLang.Core.Services;
using CursorLang.Core.Threading;
namespace CursorLang.Services;
namespace CursorLang.Agent.Services;
/// <summary>
/// Holds the system Caps Lock hook and tells a short tap from a hold.
@@ -12,22 +13,32 @@ namespace CursorLang.Services;
/// intercepted — the press and the release. That is also the only way to cancel the
/// case change: Windows toggles Caps Lock on the press event, and letting it through
/// "just in case" is not an option.
///
/// Two things changed on the way out of WPF: the hold is timed by
/// <see cref="MessageTimer"/>, and the event reaches its subscribers through a message
/// posted to the agent's window rather than through the dispatcher.
/// </remarks>
public sealed class CapsLockHotkeyService : ICapsLockHotkeyService, IDisposable
{
private const int VirtualKeyCapsLock = 0x14;
private readonly AppSettings _settings;
private readonly Action<Action> _post;
private readonly LowLevelKeyboardHook _hook;
private readonly DispatcherTimer _holdTimer = new();
private readonly Dispatcher _dispatcher = Dispatcher.CurrentDispatcher;
private readonly MessageTimer _holdTimer = new();
private bool _isPressed;
private bool _isHolding;
public CapsLockHotkeyService(AppSettings settings)
/// <param name="settings">Where the hold threshold is read from, on every press.</param>
/// <param name="post">
/// Hands work back to the message loop. Taken as a delegate rather than as the
/// agent's window so that the press logic can be checked without one.
/// </param>
public CapsLockHotkeyService(AppSettings settings, Action<Action> post)
{
_settings = settings;
_post = post;
_hook = new LowLevelKeyboardHook(HandleKeyEvent);
_holdTimer.Tick += OnHoldTimerTick;
}
@@ -49,12 +60,12 @@ public sealed class CapsLockHotkeyService : ICapsLockHotkeyService, IDisposable
}
// When the application is closing, nobody is waiting for events any more, so
// unlike in Stop the state is reset quietly: the dispatcher queue may be shut
// down by that moment
// unlike in Stop the state is reset quietly: the message loop is already gone by
// that moment and posted work would never run
public void Dispose()
{
_holdTimer.Tick -= OnHoldTimerTick;
_holdTimer.Stop();
_holdTimer.Dispose();
_isPressed = false;
_isHolding = false;
_hook.Dispose();
@@ -104,9 +115,28 @@ public sealed class CapsLockHotkeyService : ICapsLockHotkeyService, IDisposable
return true;
}
private void OnHoldTimerTick(object? sender, EventArgs e)
private void OnHoldTimerTick(object? sender, EventArgs e) => HandleHoldElapsed();
/// <summary>
/// The hold countdown has run out.
/// </summary>
/// <remarks>
/// The key being down is checked rather than assumed. A countdown started on the
/// press can still be delivered just after the release — Windows does not withdraw a
/// WM_TIMER it has already posted — and announcing a hold then would put the popup on
/// screen showing the layout the tap is about to change away from.
///
/// The tests reach this directly: that ordering is the whole point and a real clock
/// will not reproduce it on demand.
/// </remarks>
internal void HandleHoldElapsed()
{
_holdTimer.Stop();
if (!_isPressed)
{
return;
}
_isHolding = true;
HoldStarted?.Invoke(this, EventArgs.Empty);
}
@@ -117,7 +147,7 @@ public sealed class CapsLockHotkeyService : ICapsLockHotkeyService, IDisposable
{
if (handler is not null)
{
_dispatcher.BeginInvoke(() => handler(this, EventArgs.Empty));
_post(() => handler(this, EventArgs.Empty));
}
}
@@ -1,24 +1,27 @@
using System.Windows.Threading;
using CursorLang.Models;
using CursorLang.ViewModels;
using CursorLang.Core.Models;
using CursorLang.Core.Services;
using CursorLang.Core.Threading;
namespace CursorLang.Services;
namespace CursorLang.Agent.Services;
/// <summary>
/// Manages the lifetime of the popup: the window is only responsible for showing it,
/// while the decision of when to show and when to take it down is made here.
/// </summary>
/// <remarks>
/// The same service it always was, with <c>DispatcherTimer</c> swapped for
/// <see cref="MessageTimer"/>: both tick on the thread that owns the window, so
/// nothing else about the logic had to move.
/// </remarks>
public sealed class LayoutPopupService : ILayoutPopupService, IDisposable
{
private readonly ILayoutPopupWindow _window;
private readonly LayoutPopupViewModel _viewModel;
private readonly AppSettings _settings;
private readonly DispatcherTimer _hideTimer = new();
private readonly MessageTimer _hideTimer = new();
public LayoutPopupService(ILayoutPopupWindow window, LayoutPopupViewModel viewModel, AppSettings settings)
public LayoutPopupService(ILayoutPopupWindow window, AppSettings settings)
{
_window = window;
_viewModel = viewModel;
_settings = settings;
_hideTimer.Tick += OnHideTimerTick;
@@ -37,9 +40,7 @@ public sealed class LayoutPopupService : ILayoutPopupService, IDisposable
public void ShowUntilHidden(KeyboardLayout layout)
{
_hideTimer.Stop();
_viewModel.ShortName = layout.ShortName;
_window.ShowPopup();
_window.ShowPopup(layout.ShortName);
}
public void Hide()
@@ -50,8 +51,8 @@ public sealed class LayoutPopupService : ILayoutPopupService, IDisposable
public void Dispose()
{
_hideTimer.Stop();
_hideTimer.Tick -= OnHideTimerTick;
_hideTimer.Dispose();
_window.Close();
}
@@ -0,0 +1,43 @@
using System.ComponentModel;
using System.Diagnostics;
using CursorLang.Core.Services;
namespace CursorLang.Agent.Services;
/// <summary>
/// Starts the settings window.
/// </summary>
/// <remarks>
/// The agent does not keep track of whether the window is already open, and does not
/// need to: the settings process guards a single-instance slot of its own, so a second
/// launch raises the window already there and exits. That costs a process start to find
/// out, which is a fraction of the time it takes a person to look at the tray, and it
/// saves the agent from holding a handle to something it does not own.
/// </remarks>
internal static class SettingsLauncher
{
/// <summary>
/// Opens the settings window. Returns <c>false</c> when the executable is not
/// where it should be — a half-copied installation, or the agent run from a build
/// folder of its own.
/// </summary>
internal static bool Open()
{
if (AgentExecutable.SettingsPath is not { } path)
{
return false;
}
try
{
using Process? started = Process.Start(new ProcessStartInfo(path) { UseShellExecute = false });
return started is not null;
}
catch (Exception e) when (e is Win32Exception or InvalidOperationException)
{
// Nothing to tell the user with: the agent has no window of its own, and
// the one that would have shown the message is the one that failed to start
return false;
}
}
}
+85
View File
@@ -0,0 +1,85 @@
using System.Collections.Concurrent;
using CursorLang.Agent.Interop;
namespace CursorLang.Agent.Windows;
/// <summary>
/// The window the agent lives around: never shown, but it owns the tray icon and it
/// is the way back onto the message loop from a callback.
/// </summary>
/// <remarks>
/// A window with no <c>WS_VISIBLE</c> shows nowhere, yet is a window in every other
/// way. A message-only window would do as well were it not for the news of Explorer
/// restarting: that one is broadcast, and broadcasts pass such windows by.
/// </remarks>
internal sealed class AgentWindow : NativeWindow
{
/// <summary>
/// A message hook. Returning <c>true</c> means the message has been dealt with.
/// </summary>
internal delegate bool MessageFilter(uint message, IntPtr wParam, IntPtr lParam);
private const string ClassName = "CursorLang.Agent.Window";
/// <summary>Drain the queue of posted work. WM_APP is free for the application.</summary>
private const uint WM_INVOKE = WindowNative.WM_APP + 100;
private readonly List<MessageFilter> _filters = [];
private readonly ConcurrentQueue<Action> _posted = new();
internal AgentWindow()
: base(ClassName, "CursorLang agent", WindowNative.WS_POPUP, WindowNative.WS_EX_TOOLWINDOW)
{
}
internal void AddFilter(MessageFilter filter) => _filters.Add(filter);
/// <summary>
/// Runs the action on the message loop, after the current message is done with.
/// </summary>
/// <remarks>
/// This is what the agent has instead of <c>Dispatcher.BeginInvoke</c>. The caller
/// that matters is the keyboard hook: Windows removes a hook whose procedure takes
/// too long, so the procedure only records what happened and the answer — showing
/// the popup, switching the layout — waits for the message after this one.
/// </remarks>
internal void Post(Action action)
{
_posted.Enqueue(action);
WindowNative.PostMessage(Handle, WM_INVOKE, IntPtr.Zero, IntPtr.Zero);
}
/// <summary>Asks the message loop to finish.</summary>
internal void Quit() => WindowNative.PostQuitMessage(0);
protected override bool OnMessage(uint message, IntPtr wParam, IntPtr lParam, out IntPtr result)
{
result = IntPtr.Zero;
if (message == WM_INVOKE)
{
while (_posted.TryDequeue(out Action? action))
{
action();
}
return true;
}
if (message is WindowNative.WM_CLOSE or WindowNative.WM_ENDSESSION)
{
Quit();
return true;
}
foreach (MessageFilter filter in _filters)
{
if (filter(message, wParam, lParam))
{
return true;
}
}
return false;
}
}
@@ -0,0 +1,424 @@
using System.Drawing;
using System.Runtime.InteropServices;
using CursorLang.Agent.Interop;
using CursorLang.Agent.Services;
using CursorLang.Core.Interop;
using CursorLang.Core.Models;
using CursorLang.Core.Services;
namespace CursorLang.Agent.Windows;
/// <summary>
/// The popup with the short name of the layout, drawn by Win32 alone.
/// </summary>
/// <remarks>
/// A like-for-like replacement of the WPF popup this once was: a rounded rectangle of
/// radius 4 with 10×4 padding, the fill and the text colour from the settings, the whole
/// thing at the opacity from the settings, the name in Segoe UI SemiBold at the size
/// from the settings.
///
/// The picture is drawn into an off-screen bitmap and handed to the window whole, by
/// <c>UpdateLayeredWindow</c>. Painting on demand instead — a <c>WM_PAINT</c> after the
/// window is shown — is what the first version did, and it had the popup appear holding
/// the picture of the previous show: hiding a window does not throw its content away,
/// and the content is always the other layout. Here there is nothing to be stale,
/// because the window is never shown before its picture is in place.
///
/// It also does away with two devices the painted version needed: the corners came from
/// a window region, which cuts without antialiasing, and the opacity from
/// <c>SetLayeredWindowAttributes</c>. Both are now just pixels in the bitmap.
///
/// Responsible only for showing the popup, its size and its place on screen: when to
/// take it down is decided by <see cref="LayoutPopupService"/>.
/// </remarks>
internal sealed class NativePopupWindow : NativeWindow, ILayoutPopupWindow
{
private const string ClassName = "CursorLang.Agent.Popup";
// The numbers of the XAML: Border CornerRadius="4" Padding="10,4", all in WPF units
private const double CornerRadius = 4;
private const double PaddingX = 10;
private const double PaddingY = 4;
private readonly AppSettings _settings;
private IntPtr _font;
private double _fontSize;
private double _fontScale;
internal NativePopupWindow(AppSettings settings)
: base(
ClassName,
"CursorLang popup",
WindowNative.WS_POPUP,
WindowNative.WS_EX_LAYERED | WindowNative.WS_EX_TOOLWINDOW |
WindowNative.WS_EX_NOACTIVATE | WindowNative.WS_EX_TRANSPARENT |
WindowNative.WS_EX_TOPMOST)
{
_settings = settings;
}
/// <summary>
/// Shows the popup with the given text at the place set by the settings.
/// </summary>
public void ShowPopup(string text)
{
if (Handle == IntPtr.Zero)
{
return;
}
PopupWindowNative.Rect? anchor = TryGetAnchor();
PopupModeSettings mode = ModeFor(anchor);
PopupWindowNative.Rect work = default;
double scale;
if (anchor is { } at)
{
scale = PopupWindowNative.GetScaleAt(new PopupWindowNative.Point { X = at.Left, Y = at.Top });
}
else
{
(work, scale) = PopupWindowNative.GetActiveMonitorWorkArea();
}
EnsureFont(mode, scale);
Size measured = MeasureText(text);
int width = measured.Width + (2 * PopupLayout.ToPixels(PaddingX, scale));
int height = measured.Height + (2 * PopupLayout.ToPixels(PaddingY, scale));
PopupWindowNative.Point position = anchor is { } near
? PopupLayout.NearAnchor(
near,
SideForMode(),
PopupLayout.ToPixels(mode.Offset, scale),
width,
height)
: PopupLayout.OnScreen(
work,
_settings.FixedPoint.Position,
PopupLayout.ToPixels(mode.Offset, scale),
width,
height);
if (!Draw(mode, text, position, width, height, PopupLayout.ToPixels(CornerRadius, scale)))
{
return;
}
if (!WindowNative.IsWindowVisible(Handle))
{
WindowNative.ShowWindow(Handle, WindowNative.SW_SHOWNOACTIVATE);
}
}
public void Hide()
{
if (Handle != IntPtr.Zero)
{
WindowNative.ShowWindow(Handle, WindowNative.SW_HIDE);
}
}
/// <summary>Destroys the window. The agent only does this on the way out.</summary>
public void Close() => Dispose();
public override void Dispose()
{
ReleaseFont();
base.Dispose();
}
/// <summary>
/// Draws the popup off screen and hands the finished picture to the window.
/// </summary>
/// <remarks>
/// The bitmap is thrown away afterwards rather than kept: it is a few tens of
/// kilobytes for the length of one call, the popup is shown rarely, and a cached one
/// would have to be rebuilt on every change of size, colour or scale anyway.
/// </remarks>
private bool Draw(
PopupModeSettings mode, string text, PopupWindowNative.Point at, int width, int height, int radius)
{
IntPtr screen = GdiNative.GetDC(IntPtr.Zero);
if (screen == IntPtr.Zero)
{
return false;
}
IntPtr memory = IntPtr.Zero;
IntPtr surface = IntPtr.Zero;
try
{
memory = GdiNative.CreateCompatibleDC(screen);
if (memory == IntPtr.Zero)
{
return false;
}
surface = GdiNative.CreateSurface(memory, width, height, out IntPtr bits);
if (surface == IntPtr.Zero)
{
return false;
}
GdiNative.SelectObject(memory, surface);
Fill(mode, bits, width, height);
DrawText(mode, memory, text, width, height);
// GDI writes nothing into the alpha channel, so the letters it just drew are
// sitting at zero alpha and would come out invisible. The inside of the
// popup is opaque anyway, so the whole surface is simply declared so — and
// the corners are rounded off afterwards, which is the only place alpha
// varies
MakeOpaque(bits, width, height);
RoundTheCorners(bits, width, height, radius);
var size = new WindowNative.Size { Width = width, Height = height };
var alpha = (byte)Math.Clamp(Math.Round(mode.Opacity * 255), 0, 255);
return WindowNative.SetContent(Handle, at, size, memory, alpha);
}
finally
{
// The context goes first: a bitmap still selected into one cannot be
// deleted, and this way that holds however the method was left
if (memory != IntPtr.Zero)
{
GdiNative.DeleteDC(memory);
}
if (surface != IntPtr.Zero)
{
GdiNative.DeleteObject(surface);
}
GdiNative.ReleaseDC(IntPtr.Zero, screen);
}
}
private static void Fill(PopupModeSettings mode, IntPtr bits, int width, int height)
{
Color background = mode.BackgroundColor;
// Straight into the bitmap rather than through a brush: the pixels have to be
// written anyway to carry an alpha channel GDI would not touch
int packed = (255 << 24) | (background.R << 16) | (background.G << 8) | background.B;
var row = new int[width];
Array.Fill(row, packed);
for (var y = 0; y < height; y++)
{
Marshal.Copy(row, 0, bits + (y * width * 4), width);
}
}
private void DrawText(
PopupModeSettings mode, IntPtr deviceContext, string text, int width, int height)
{
if (_font == IntPtr.Zero || text.Length == 0)
{
return;
}
var bounds = new PopupWindowNative.Rect { Left = 0, Top = 0, Right = width, Bottom = height };
IntPtr previousFont = GdiNative.SelectObject(deviceContext, _font);
GdiNative.SetBkMode(deviceContext, GdiNative.TRANSPARENT);
GdiNative.SetTextColor(deviceContext, GdiNative.ToColorRef(mode.ForegroundColor));
GdiNative.DrawText(deviceContext, text, text.Length, ref bounds,
GdiNative.DT_SINGLELINE | GdiNative.DT_CENTER | GdiNative.DT_VCENTER |
GdiNative.DT_NOPREFIX | GdiNative.DT_NOCLIP);
GdiNative.SelectObject(deviceContext, previousFont);
}
private static void MakeOpaque(IntPtr bits, int width, int height)
{
var row = new int[width];
for (var y = 0; y < height; y++)
{
IntPtr line = bits + (y * width * 4);
Marshal.Copy(line, row, 0, width);
for (var x = 0; x < width; x++)
{
row[x] = (int)((uint)row[x] | 0xFF000000);
}
Marshal.Copy(row, 0, line, width);
}
}
/// <summary>
/// Cuts the four corners to a radius, fading the edge rather than stepping it.
/// </summary>
/// <remarks>
/// The painted version cut them with a window region, which is a yes-or-no mask and
/// left a visible staircase at 200% scale. Here the corner pixels carry a partial
/// alpha worked out from how far the pixel centre is past the arc, which is what
/// antialiasing amounts to. The colours are premultiplied to match, as
/// <c>UpdateLayeredWindow</c> expects.
/// </remarks>
private static void RoundTheCorners(IntPtr bits, int width, int height, int radius)
{
if (radius <= 0)
{
return;
}
radius = Math.Min(radius, Math.Min(width, height) / 2);
var row = new int[width];
for (var y = 0; y < height; y++)
{
bool nearTop = y < radius;
bool nearBottom = y >= height - radius;
if (!nearTop && !nearBottom)
{
continue;
}
IntPtr line = bits + (y * width * 4);
Marshal.Copy(line, row, 0, width);
double centreY = nearTop ? radius - 0.5 : height - radius - 0.5;
for (var x = 0; x < width; x++)
{
bool nearLeft = x < radius;
bool nearRight = x >= width - radius;
if (!nearLeft && !nearRight)
{
continue;
}
double centreX = nearLeft ? radius - 0.5 : width - radius - 0.5;
double distance = Math.Sqrt(
((x - centreX) * (x - centreX)) + ((y - centreY) * (y - centreY)));
// One pixel of softness across the arc: fully inside, fully outside,
// and a ramp in between
double coverage = Math.Clamp(radius - distance + 0.5, 0, 1);
if (coverage >= 1)
{
continue;
}
row[x] = Premultiply(row[x], coverage);
}
Marshal.Copy(row, 0, line, width);
}
}
private static int Premultiply(int pixel, double coverage)
{
var value = (uint)pixel;
var alpha = (uint)Math.Round(((value >> 24) & 0xFF) * coverage);
uint red = (uint)Math.Round(((value >> 16) & 0xFF) * coverage);
uint green = (uint)Math.Round(((value >> 8) & 0xFF) * coverage);
uint blue = (uint)Math.Round((value & 0xFF) * coverage);
return (int)((alpha << 24) | (red << 16) | (green << 8) | blue);
}
/// <summary>
/// What the popup is placed next to, or <c>null</c> when there is nothing: the fixed
/// point mode, and the caret mode where the application reports no caret.
/// </summary>
/// <remarks>
/// The cursor is a rectangle of zero size, so the corner arithmetic is shared by it
/// and the caret.
/// </remarks>
private PopupWindowNative.Rect? TryGetAnchor() => _settings.PlacementMode switch
{
PopupPlacementMode.FixedPoint => null,
PopupPlacementMode.AtCaret => CaretNative.TryGetCaretRect(),
_ => PopupLayout.AsAnchor(PopupWindowNative.GetCursorPosition()),
};
/// <summary>
/// The settings the popup is shown with: those of the mode chosen, or those of the
/// fixed point when there is no anchor to stand next to.
/// </summary>
/// <remarks>
/// The caret mode falls back to the fixed point rather than to the mouse cursor:
/// the cursor is wherever it was last left — off to a side, on another monitor, or
/// over the very text being typed — and a popup that lands there while the eyes are
/// on the caret is one that is looked for and not found. The fixed point is always
/// in the same place, so it is known where to look.
///
/// The look comes from the fixed point mode too, not just the place. The two are set
/// up together for a reason: the popup by the caret is small and quiet because it
/// sits inside a text being read, while the one in the corner of the monitor is
/// looked for on purpose and is set larger. Keeping the caret look at the corner
/// would put a popup meant to go unnoticed where nothing else draws the eye.
/// </remarks>
private PopupModeSettings ModeFor(PopupWindowNative.Rect? anchor) =>
anchor is null ? _settings.FixedPoint : _settings.Current;
// The caret has two sides to choose from and the cursor has six, so each mode names
// its own side in its own terms
private AnchorSide SideForMode() => _settings.PlacementMode == PopupPlacementMode.AtCaret
? _settings.AtCaret.Anchor
: _settings.AtCursor.Side;
private Size MeasureText(string text)
{
IntPtr deviceContext = GdiNative.GetDC(Handle);
if (deviceContext == IntPtr.Zero)
{
return Size.Empty;
}
try
{
IntPtr previousFont = GdiNative.SelectObject(deviceContext, _font);
Size measured = GdiNative.MeasureText(deviceContext, text);
GdiNative.SelectObject(deviceContext, previousFont);
return measured;
}
finally
{
GdiNative.ReleaseDC(Handle, deviceContext);
}
}
// The font is rebuilt only when the size in the settings or the monitor scale
// changes: it is the one expensive thing a show does. A switch of the placement
// mode counts as a change of the size, since the size belongs to the mode
private void EnsureFont(PopupModeSettings mode, double scale)
{
if (_font != IntPtr.Zero &&
Math.Abs(_fontSize - mode.FontSize) < 0.01 &&
Math.Abs(_fontScale - scale) < 0.01)
{
return;
}
ReleaseFont();
_fontSize = mode.FontSize;
_fontScale = scale;
_font = GdiNative.CreateFont(_fontSize, scale);
}
private void ReleaseFont()
{
if (_font != IntPtr.Zero)
{
GdiNative.DeleteObject(_font);
_font = IntPtr.Zero;
}
}
}
+128
View File
@@ -0,0 +1,128 @@
using CursorLang.Agent.Interop;
using CursorLang.Core.Interop;
using CursorLang.Core.Services;
namespace CursorLang.Agent.Windows;
/// <summary>
/// The icon in the notification area: the way to the settings window and the only way
/// to quit the application.
/// </summary>
/// <remarks>
/// The interop half is the same as it always was — the icon has never known anything
/// about the framework. What changed is the menu: a WPF <c>ContextMenu</c> obeyed the
/// theme and the language chosen in the settings, and a <c>TrackPopupMenuEx</c> menu is
/// drawn by Windows in the system look. The language still reaches it, because the
/// captions are ours; the theme does not, and that is the price of taking the rendering
/// stack out of the background process.
///
/// The captions are read each time the menu is raised rather than once: the language is
/// changed in the settings without a restart, and the menu is built on every click
/// anyway — a menu costs nothing to build and is asked for rarely.
/// </remarks>
internal sealed class NativeTrayIcon : IDisposable
{
/// <summary>Windows shows it under the pointer. The name of the app says enough.</summary>
private const string Tooltip = "CursorLang";
/// <summary>Distinguishes the icon among those of the same window; we have one.</summary>
private const int IconId = 1;
private const int CommandSettings = 1;
private const int CommandExit = 2;
private readonly AgentWindow _window;
private readonly ILocalizationService _localization;
private IntPtr _icon;
private bool _isInstalled;
internal NativeTrayIcon(AgentWindow window, ILocalizationService localization)
{
_window = window;
_localization = localization;
_window.AddFilter(OnMessage);
}
internal event EventHandler? OpenRequested;
internal event EventHandler? ExitRequested;
internal bool Install()
{
if (_isInstalled)
{
return true;
}
_icon = TrayIconNative.LoadApplicationIcon();
_isInstalled = TrayIconNative.Add(_window.Handle, IconId, _icon, Tooltip);
return _isInstalled;
}
public void Dispose()
{
if (_isInstalled)
{
TrayIconNative.Remove(_window.Handle, IconId);
_isInstalled = false;
}
TrayIconNative.ReleaseIcon(_icon);
_icon = IntPtr.Zero;
}
private bool OnMessage(uint message, IntPtr wParam, IntPtr lParam)
{
// Explorer has restarted and taken every icon down with it
if (message == (uint)TrayIconNative.TaskbarCreatedMessage && _isInstalled)
{
TrayIconNative.Add(_window.Handle, IconId, _icon, Tooltip);
return true;
}
if (message != TrayIconNative.CallbackMessage)
{
return false;
}
switch (TrayIconNative.NotificationOf(lParam))
{
case TrayIconNative.SelectNotification:
case TrayIconNative.KeySelectNotification:
OpenRequested?.Invoke(this, EventArgs.Empty);
return true;
case TrayIconNative.ContextMenuNotification:
ShowMenu(TrayIconNative.PointOf(wParam));
return true;
default:
return false;
}
}
/// <summary>Raises the menu of the icon where the pointer is.</summary>
internal void ShowMenu(PopupWindowNative.Point at)
{
MenuNative.Item[] items =
[
new(CommandSettings, _localization["TrayMenuSettings"]),
MenuNative.Item.Separator,
new(CommandExit, _localization["TrayMenuExit"]),
];
switch (MenuNative.Track(_window.Handle, at, items))
{
case CommandSettings:
OpenRequested?.Invoke(this, EventArgs.Empty);
break;
case CommandExit:
ExitRequested?.Invoke(this, EventArgs.Empty);
break;
}
}
}
+104
View File
@@ -0,0 +1,104 @@
using CursorLang.Agent.Interop;
namespace CursorLang.Agent.Windows;
/// <summary>
/// A window with no framework behind it: a registered class, a handle and a window
/// procedure that lands in <see cref="OnMessage"/>.
/// </summary>
/// <remarks>
/// Windows knows one procedure per class, so the procedure here is shared and static,
/// and finds the instance by handle. The very first message of a window arrives while
/// <c>CreateWindowExW</c> is still running and there is nothing to find yet — that is
/// what the field holding the instance under construction is for.
///
/// Everything is deliberately without locks: the agent has one message loop, every
/// window belongs to it, and a window procedure can only ever be called on the thread
/// that created the window.
/// </remarks>
internal abstract class NativeWindow : IDisposable
{
private static readonly Dictionary<IntPtr, NativeWindow> Live = [];
private static readonly HashSet<string> RegisteredClasses = new(StringComparer.Ordinal);
// The shared procedure is a static field for the same reason a hook procedure is:
// Windows holds the only reference to it and the collector does not see that
private static readonly WindowNative.WindowProc SharedProc = StaticWindowProc;
[ThreadStatic]
private static NativeWindow? _creating;
protected NativeWindow(string className, string title, int style, int exStyle)
{
if (RegisteredClasses.Add(className))
{
WindowNative.RegisterClass(className, SharedProc);
}
_creating = this;
try
{
Handle = WindowNative.CreateWindow(className, title, style, exStyle);
}
finally
{
_creating = null;
}
Live[Handle] = this;
}
/// <summary>The window handle. Zero once the window is gone.</summary>
internal IntPtr Handle { get; private set; }
public virtual void Dispose()
{
if (Handle == IntPtr.Zero)
{
return;
}
IntPtr handle = Handle;
Handle = IntPtr.Zero;
Live.Remove(handle);
WindowNative.DestroyWindow(handle);
}
/// <summary>
/// A message for this window. Returning <c>false</c> passes it to
/// <c>DefWindowProcW</c>, which is what the vast majority of messages want — and
/// what all of them want for a window whose content is set from the outside.
/// </summary>
protected virtual bool OnMessage(uint message, IntPtr wParam, IntPtr lParam, out IntPtr result)
{
result = IntPtr.Zero;
return false;
}
private static IntPtr StaticWindowProc(IntPtr hWnd, uint message, IntPtr wParam, IntPtr lParam)
{
if (!Live.TryGetValue(hWnd, out NativeWindow? window))
{
if (_creating is null)
{
return WindowNative.DefWindowProc(hWnd, message, wParam, lParam);
}
// The window is being created right now: bind the handle to the instance
// so that the rest of its creation messages find their way home
window = _creating;
window.Handle = hWnd;
Live[hWnd] = window;
}
if (message == WindowNative.WM_DESTROY)
{
Live.Remove(hWnd);
}
return window.OnMessage(message, wParam, lParam, out IntPtr result)
? result
: WindowNative.DefWindowProc(hWnd, message, wParam, lParam);
}
}
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="CursorLang.app" />
<assemblyIdentity version="1.0.0.0" name="CursorLang.Agent.app" />
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
@@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0-windows10.0.19041.0</TargetFramework>
<SupportedOSPlatformVersion>10.0.17763.0</SupportedOSPlatformVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<OutputType>Exe</OutputType>
<PlatformTarget>AnyCPU</PlatformTarget>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>CursorLang.Core.Tests</RootNamespace>
<AssemblyName>CursorLang.Core.Tests</AssemblyName>
</PropertyGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="xunit.v3" Version="3.2.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
<PackageReference Include="coverlet.collector" Version="10.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\CursorLang.Core\CursorLang.Core.csproj" />
<ProjectReference Include="..\CursorLang.Tests.Shared\CursorLang.Tests.Shared.csproj" />
</ItemGroup>
</Project>
@@ -1,7 +1,7 @@
using CursorLang.Interop;
using CursorLang.Tests.Infrastructure;
using CursorLang.Core.Interop;
using CursorLang.Tests.Shared;
namespace CursorLang.Tests.Interop;
namespace CursorLang.Core.Tests.Interop;
/// <summary>
/// Vetting the caret position. Some applications report it in their own
@@ -96,7 +96,7 @@ public sealed class CaretNativeTests
[Fact]
public void Asking_the_system_for_the_caret_goes_without_errors()
{
PopupWindowNative.Rect? caret = Sta.Run(CaretNative.TryGetCaretRect);
PopupWindowNative.Rect? caret = Pump.Run(CaretNative.TryGetCaretRect);
if (caret is not null)
{
@@ -1,9 +1,9 @@
using System.Reflection;
using System.Runtime.InteropServices;
using CursorLang.Interop;
using CursorLang.Tests.Infrastructure;
using CursorLang.Core.Interop;
using CursorLang.Tests.Shared;
namespace CursorLang.Tests.Interop;
namespace CursorLang.Core.Tests.Interop;
/// <summary>
/// Making sense of the events of the system keyboard hook.
@@ -126,7 +126,7 @@ public sealed class LowLevelKeyboardHookTests
[Fact]
public void The_interception_is_installed_and_removed()
{
Sta.Run(() =>
Pump.Run(() =>
{
using var hook = new LowLevelKeyboardHook(static (_, _) => false);
@@ -143,7 +143,7 @@ public sealed class LowLevelKeyboardHookTests
[Fact]
public void Installing_again_changes_nothing()
{
Sta.Run(() =>
Pump.Run(() =>
{
using var hook = new LowLevelKeyboardHook(static (_, _) => false);
@@ -169,7 +169,7 @@ public sealed class LowLevelKeyboardHookTests
[Fact]
public void Closing_removes_the_interception()
{
Sta.Run(() =>
Pump.Run(() =>
{
var hook = new LowLevelKeyboardHook(static (_, _) => false);
hook.Install();
@@ -0,0 +1,319 @@
using System.ComponentModel;
using System.Drawing;
using System.Reflection;
using System.Text.Json;
using CursorLang.Core.Models;
namespace CursorLang.Core.Tests.Models;
public sealed class AppSettingsTests
{
[Fact]
public void Default_values_describe_a_tooltip_at_the_cursor()
{
var settings = new AppSettings();
Assert.Equal("en", settings.Language);
Assert.Equal(AppTheme.System, settings.Theme);
Assert.Equal(PopupPlacementMode.AtCursor, settings.PlacementMode);
Assert.Equal(500, settings.DurationMilliseconds);
Assert.Equal(300, settings.CapsLockHoldMilliseconds);
}
[Fact]
public void Every_mode_starts_out_looking_the_same()
{
var settings = new AppSettings();
Assert.Equal(AnchorSide.BottomRight, settings.AtCursor.Side);
Assert.Equal(16, settings.AtCursor.Offset);
Assert.Equal(CaretSide.Right, settings.AtCaret.Side);
Assert.Equal(16, settings.AtCaret.Offset);
// The middle of the monitor, where there is no edge to stand off from
Assert.Equal(ScreenPosition.Center, settings.FixedPoint.Position);
Assert.Equal(0, settings.FixedPoint.Offset);
PopupModeSettings[] modes = [settings.AtCursor, settings.AtCaret, settings.FixedPoint];
Assert.All(modes, mode =>
{
Assert.Equal(20, mode.FontSize);
Assert.Equal(0.9, mode.Opacity);
Assert.Equal(Color.FromArgb(0x20, 0x20, 0x20), mode.BackgroundColor);
Assert.Equal(Color.FromArgb(0xFF, 0xFF, 0xFF), mode.ForegroundColor);
});
// Three modes and three sets of settings: none of them is shared
Assert.Equal(3, modes.Distinct().Count());
}
[Theory]
[InlineData(PopupPlacementMode.AtCursor)]
[InlineData(PopupPlacementMode.AtCaret)]
[InlineData(PopupPlacementMode.FixedPoint)]
public void The_chosen_mode_is_the_one_handed_out(PopupPlacementMode mode)
{
var settings = new AppSettings { PlacementMode = mode };
PopupModeSettings expected = mode switch
{
PopupPlacementMode.AtCaret => settings.AtCaret,
PopupPlacementMode.FixedPoint => settings.FixedPoint,
_ => settings.AtCursor,
};
Assert.Same(expected, settings.Current);
}
/// <summary>
/// The settings of a mode are its own: setting one up leaves the others alone.
/// </summary>
/// <remarks>
/// This is the whole point of keeping them per mode. A look shared by the modes
/// meant setting it up again after every switch, and switching modes to see what
/// they do undid what had just been set up.
/// </remarks>
[Fact]
public void Setting_one_mode_up_leaves_the_others_where_they_were()
{
var settings = new AppSettings();
settings.AtCaret.FontSize = 12;
settings.AtCaret.Side = CaretSide.Left;
settings.AtCaret.BackgroundColor = Color.FromArgb(0x11, 0x22, 0x33);
Assert.Equal(20, settings.AtCursor.FontSize);
Assert.Equal(AnchorSide.BottomRight, settings.AtCursor.Side);
Assert.Equal(20, settings.FixedPoint.FontSize);
Assert.Equal(Color.FromArgb(0x20, 0x20, 0x20), settings.FixedPoint.BackgroundColor);
}
// The mode in force decides what the look means, so a switch of it is a change of
// everything the window shows through the current mode
[Fact]
public void A_switch_of_the_mode_is_announced_as_a_change_of_the_current_one()
{
var settings = new AppSettings();
List<string?> changed = [];
settings.PropertyChanged += (_, e) => changed.Add(e.PropertyName);
settings.PlacementMode = PopupPlacementMode.FixedPoint;
Assert.Contains(nameof(AppSettings.PlacementMode), changed);
Assert.Contains(nameof(AppSettings.Current), changed);
}
/// <summary>
/// A change inside a mode is passed on as a change of the settings.
/// </summary>
/// <remarks>
/// The service that writes the file listens to the settings alone. Without this, a
/// font size dragged in the window would never reach the disk.
/// </remarks>
[Fact]
public void A_change_inside_a_mode_is_announced_by_the_settings()
{
var settings = new AppSettings();
List<string?> changed = [];
settings.PropertyChanged += (_, e) => changed.Add(e.PropertyName);
settings.AtCaret.FontSize = 42;
settings.FixedPoint.Position = ScreenPosition.Bottom;
settings.AtCursor.Offset = 8;
Assert.Contains("AtCaret.FontSize", changed);
Assert.Contains("FixedPoint.Position", changed);
Assert.Contains("AtCursor.Offset", changed);
}
// The app must not change how the system behaves until it is asked to
[Fact]
public void Caps_Lock_interception_is_off_by_default()
{
Assert.False(new AppSettings().UseCapsLockHotkey);
}
[Fact]
public void The_time_on_screen_is_derived_from_milliseconds()
{
var settings = new AppSettings { DurationMilliseconds = 1250 };
Assert.Equal(TimeSpan.FromMilliseconds(1250), settings.Duration);
}
[Fact]
public void The_hold_threshold_is_derived_from_milliseconds()
{
var settings = new AppSettings { CapsLockHoldMilliseconds = 400 };
Assert.Equal(TimeSpan.FromMilliseconds(400), settings.CapsLockHoldDelay);
}
[Theory]
[MemberData(nameof(WritableProperties))]
public void A_changed_setting_is_announced_to_subscribers(string propertyName)
{
var settings = new AppSettings();
List<string?> changed = [];
settings.PropertyChanged += (_, e) => changed.Add(e.PropertyName);
SetDifferentValue(settings, propertyName);
Assert.Contains(propertyName, changed);
}
[Theory]
[MemberData(nameof(WritableProperties))]
public void Writing_the_same_value_leaves_subscribers_alone(string propertyName)
{
var settings = new AppSettings();
PropertyInfo property = typeof(AppSettings).GetProperty(propertyName)!;
object? value = property.GetValue(settings);
List<string?> changed = [];
settings.PropertyChanged += (_, e) => changed.Add(e.PropertyName);
property.SetValue(settings, value);
Assert.Empty(changed);
}
/// <summary>
/// Pouring another instance in fills the modes in place rather than replacing them.
/// </summary>
/// <remarks>
/// This is the agent's side of the connection: the fresh values arrive as a freshly
/// parsed instance. Replacing the mode objects would leave the popup bound to the
/// old ones.
/// </remarks>
[Fact]
public void Taking_other_settings_over_fills_the_modes_that_are_already_there()
{
var settings = new AppSettings();
CursorModeSettings cursor = settings.AtCursor;
var other = new AppSettings
{
Language = "ru",
Theme = AppTheme.Dark,
PlacementMode = PopupPlacementMode.FixedPoint,
DurationMilliseconds = 900,
UseCapsLockHotkey = true,
CapsLockHoldMilliseconds = 450,
};
other.AtCursor.Side = AnchorSide.TopLeft;
other.AtCursor.Offset = 5;
other.AtCursor.FontSize = 11;
other.AtCaret.Side = CaretSide.Left;
other.AtCaret.ForegroundColor = Color.FromArgb(0x0A, 0x0B, 0x0C);
other.FixedPoint.Position = ScreenPosition.Top;
other.FixedPoint.Offset = 64;
settings.CopyFrom(other);
Assert.Same(cursor, settings.AtCursor);
Assert.Equal("ru", settings.Language);
Assert.Equal(AppTheme.Dark, settings.Theme);
Assert.Equal(PopupPlacementMode.FixedPoint, settings.PlacementMode);
Assert.Equal(900, settings.DurationMilliseconds);
Assert.True(settings.UseCapsLockHotkey);
Assert.Equal(450, settings.CapsLockHoldMilliseconds);
Assert.Equal(AnchorSide.TopLeft, settings.AtCursor.Side);
Assert.Equal(5, settings.AtCursor.Offset);
Assert.Equal(11, settings.AtCursor.FontSize);
Assert.Equal(CaretSide.Left, settings.AtCaret.Side);
Assert.Equal(Color.FromArgb(0x0A, 0x0B, 0x0C), settings.AtCaret.ForegroundColor);
Assert.Equal(ScreenPosition.Top, settings.FixedPoint.Position);
Assert.Equal(64, settings.FixedPoint.Offset);
}
// Derived values and the modes handed out for convenience are computed from what
// is stored and have no business being in the file themselves
[Fact]
public void Derived_values_stay_out_of_the_file()
{
using JsonDocument document = JsonSerializer.SerializeToDocument(new AppSettings());
List<string> names = [.. document.RootElement.EnumerateObject().Select(property => property.Name)];
Assert.DoesNotContain(nameof(AppSettings.Duration), names);
Assert.DoesNotContain(nameof(AppSettings.CapsLockHoldDelay), names);
Assert.DoesNotContain(nameof(AppSettings.Current), names);
// What they are derived from, on the other hand, has to be stored
Assert.Contains(nameof(AppSettings.DurationMilliseconds), names);
Assert.Contains(nameof(AppSettings.CapsLockHoldMilliseconds), names);
Assert.Contains(nameof(AppSettings.AtCursor), names);
Assert.Contains(nameof(AppSettings.AtCaret), names);
Assert.Contains(nameof(AppSettings.FixedPoint), names);
}
[Fact]
public void Settings_report_changes_as_INotifyPropertyChanged()
{
Assert.IsAssignableFrom<INotifyPropertyChanged>(new AppSettings());
}
public static TheoryData<string> WritableProperties()
{
var data = new TheoryData<string>();
foreach (string name in WritablePropertyNames())
{
data.Add(name);
}
return data;
}
/// <summary>Names of the settings the user is able to change.</summary>
/// <remarks>
/// The modes are handed out rather than assigned — they are filled in place — so
/// they are not among these. What is inside them is checked by
/// <see cref="PopupModeSettingsTests"/>.
/// </remarks>
internal static IEnumerable<string> WritablePropertyNames() =>
typeof(AppSettings).GetProperties()
.Where(property => property.CanWrite)
.Select(property => property.Name);
// A value guaranteed to differ from the current one: each kind of setting
// has its own way of differing
private static void SetDifferentValue(AppSettings settings, string propertyName)
{
PropertyInfo property = typeof(AppSettings).GetProperty(propertyName)!;
object? current = property.GetValue(settings);
object next = current switch
{
string text => text + "-other",
double number => number + 1,
bool flag => !flag,
Color color => Color.FromArgb((byte)(color.R + 1), color.G, color.B),
Enum value => NextEnumValue(value),
_ => throw new NotSupportedException($"Unknown kind of setting: {property.PropertyType}"),
};
property.SetValue(settings, next);
}
private static object NextEnumValue(Enum current)
{
Array values = Enum.GetValues(current.GetType());
foreach (object? value in values)
{
if (!Equals(value, current))
{
return value!;
}
}
throw new NotSupportedException($"{current.GetType()} has a single value");
}
}
@@ -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
{
@@ -0,0 +1,289 @@
using System.ComponentModel;
using System.Drawing;
using System.Reflection;
using System.Text.Json;
using CursorLang.Core.Models;
namespace CursorLang.Core.Tests.Models;
/// <summary>
/// The settings of a single placement mode: what the popup looks like there and how
/// far from its anchor it sits.
/// </summary>
public sealed class PopupModeSettingsTests
{
[Fact]
public void The_cursor_mode_starts_out_below_and_right_of_the_pointer()
{
var mode = new CursorModeSettings();
Assert.Equal(AnchorSide.BottomRight, mode.Side);
Assert.Equal(16, mode.Offset);
}
/// <summary>
/// Next to the caret the popup goes beside it, and to the right by default.
/// </summary>
/// <remarks>
/// Above or below the caret is where the next line of the text is, so those sides
/// are not on offer at all — the type has the two of them and no more.
/// </remarks>
[Fact]
public void The_caret_mode_starts_out_to_the_right_of_the_caret()
{
var mode = new CaretModeSettings();
Assert.Equal(CaretSide.Right, mode.Side);
Assert.Equal(16, mode.Offset);
Assert.Equal([CaretSide.Left, CaretSide.Right], Enum.GetValues<CaretSide>());
}
[Theory]
[InlineData(CaretSide.Left, AnchorSide.Left)]
[InlineData(CaretSide.Right, AnchorSide.Right)]
public void The_side_of_the_caret_lines_the_popup_up_with_it(CaretSide side, AnchorSide expected)
{
var mode = new CaretModeSettings { Side = side };
Assert.Equal(expected, mode.Anchor);
}
[Fact]
public void A_changed_side_of_the_caret_is_a_change_of_what_it_lines_up_with()
{
var mode = new CaretModeSettings();
List<string?> changed = [];
mode.PropertyChanged += (_, e) => changed.Add(e.PropertyName);
mode.Side = CaretSide.Left;
Assert.Contains(nameof(CaretModeSettings.Anchor), changed);
}
/// <summary>
/// The fixed point starts out in the middle of the monitor, where there is no edge
/// to stand off from.
/// </summary>
[Fact]
public void The_fixed_point_starts_out_in_the_middle_with_no_offset()
{
var mode = new FixedPointModeSettings();
Assert.Equal(ScreenPosition.Center, mode.Position);
Assert.Equal(0, mode.Offset);
Assert.False(mode.IsAtAnEdge);
}
// A setting that is not shown must not be one that still applies
[Fact]
public void Moving_the_fixed_point_to_the_middle_drops_the_offset()
{
var mode = new FixedPointModeSettings { Position = ScreenPosition.TopLeft, Offset = 40 };
Assert.True(mode.IsAtAnEdge);
mode.Position = ScreenPosition.Center;
Assert.Equal(0, mode.Offset);
Assert.False(mode.IsAtAnEdge);
}
[Theory]
[InlineData(ScreenPosition.TopLeft)]
[InlineData(ScreenPosition.Top)]
[InlineData(ScreenPosition.TopRight)]
[InlineData(ScreenPosition.BottomLeft)]
[InlineData(ScreenPosition.Bottom)]
[InlineData(ScreenPosition.BottomRight)]
public void Away_from_the_middle_the_offset_is_kept(ScreenPosition position)
{
var mode = new FixedPointModeSettings { Position = position, Offset = 40 };
Assert.Equal(40, mode.Offset);
Assert.True(mode.IsAtAnEdge);
}
[Fact]
public void A_changed_place_of_the_fixed_point_is_a_change_of_having_an_edge()
{
var mode = new FixedPointModeSettings();
List<string?> changed = [];
mode.PropertyChanged += (_, e) => changed.Add(e.PropertyName);
mode.Position = ScreenPosition.Bottom;
Assert.Contains(nameof(FixedPointModeSettings.IsAtAnEdge), changed);
}
[Theory]
[MemberData(nameof(WritableProperties), typeof(CursorModeSettings))]
[MemberData(nameof(WritableProperties), typeof(CaretModeSettings))]
[MemberData(nameof(WritableProperties), typeof(FixedPointModeSettings))]
public void A_changed_setting_is_announced_to_subscribers(Type type, string propertyName)
{
PopupModeSettings mode = Create(type);
List<string?> changed = [];
mode.PropertyChanged += (_, e) => changed.Add(e.PropertyName);
SetDifferentValue(mode, propertyName);
Assert.Contains(propertyName, changed);
}
[Theory]
[MemberData(nameof(WritableProperties), typeof(CursorModeSettings))]
[MemberData(nameof(WritableProperties), typeof(CaretModeSettings))]
[MemberData(nameof(WritableProperties), typeof(FixedPointModeSettings))]
public void Writing_the_same_value_leaves_subscribers_alone(Type type, string propertyName)
{
PopupModeSettings mode = Create(type);
PropertyInfo property = type.GetProperty(propertyName)!;
List<string?> changed = [];
mode.PropertyChanged += (_, e) => changed.Add(e.PropertyName);
property.SetValue(mode, property.GetValue(mode));
Assert.Empty(changed);
}
[Fact]
public void The_cursor_mode_takes_another_one_over_whole()
{
var mode = new CursorModeSettings();
var other = new CursorModeSettings
{
Side = AnchorSide.Left,
Offset = 3,
FontSize = 41,
Opacity = 0.25,
BackgroundColor = Color.FromArgb(0x01, 0x02, 0x03),
ForegroundColor = Color.FromArgb(0x04, 0x05, 0x06),
};
mode.CopyFrom(other);
Assert.Equal(AnchorSide.Left, mode.Side);
Assert.Equal(3, mode.Offset);
Assert.Equal(41, mode.FontSize);
Assert.Equal(0.25, mode.Opacity);
Assert.Equal(Color.FromArgb(0x01, 0x02, 0x03), mode.BackgroundColor);
Assert.Equal(Color.FromArgb(0x04, 0x05, 0x06), mode.ForegroundColor);
}
[Fact]
public void The_caret_mode_takes_another_one_over_whole()
{
var mode = new CaretModeSettings();
var other = new CaretModeSettings { Side = CaretSide.Left, Offset = 2, FontSize = 15 };
mode.CopyFrom(other);
Assert.Equal(CaretSide.Left, mode.Side);
Assert.Equal(2, mode.Offset);
Assert.Equal(15, mode.FontSize);
}
[Fact]
public void The_fixed_point_takes_another_one_over_whole()
{
var mode = new FixedPointModeSettings();
var other = new FixedPointModeSettings
{
Position = ScreenPosition.Bottom,
Offset = 120,
FontSize = 60,
Opacity = 0.5,
BackgroundColor = Color.FromArgb(0x07, 0x08, 0x09),
ForegroundColor = Color.FromArgb(0x0A, 0x0B, 0x0C),
};
mode.CopyFrom(other);
Assert.Equal(ScreenPosition.Bottom, mode.Position);
Assert.Equal(120, mode.Offset);
Assert.Equal(60, mode.FontSize);
Assert.Equal(0.5, mode.Opacity);
Assert.Equal(Color.FromArgb(0x07, 0x08, 0x09), mode.BackgroundColor);
Assert.Equal(Color.FromArgb(0x0A, 0x0B, 0x0C), mode.ForegroundColor);
}
// Taking over a mode that sits in the middle takes its lack of an offset over too
[Fact]
public void Taking_over_a_fixed_point_in_the_middle_leaves_no_offset()
{
var mode = new FixedPointModeSettings { Position = ScreenPosition.Top, Offset = 32 };
mode.CopyFrom(new FixedPointModeSettings());
Assert.Equal(ScreenPosition.Center, mode.Position);
Assert.Equal(0, mode.Offset);
}
// What a mode works out from what it stores is not stored itself
[Fact]
public void Derived_values_of_a_mode_stay_out_of_the_file()
{
List<string> caret = Stored(new CaretModeSettings());
List<string> fixedPoint = Stored(new FixedPointModeSettings());
Assert.DoesNotContain(nameof(CaretModeSettings.Anchor), caret);
Assert.DoesNotContain(nameof(FixedPointModeSettings.IsAtAnEdge), fixedPoint);
Assert.Contains(nameof(CaretModeSettings.Side), caret);
Assert.Contains(nameof(FixedPointModeSettings.Position), fixedPoint);
}
[Fact]
public void A_mode_reports_changes_as_INotifyPropertyChanged()
{
Assert.IsAssignableFrom<INotifyPropertyChanged>(new CursorModeSettings());
Assert.IsAssignableFrom<INotifyPropertyChanged>(new CaretModeSettings());
Assert.IsAssignableFrom<INotifyPropertyChanged>(new FixedPointModeSettings());
}
public static TheoryData<Type, string> WritableProperties(Type type)
{
var data = new TheoryData<Type, string>();
foreach (string name in WritablePropertyNames(type))
{
data.Add(type, name);
}
return data;
}
/// <summary>Names of the settings of a mode the user is able to change.</summary>
internal static IEnumerable<string> WritablePropertyNames(Type type) =>
type.GetProperties().Where(property => property.CanWrite).Select(property => property.Name);
private static PopupModeSettings Create(Type type) => (PopupModeSettings)Activator.CreateInstance(type)!;
private static List<string> Stored<TMode>(TMode mode) where TMode : PopupModeSettings
{
using JsonDocument document = JsonSerializer.SerializeToDocument(mode);
return [.. document.RootElement.EnumerateObject().Select(property => property.Name)];
}
// A value guaranteed to differ from the current one: each kind of setting has its
// own way of differing
private static void SetDifferentValue(PopupModeSettings mode, string propertyName)
{
PropertyInfo property = mode.GetType().GetProperty(propertyName)!;
object next = property.GetValue(mode) switch
{
double number => number + 1,
Color color => Color.FromArgb((byte)(color.R + 1), color.G, color.B),
Enum value => Enum.GetValues(value.GetType())
.Cast<object>()
.First(other => !Equals(other, value)),
var other => throw new NotSupportedException($"Unknown kind of setting: {other?.GetType()}"),
};
property.SetValue(mode, next);
}
}
@@ -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");
@@ -81,43 +79,15 @@ 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.
/// The version is put into the title by the app, so the place for it has to
/// be there in both languages: the title is the only place it is shown, and a
/// translation without the placeholder would quietly drop it.
/// </summary>
[Fact]
public void Every_key_from_the_settings_window_markup_exists_in_the_resources()
public void The_title_of_the_window_has_room_for_the_version()
{
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.
/// </summary>
[Fact]
public void The_string_about_an_available_update_has_room_for_the_version()
{
Assert.Contains("{0}", Resources.GetString("UpdateAvailable", English), StringComparison.Ordinal);
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()));
Assert.Contains("{0}", Resources.GetString("SettingsTitle", English), StringComparison.Ordinal);
Assert.Contains("{0}", Resources.GetString("SettingsTitle", Russian), StringComparison.Ordinal);
}
public static TheoryData<string> EnumKeys()
@@ -139,6 +109,11 @@ public sealed partial class StringsTests
data.Add(key);
}
foreach (string key in EnumKeysOf<CaretSide>())
{
data.Add(key);
}
foreach (string key in EnumKeysOf<ScreenPosition>())
{
data.Add(key);
@@ -164,15 +139,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.
@@ -71,7 +71,7 @@ public sealed class CapsLockSwitchCoordinatorTests
int startsBefore = hotkey.StartCalls;
int stopsBefore = hotkey.StopCalls;
settings.FontSize = 44;
settings.Current.FontSize = 44;
settings.CapsLockHoldMilliseconds = 700;
Assert.Equal(startsBefore, hotkey.StartCalls);
@@ -1,9 +1,9 @@
using System.Collections.Concurrent;
using CursorLang.Models;
using CursorLang.Services;
using CursorLang.Tests.Infrastructure;
using CursorLang.Core.Models;
using CursorLang.Core.Services;
using CursorLang.Tests.Shared;
namespace CursorLang.Tests.Services;
namespace CursorLang.Core.Tests.Services;
/// <summary>
/// Watching the layout of the foreground window. The test supplies what the
@@ -48,9 +48,9 @@ public sealed class KeyboardLayoutServiceTests
using var world = new World { LocaleId = English };
KeyboardLayoutService service = world.CreateService();
Sta.Run(service.Start);
Pump.Run(service.Start);
world.LocaleId = Russian;
Sta.Run(service.Poll);
Pump.Run(service.Poll);
LayoutChangedEventArgs change = Assert.Single(world.Changes);
Assert.Equal(LayoutChangeReason.UserSwitched, change.Reason);
@@ -63,11 +63,11 @@ public sealed class KeyboardLayoutServiceTests
using var world = new World { LocaleId = English };
KeyboardLayoutService service = world.CreateService();
Sta.Run(service.Start);
Pump.Run(service.Start);
world.ForegroundWindow = SecondWindow;
world.LocaleId = Russian;
Sta.Run(service.Poll);
Pump.Run(service.Poll);
LayoutChangedEventArgs change = Assert.Single(world.Changes);
Assert.Equal(LayoutChangeReason.ApplicationSwitched, change.Reason);
@@ -80,10 +80,10 @@ public sealed class KeyboardLayoutServiceTests
using var world = new World { LocaleId = English };
KeyboardLayoutService service = world.CreateService();
Sta.Run(service.Start);
Pump.Run(service.Start);
world.ForegroundWindow = SecondWindow;
Sta.Run(service.Poll);
Pump.Run(service.Poll);
Assert.Empty(world.Changes);
}
@@ -94,11 +94,11 @@ public sealed class KeyboardLayoutServiceTests
using var world = new World { LocaleId = Russian };
KeyboardLayoutService service = world.CreateService();
Sta.Run(service.Start);
Pump.Run(service.Start);
for (int i = 0; i < 5; i++)
{
Sta.Run(service.Poll);
Pump.Run(service.Poll);
}
Assert.Empty(world.Changes);
@@ -111,17 +111,17 @@ public sealed class KeyboardLayoutServiceTests
using var world = new World { LocaleId = English };
KeyboardLayoutService service = world.CreateService();
Sta.Run(service.Start);
Pump.Run(service.Start);
world.ForegroundWindow = IntPtr.Zero;
world.LocaleId = Russian;
Sta.Run(service.Poll);
Pump.Run(service.Poll);
Assert.Empty(world.Changes);
// And the layout was not remembered: the change is noticed once a window is back
world.ForegroundWindow = FirstWindow;
Sta.Run(service.Poll);
Pump.Run(service.Poll);
Assert.Single(world.Changes);
}
@@ -132,12 +132,11 @@ public sealed class KeyboardLayoutServiceTests
using var world = new World { LocaleId = English };
KeyboardLayoutService service = world.CreateService();
Sta.Run(service.Start);
Pump.Run(service.Start);
world.LocaleId = Russian;
Sta.Run(service.Poll);
Sta.Run(service.Poll);
Sta.Run(service.Poll);
Pump.Run(service.Poll);
Pump.Run(service.Poll);
Assert.Single(world.Changes);
}
@@ -149,8 +148,8 @@ public sealed class KeyboardLayoutServiceTests
KeyboardLayoutService service = world.CreateService();
world.LocaleId = Russian;
Sta.Run(service.Start);
Sta.Run(service.Poll);
Pump.Run(service.Start);
Pump.Run(service.Poll);
// Start remembered the layout that was in place at that moment
Assert.Empty(world.Changes);
@@ -162,10 +161,10 @@ public sealed class KeyboardLayoutServiceTests
using var world = new World { LocaleId = English };
KeyboardLayoutService service = world.CreateService(TimeSpan.FromMilliseconds(15));
Sta.Run(service.Start);
Pump.Run(service.Start);
world.LocaleId = Russian;
Sta.WaitFor(() => !world.Changes.IsEmpty, "the timer noticed the layout change");
Pump.WaitFor(() => !world.Changes.IsEmpty, "the timer noticed the layout change");
}
[Fact]
@@ -174,11 +173,11 @@ public sealed class KeyboardLayoutServiceTests
using var world = new World { LocaleId = English };
KeyboardLayoutService service = world.CreateService(TimeSpan.FromMilliseconds(15));
Sta.Run(service.Start);
Sta.Run(service.Stop);
Pump.Run(service.Start);
Pump.Run(service.Stop);
world.LocaleId = Russian;
Sta.Pause(TimeSpan.FromMilliseconds(120));
Pump.Pause(TimeSpan.FromMilliseconds(120));
Assert.Empty(world.Changes);
}
@@ -189,11 +188,11 @@ public sealed class KeyboardLayoutServiceTests
using var world = new World { LocaleId = English };
KeyboardLayoutService service = world.CreateService(TimeSpan.FromMilliseconds(15));
Sta.Run(service.Start);
Sta.Run(service.Dispose);
Pump.Run(service.Start);
Pump.Run(service.Dispose);
world.LocaleId = Russian;
Sta.Pause(TimeSpan.FromMilliseconds(120));
Pump.Pause(TimeSpan.FromMilliseconds(120));
Assert.Empty(world.Changes);
}
@@ -204,34 +203,34 @@ public sealed class KeyboardLayoutServiceTests
using var world = new World { LocaleId = English };
KeyboardLayoutService service = world.CreateService(TimeSpan.FromMilliseconds(15));
Sta.Run(service.Start);
Sta.Run(service.Stop);
Sta.Run(service.Start);
Pump.Run(service.Start);
Pump.Run(service.Stop);
Pump.Run(service.Start);
world.LocaleId = Russian;
Sta.WaitFor(() => !world.Changes.IsEmpty, "the watch resumed");
Pump.WaitFor(() => !world.Changes.IsEmpty, "the watch resumed");
}
// The ordinary service asks Windows itself about the layout
[Fact]
public void The_service_can_work_with_the_real_system()
{
KeyboardLayoutService service = Sta.Run(() =>
KeyboardLayoutService service = Pump.Run(() =>
new KeyboardLayoutService(new KeyboardLayoutOptions { PollInterval = TimeSpan.FromHours(1) }));
try
{
Sta.Run(service.Start);
Sta.Run(service.Poll);
Pump.Run(service.Start);
Pump.Run(service.Poll);
Assert.InRange(service.Current.LocaleId, 1, 0xFFFF);
Sta.Run(service.Stop);
Pump.Run(service.Stop);
}
finally
{
Sta.Run(service.Dispose);
Pump.Run(service.Dispose);
}
}
@@ -260,13 +259,12 @@ public sealed class KeyboardLayoutServiceTests
internal KeyboardLayoutService CreateService(TimeSpan? pollInterval = null)
{
// An hour between ticks means the poll only runs when the test asks for it
var options = new KeyboardLayoutOptions
{
PollInterval = pollInterval ?? TimeSpan.FromHours(1),
};
_service = Sta.Run(() => new KeyboardLayoutService(
_service = Pump.Run(() => new KeyboardLayoutService(
options,
() => ForegroundWindow,
() => LocaleId,
@@ -281,7 +279,7 @@ public sealed class KeyboardLayoutServiceTests
{
if (_service is not null)
{
Sta.Run(_service.Dispose);
Pump.Run(_service.Dispose);
}
}
}
@@ -1,8 +1,8 @@
using CursorLang.Models;
using CursorLang.Services;
using CursorLang.Tests.Infrastructure;
using CursorLang.Core.Models;
using CursorLang.Core.Services;
using CursorLang.Tests.Shared;
namespace CursorLang.Tests.Services;
namespace CursorLang.Core.Tests.Services;
/// <summary>
/// The link between watching the layout and showing the tooltip.
@@ -1,9 +1,9 @@
using System.ComponentModel;
using System.Globalization;
using System.Windows.Data;
using CursorLang.Services;
using CursorLang.Core.Services;
namespace CursorLang.Tests.Services;
namespace CursorLang.Core.Tests.Services;
public sealed class LocalizationServiceTests
{
@@ -1,8 +1,8 @@
using CursorLang.Interop;
using CursorLang.Models;
using CursorLang.Services;
using CursorLang.Core.Interop;
using CursorLang.Core.Models;
using CursorLang.Core.Services;
namespace CursorLang.Tests.Services;
namespace CursorLang.Core.Tests.Services;
/// <summary>
/// The placement maths for the tooltip. This is the easiest place to get a sign
@@ -1,9 +1,9 @@
using CursorLang.Models;
using CursorLang.Services;
using CursorLang.Tests.Infrastructure;
using CursorLang.Core.Models;
using CursorLang.Core.Services;
using CursorLang.Tests.Shared;
using Microsoft.Win32;
namespace CursorLang.Tests.Services;
namespace CursorLang.Core.Tests.Services;
/// <summary>
/// Startup of a build unpacked into a folder: a value under the Run key. The tests
@@ -0,0 +1,115 @@
using System.Collections.Concurrent;
using CursorLang.Core.Services;
using CursorLang.Tests.Shared;
namespace CursorLang.Core.Tests.Services;
/// <summary>
/// The one thing the agent says to the settings window: quit with me.
/// </summary>
/// <remarks>
/// The event names here are the tests' own. The application's name is fixed, and a test
/// listening on it would answer for a settings window someone is using — or, signalling,
/// close it.
/// </remarks>
public sealed class SettingsCloseSignalTests
{
[Fact]
public void The_request_reaches_the_settings_window()
{
string suffix = UniqueSuffix();
var signal = new SettingsCloseSignal(suffix);
ConcurrentQueue<EventArgs> requests = new();
signal.CloseRequested += (_, e) => requests.Enqueue(e);
try
{
signal.Listen();
Assert.True(RequestApart(suffix), "the request found nobody listening");
Pump.WaitFor(() => !requests.IsEmpty, "the settings window got the request to close");
}
finally
{
signal.Dispose();
}
}
// The usual case: the user quits from the tray with no settings window on the screen
[Fact]
public void A_request_with_no_settings_window_open_passes_without_consequence()
{
Assert.False(RequestApart(UniqueSuffix()));
}
// The window has closed on its own, and the process is on its way out anyway
[Fact]
public void No_request_arrives_after_the_window_is_gone()
{
string suffix = UniqueSuffix();
var signal = new SettingsCloseSignal(suffix);
ConcurrentQueue<EventArgs> requests = new();
signal.CloseRequested += (_, e) => requests.Enqueue(e);
signal.Listen();
signal.Dispose();
RequestApart(suffix);
Pump.Pause(TimeSpan.FromMilliseconds(80));
Assert.Empty(requests);
}
[Fact]
public void Listening_twice_leaves_one_listener()
{
string suffix = UniqueSuffix();
var signal = new SettingsCloseSignal(suffix);
var requests = 0;
signal.CloseRequested += (_, _) => Interlocked.Increment(ref requests);
try
{
signal.Listen();
signal.Listen();
Assert.True(RequestApart(suffix));
Pump.WaitFor(() => Volatile.Read(ref requests) > 0, "the request arrived");
Pump.Pause(TimeSpan.FromMilliseconds(80));
Assert.Equal(1, Volatile.Read(ref requests));
}
finally
{
signal.Dispose();
}
}
[Fact]
public void Closing_without_listening_passes_without_consequence()
{
var signal = new SettingsCloseSignal(UniqueSuffix());
signal.Dispose();
signal.Dispose();
}
// Every test gets a namespace of kernel objects of its own
private static string UniqueSuffix() => "." + Guid.NewGuid().ToString("N");
/// <summary>
/// Asks for the close the way the agent does it — from another process, and here
/// from another thread, which is as foreign as a test can get.
/// </summary>
private static bool RequestApart(string suffix)
{
var heard = false;
Pump.RunApart(() => heard = SettingsCloseSignal.RequestClose(suffix));
return heard;
}
}
@@ -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,14 +23,14 @@ 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();
});
Assert.Equal(AppTheme.System, settings.Theme);
Assert.Equal(20, settings.FontSize);
Assert.Equal(20, settings.Current.FontSize);
}
[Theory]
@@ -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,30 +67,30 @@ public sealed class SettingsServiceTests
{
using var folder = new TempFolder();
Sta.Run(() =>
Pump.Run(() =>
{
using SettingsService service = Create(folder);
AppSettings settings = service.Load();
settings.FontSize = 42;
settings.Theme = AppTheme.Dark;
settings.PlacementMode = PopupPlacementMode.AtCaret;
settings.BackgroundColor = Color.FromRgb(0x11, 0x22, 0x33);
settings.Current.FontSize = 42;
settings.Current.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();
});
Assert.Equal(42, restored.FontSize);
Assert.Equal(42, restored.Current.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.Current.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.Current.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;
settings.Current.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);
settings.Current.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.Current.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, """{"AtCursor": {"FontSize": 31, "BackgroundColor": "#FF102030"}}""");
service.Reload();
Assert.Equal(31, settings.Current.FontSize);
Assert.Equal(Color.FromArgb(0x10, 0x20, 0x30), settings.Current.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.Current.FontSize = 44;
File.WriteAllText(path, "not json at all");
service.Reload();
Assert.Equal(44, settings.Current.FontSize);
});
}
@@ -168,11 +264,12 @@ public sealed class SettingsServiceTests
{
using var folder = new TempFolder();
Sta.Run(() =>
Pump.Run(() =>
{
SettingsService service = Create(folder);
AppSettings settings = service.Load();
settings.FontSize = 27;
service.TrackChanges();
settings.Current.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));
settings.Current.FontSize = 99;
Pump.Pause(TimeSpan.FromMilliseconds(60));
Assert.Equal(afterDispose, File.ReadAllText(path));
});
@@ -211,15 +309,15 @@ public sealed class SettingsServiceTests
string inherited = folder.File("inherited.json");
string own = folder.File("settings.json");
File.WriteAllText(inherited, """{"FontSize": 31, "Language": "ru"}""");
File.WriteAllText(inherited, """{"AtCursor": {"FontSize": 31}, "Language": "ru"}""");
AppSettings settings = Sta.Run(() =>
AppSettings settings = Pump.Run(() =>
{
using SettingsService service = new(own, inherited, SaveDelay);
return service.Load();
});
Assert.Equal(31, settings.FontSize);
Assert.Equal(31, settings.Current.FontSize);
Assert.Equal("ru", settings.Language);
// What was taken over is pinned to its new place at once rather than on the first edit
@@ -233,11 +331,11 @@ public sealed class SettingsServiceTests
{
using var folder = new TempFolder();
string inherited = folder.File("inherited.json");
string original = """{"FontSize": 31}""";
string original = """{"AtCursor": {"FontSize": 31}}""";
File.WriteAllText(inherited, original);
Sta.Run(() =>
Pump.Run(() =>
{
using SettingsService service = new(folder.File("settings.json"), inherited, SaveDelay);
_ = service.Load();
@@ -253,16 +351,16 @@ public sealed class SettingsServiceTests
string own = folder.File("settings.json");
string inherited = folder.File("inherited.json");
File.WriteAllText(own, """{"FontSize": 12}""");
File.WriteAllText(inherited, """{"FontSize": 31}""");
File.WriteAllText(own, """{"AtCursor": {"FontSize": 12}}""");
File.WriteAllText(inherited, """{"AtCursor": {"FontSize": 31}}""");
AppSettings settings = Sta.Run(() =>
AppSettings settings = Pump.Run(() =>
{
using SettingsService service = new(own, inherited, SaveDelay);
return service.Load();
});
Assert.Equal(12, settings.FontSize);
Assert.Equal(12, settings.Current.FontSize);
}
// Outside a package both paths are the same, so there is nothing to take over
@@ -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,28 +386,28 @@ 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();
});
Assert.Equal(20, settings.FontSize);
Assert.Equal(20, settings.Current.FontSize);
}
[Fact]
public void Settings_with_unknown_fields_are_still_read()
{
using var folder = new TempFolder();
File.WriteAllText(folder.File("settings.json"), """{"FontSize": 15, "SomethingNew": true}""");
File.WriteAllText(folder.File("settings.json"), """{"AtCursor": {"FontSize": 15}, "SomethingNew": true}""");
AppSettings settings = Sta.Run(() =>
AppSettings settings = Pump.Run(() =>
{
using SettingsService service = Create(folder);
return service.Load();
});
Assert.Equal(15, settings.FontSize);
Assert.Equal(15, settings.Current.FontSize);
}
[Theory]
@@ -319,15 +417,15 @@ public sealed class SettingsServiceTests
public void A_colour_is_read_from_its_usual_notation(string stored, byte r, byte g, byte b)
{
using var folder = new TempFolder();
File.WriteAllText(folder.File("settings.json"), $$"""{"BackgroundColor": {{stored}}}""");
File.WriteAllText(folder.File("settings.json"), $$"""{"AtCursor": {"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.Current.BackgroundColor);
}
[Theory]
@@ -337,15 +435,15 @@ public sealed class SettingsServiceTests
public void An_unintelligible_colour_becomes_black(string stored)
{
using var folder = new TempFolder();
File.WriteAllText(folder.File("settings.json"), $$"""{"BackgroundColor": {{stored}}}""");
File.WriteAllText(folder.File("settings.json"), $$"""{"AtCursor": {"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.Current.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,12 +473,12 @@ 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();
settings.FontSize = 18;
settings.Current.FontSize = 18;
service.Save();
});
@@ -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();
@@ -436,12 +534,113 @@ public sealed class SettingsServiceTests
}
}
// The modes are sections of their own, and every setting of a mode has to reach the
// file inside its own section
[Theory]
[InlineData(nameof(AppSettings.AtCursor), typeof(CursorModeSettings))]
[InlineData(nameof(AppSettings.AtCaret), typeof(CaretModeSettings))]
[InlineData(nameof(AppSettings.FixedPoint), typeof(FixedPointModeSettings))]
public void Every_setting_of_a_mode_reaches_its_section_of_the_file(string section, Type mode)
{
using var folder = new TempFolder();
Pump.Run(() =>
{
using SettingsService service = Create(folder);
_ = service.Load();
service.Save();
});
using JsonDocument document = JsonDocument.Parse(File.ReadAllText(folder.File("settings.json")));
Assert.True(document.RootElement.TryGetProperty(section, out JsonElement stored));
foreach (string name in PopupModeSettingsTests.WritablePropertyNames(mode))
{
Assert.True(stored.TryGetProperty(name, out _), $"{section}.{name}");
}
}
/// <summary>
/// The modes are stored apart: what is set up in one is still there after a trip
/// through the file and the other two.
/// </summary>
[Fact]
public void Each_mode_keeps_its_own_settings()
{
using var folder = new TempFolder();
Pump.Run(() =>
{
using SettingsService service = Create(folder);
AppSettings settings = service.Load();
settings.AtCursor.Side = AnchorSide.TopLeft;
settings.AtCursor.FontSize = 14;
settings.AtCaret.Side = CaretSide.Left;
settings.AtCaret.FontSize = 28;
settings.AtCaret.ForegroundColor = Color.FromArgb(0x0A, 0x0B, 0x0C);
settings.FixedPoint.Position = ScreenPosition.Top;
settings.FixedPoint.Offset = 96;
settings.FixedPoint.Opacity = 0.4;
service.Save();
});
AppSettings restored = Pump.Run(() =>
{
using SettingsService service = Create(folder);
return service.Load();
});
Assert.Equal(AnchorSide.TopLeft, restored.AtCursor.Side);
Assert.Equal(14, restored.AtCursor.FontSize);
Assert.Equal(CaretSide.Left, restored.AtCaret.Side);
Assert.Equal(28, restored.AtCaret.FontSize);
Assert.Equal(Color.FromArgb(0x0A, 0x0B, 0x0C), restored.AtCaret.ForegroundColor);
Assert.Equal(ScreenPosition.Top, restored.FixedPoint.Position);
Assert.Equal(96, restored.FixedPoint.Offset);
Assert.Equal(0.4, restored.FixedPoint.Opacity);
// What was left alone stays at its default rather than following a neighbour
Assert.Equal(0.9, restored.AtCursor.Opacity);
}
// A change inside a mode is a change of the settings: the file has to follow the
// sliders of the look as well as the ones above them
[Fact]
public void A_change_inside_a_mode_saves_itself()
{
using var folder = new TempFolder();
string path = folder.File("settings.json");
Pump.Run(() =>
{
using SettingsService service = Create(folder);
AppSettings settings = service.Load();
service.TrackChanges();
settings.AtCaret.FontSize = 37;
Pump.WaitFor(() => File.Exists(path), "the settings were written by the timer");
});
using JsonDocument document = JsonDocument.Parse(File.ReadAllText(path));
Assert.Equal(
37,
document.RootElement
.GetProperty(nameof(AppSettings.AtCaret))
.GetProperty(nameof(PopupModeSettings.FontSize))
.GetDouble());
}
// An ordinary run picks the storage place itself: a package keeps settings
// of its own, a separate install keeps them in the user profile
[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);
@@ -0,0 +1,98 @@
using CursorLang.Core.Services;
using CursorLang.Tests.Shared;
namespace CursorLang.Core.Tests.Services;
/// <summary>
/// Telling a launch by Windows apart from a launch by the user: the first one goes
/// to the tray without a window, the second one is what the window is for.
/// </summary>
public sealed class StartupLaunchTests
{
[Fact]
public void A_launch_by_the_user_carries_no_argument()
{
Assert.False(StartupLaunch.HasArgument([]));
Assert.False(StartupLaunch.IsAutomatic([]));
}
[Fact]
public void The_startup_entry_says_so_in_the_command_line()
{
Assert.True(StartupLaunch.HasArgument([StartupLaunch.Argument]));
Assert.True(StartupLaunch.IsAutomatic([StartupLaunch.Argument]));
}
// The argument does not have to come first: Windows may put its own
// alongside it one day
[Fact]
public void The_argument_is_looked_for_among_the_others()
{
Assert.True(StartupLaunch.HasArgument(["--whatever", StartupLaunch.Argument]));
}
[Fact]
public void The_case_of_the_argument_does_not_matter()
{
Assert.True(StartupLaunch.HasArgument([StartupLaunch.Argument.ToUpperInvariant()]));
}
[Fact]
public void Anything_else_is_a_launch_by_the_user()
{
Assert.False(StartupLaunch.HasArgument(["--startupp", "startup", "-startup"]));
}
/// <summary>
/// The command written into the registry carries the argument: that is the whole
/// point of the argument.
/// </summary>
[Fact]
public void The_startup_entry_is_written_with_the_argument()
{
using var agent = new StagedAgentExecutable();
string? command = RegistryStartup.GetCommand();
Assert.NotNull(command);
Assert.EndsWith(StartupLaunch.Argument, command, StringComparison.Ordinal);
// And the path itself stays quoted: it has spaces in it more often than not
Assert.StartsWith("\"", command, StringComparison.Ordinal);
}
/// <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());
@@ -0,0 +1,129 @@
using CursorLang.Core.Threading;
using CursorLang.Tests.Shared;
namespace CursorLang.Core.Tests.Threading;
/// <summary>
/// The timer the agent has instead of a dispatcher timer.
/// </summary>
/// <remarks>
/// It ticks on the message loop, so everything here runs on the pump thread — a timer
/// started on one thread and awaited on another would never be seen to fire.
/// </remarks>
public sealed class MessageTimerTests
{
[Fact]
public void A_started_timer_ticks()
{
int ticks = 0;
Pump.Run(() =>
{
using var timer = new MessageTimer();
timer.Interval = TimeSpan.FromMilliseconds(15);
timer.Tick += (_, _) => ticks++;
timer.Start();
Pump.WaitFor(() => ticks > 0, "the timer ticked");
});
}
[Fact]
public void A_timer_keeps_ticking_until_it_is_stopped()
{
var ticks = 0;
Pump.Run(() =>
{
using var timer = new MessageTimer();
timer.Interval = TimeSpan.FromMilliseconds(15);
timer.Tick += (_, _) => ticks++;
timer.Start();
Pump.WaitFor(() => ticks >= 3, "the timer ticked more than once");
});
}
// A stopped countdown does not go off, however long the loop runs afterwards
[Fact]
public void A_stopped_timer_does_not_tick()
{
var ticks = 0;
Pump.Run(() =>
{
using var timer = new MessageTimer();
timer.Interval = TimeSpan.FromMilliseconds(10);
timer.Tick += (_, _) => ticks++;
timer.Start();
timer.Stop();
Pump.Pause(TimeSpan.FromMilliseconds(80));
});
Assert.Equal(0, ticks);
}
// Restarting means from zero, so a countdown kept short by repeated restarts
// never reaches its end
[Fact]
public void Restarting_begins_the_countdown_again()
{
var ticks = 0;
Pump.Run(() =>
{
using var timer = new MessageTimer();
timer.Interval = TimeSpan.FromMilliseconds(60);
timer.Tick += (_, _) => ticks++;
for (var i = 0; i < 6; i++)
{
timer.Start();
Pump.Pause(TimeSpan.FromMilliseconds(20));
}
Assert.Equal(0, ticks);
Pump.WaitFor(() => ticks > 0, "left alone, the timer reached its end");
});
}
[Fact]
public void Closing_the_timer_ends_the_ticking()
{
var ticks = 0;
Pump.Run(() =>
{
var timer = new MessageTimer { Interval = TimeSpan.FromMilliseconds(15) };
timer.Tick += (_, _) => ticks++;
timer.Start();
Pump.WaitFor(() => ticks > 0, "the timer ticked");
timer.Dispose();
int seen = ticks;
Pump.Pause(TimeSpan.FromMilliseconds(80));
Assert.Equal(seen, ticks);
});
}
[Fact]
public void A_timer_that_was_never_started_says_so()
{
Pump.Run(() =>
{
using var timer = new MessageTimer();
Assert.False(timer.IsRunning);
timer.Start();
Assert.True(timer.IsRunning);
timer.Stop();
Assert.False(timer.IsRunning);
});
}
}
+10
View File
@@ -0,0 +1,10 @@
using System.Runtime.CompilerServices;
// Core keeps its interop and its arithmetic internal, as it always did. The two
// processes built on it are not outside consumers but the other halves of the same
// application, so they are let in rather than the surface being widened for them.
[assembly: InternalsVisibleTo("CursorLang")]
[assembly: InternalsVisibleTo("CursorLang.Settings")]
[assembly: InternalsVisibleTo("CursorLang.Core.Tests")]
[assembly: InternalsVisibleTo("CursorLang.Agent.Tests")]
[assembly: InternalsVisibleTo("CursorLang.Settings.Tests")]
+40
View File
@@ -0,0 +1,40 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0-windows10.0.19041.0</TargetFramework>
<SupportedOSPlatformVersion>10.0.17763.0</SupportedOSPlatformVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<RootNamespace>CursorLang.Core</RootNamespace>
<AssemblyName>CursorLang.Core</AssemblyName>
<PlatformTarget>AnyCPU</PlatformTarget>
<RuntimeIdentifiers>win-x64</RuntimeIdentifiers>
<Version>1.0.0</Version>
<AssemblyVersion>1.0.0.0</AssemblyVersion>
<FileVersion>1.0.0.0</FileVersion>
<Product>CursorLang</Product>
<Company>Aleksandr Neichev</Company>
<Description>Shared part of CursorLang: models, settings, layout tracking</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);
@@ -104,7 +113,7 @@ internal static class CaretNative
private static PopupWindowNative.Rect? TryGetAutomationCaret()
{
// A hung application must not hang the popup along with it: we wait for the
// answer for a limited time, otherwise we show the popup at the cursor
// answer for a limited time, otherwise the popup goes to the fixed point
Task<PopupWindowNative.Rect?> query = Task.Run(QueryAutomationCaret);
return query.Wait(AutomationTimeout) ? query.Result : null;
}
@@ -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 fixed point
private static PopupWindowNative.Rect? TryGetAutomationCaret() => null;
#endif
// The system caret: its coordinates come relative to the window that owns it
private static PopupWindowNative.Rect? TryGetSystemCaret(ForegroundInputNative.GuiThreadInfo info)
@@ -1,6 +1,6 @@
using System.Runtime.InteropServices;
namespace CursorLang.Interop;
namespace CursorLang.Core.Interop;
/// <summary>
/// Input details of the active application: which window holds keyboard focus
@@ -1,6 +1,6 @@
using System.Runtime.InteropServices;
namespace CursorLang.Interop;
namespace CursorLang.Core.Interop;
/// <summary>
/// The right to bring a window to the foreground.
@@ -1,6 +1,6 @@
using System.Runtime.InteropServices;
namespace CursorLang.Interop;
namespace CursorLang.Core.Interop;
/// <summary>
/// Win32 API for reading the layout of the active application.
@@ -1,6 +1,6 @@
using System.Runtime.InteropServices;
namespace CursorLang.Interop;
namespace CursorLang.Core.Interop;
/// <summary>
/// A system keyboard hook (WH_KEYBOARD_LL): sees key presses in every application
@@ -1,6 +1,6 @@
using System.Runtime.InteropServices;
namespace CursorLang.Interop;
namespace CursorLang.Core.Interop;
/// <summary>
/// Answers whether the application runs from an MSIX package.
@@ -1,6 +1,6 @@
using System.Runtime.InteropServices;
namespace CursorLang.Interop;
namespace CursorLang.Core.Interop;
/// <summary>
/// Win32 API for the popup window: styles, positioning near the cursor
@@ -21,12 +21,6 @@ internal static class PopupWindowNative
[DllImport("user32.dll")]
private static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll", SetLastError = true)]
private static extern int GetWindowLong(IntPtr hWnd, int nIndex);
[DllImport("user32.dll")]
private static extern int SetWindowLong(IntPtr hWnd, int nIndex, int dwNewLong);
[DllImport("user32.dll")]
private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter,
int X, int Y, int cx, int cy, uint uFlags);
@@ -61,12 +55,6 @@ internal static class PopupWindowNative
public uint dwFlags;
}
private const int GWL_EXSTYLE = -20;
// The window does not take focus away from the active application
private const int WS_EX_NOACTIVATE = 0x08000000;
// And does not show up in Alt+Tab
private const int WS_EX_TOOLWINDOW = 0x00000080;
private const uint SWP_NOSIZE = 0x0001;
private const uint SWP_NOZORDER = 0x0004;
private const uint SWP_NOACTIVATE = 0x0010;
@@ -80,16 +68,6 @@ internal static class PopupWindowNative
return cursor;
}
/// <summary>
/// The popup shows up on top of other applications, so it must neither
/// activate itself nor steal input focus from the active window.
/// </summary>
internal static void MakePassive(IntPtr hWnd)
{
int exStyle = GetWindowLong(hWnd, GWL_EXSTYLE);
SetWindowLong(hWnd, GWL_EXSTYLE, exStyle | WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW);
}
/// <summary>
/// Moves the window to a screen point without changing its size or z-order.
/// The coordinates are physical pixels: monitors have different scaling, while
@@ -101,21 +79,6 @@ internal static class PopupWindowNative
SetWindowPos(hWnd, IntPtr.Zero, x, y, 0, 0, SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE);
}
/// <summary>
/// Sets the window position and size in physical pixels.
/// </summary>
/// <remarks>
/// The size is set this way rather than through Width/Height: on the first show the
/// window is still subject to the system minimum window size (SM_CXMIN×SM_CYMIN)
/// and the popup comes out noticeably larger than its text. By the time this is
/// called the window is already shown and has become a popup window, which that
/// restriction does not apply to.
/// </remarks>
internal static void SetBounds(IntPtr hWnd, int x, int y, int width, int height)
{
SetWindowPos(hWnd, IntPtr.Zero, x, y, width, height, SWP_NOZORDER | SWP_NOACTIVATE);
}
/// <summary>The scale of the monitor the point is on (1.0 at 96 DPI).</summary>
internal static double GetScaleAt(Point point) =>
GetScaleOf(MonitorFromPoint(point, MONITOR_DEFAULTTONEAREST));
+145
View File
@@ -0,0 +1,145 @@
using System.ComponentModel;
using System.Text.Json.Serialization;
using CommunityToolkit.Mvvm.ComponentModel;
namespace CursorLang.Core.Models;
/// <summary>
/// The application settings, as the settings window writes them to settings.json.
/// </summary>
/// <remarks>
/// Everything about the popup — the side, the offset, the font size, the opacity and
/// both colours — belongs to a placement mode rather than to the application, and is
/// kept in a <see cref="PopupModeSettings"/> of its own for each of them. So the
/// settings of a mode are still there after a trip through the other two.
///
/// 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>
[ObservableProperty]
private string _language = "en";
/// <summary>The look of the settings window.</summary>
[ObservableProperty]
private AppTheme _theme = AppTheme.System;
/// <summary>Where the popup is shown: at the cursor, at the caret or at a fixed point.</summary>
[ObservableProperty]
private PopupPlacementMode _placementMode = PopupPlacementMode.AtCursor;
/// <summary>How long the popup stays on screen, in milliseconds.</summary>
[ObservableProperty]
private double _durationMilliseconds = 500;
/// <summary>
/// Intercept Caps Lock and switch the layout with it instead of changing the case.
/// Off by default: the application must not change the behaviour of the system
/// until it is asked to.
/// </summary>
[ObservableProperty]
private bool _useCapsLockHotkey;
/// <summary>
/// After how long a Caps Lock hold cancels the switch, in milliseconds.
/// </summary>
[ObservableProperty]
private double _capsLockHoldMilliseconds = 300;
public AppSettings()
{
AtCursor.PropertyChanged += OnModeChanged;
AtCaret.PropertyChanged += OnModeChanged;
FixedPoint.PropertyChanged += OnModeChanged;
}
/// <summary>The <see cref="PopupPlacementMode.AtCursor"/> mode.</summary>
/// <remarks>
/// The modes are handed out rather than replaced — the instance is the same for the
/// life of the settings, so a binding and a subscription to it hold. That is what
/// the creation handling is for: without it the deserializer would want a setter,
/// and reading the file would swap the object everything is bound to.
/// </remarks>
[JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)]
public CursorModeSettings AtCursor { get; } = new();
/// <summary>The <see cref="PopupPlacementMode.AtCaret"/> mode.</summary>
[JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)]
public CaretModeSettings AtCaret { get; } = new();
/// <summary>The <see cref="PopupPlacementMode.FixedPoint"/> mode.</summary>
[JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)]
public FixedPointModeSettings FixedPoint { get; } = new();
/// <summary>The settings of the mode currently chosen.</summary>
/// <remarks>
/// The settings window binds the look through here, and the popup asks for it here
/// too: what is on screen is always the settings of the mode in force. A change of
/// <see cref="PlacementMode"/> announces this as changed, so the bindings follow.
/// </remarks>
[JsonIgnore]
public PopupModeSettings Current => PlacementMode switch
{
PopupPlacementMode.AtCaret => AtCaret,
PopupPlacementMode.FixedPoint => FixedPoint,
_ => AtCursor,
};
/// <summary><see cref="DurationMilliseconds"/> as a <see cref="TimeSpan"/>.</summary>
[JsonIgnore]
public TimeSpan Duration => TimeSpan.FromMilliseconds(DurationMilliseconds);
/// <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. The modes
/// are filled the same way, and for the same reason.
/// </remarks>
public void CopyFrom(AppSettings other)
{
Language = other.Language;
Theme = other.Theme;
PlacementMode = other.PlacementMode;
DurationMilliseconds = other.DurationMilliseconds;
UseCapsLockHotkey = other.UseCapsLockHotkey;
CapsLockHoldMilliseconds = other.CapsLockHoldMilliseconds;
AtCursor.CopyFrom(other.AtCursor);
AtCaret.CopyFrom(other.AtCaret);
FixedPoint.CopyFrom(other.FixedPoint);
}
// The mode in force decides what the look means, so a switch of the mode is a
// change of everything bound through Current
partial void OnPlacementModeChanged(PopupPlacementMode value) => OnPropertyChanged(nameof(Current));
/// <summary>
/// Passes a change made inside a mode on as a change of the settings.
/// </summary>
/// <remarks>
/// A mode is an object of its own, so an edit in it is not an edit of this one as
/// far as <see cref="INotifyPropertyChanged"/> goes. The settings window binds
/// through the path and hears the mode itself, but the service that writes the file
/// listens to the settings alone — and without this a font size dragged in the
/// window would never reach the disk. The name says where the change happened:
/// "AtCaret.FontSize".
/// </remarks>
private void OnModeChanged(object? sender, PropertyChangedEventArgs e) =>
OnPropertyChanged($"{NameOfMode(sender)}.{e.PropertyName}");
private string NameOfMode(object? mode) =>
ReferenceEquals(mode, AtCaret) ? nameof(AtCaret)
: ReferenceEquals(mode, FixedPoint) ? nameof(FixedPoint)
: nameof(AtCursor);
}
@@ -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.
+141
View File
@@ -0,0 +1,141 @@
using System.Drawing;
using System.Text.Json.Serialization;
using CommunityToolkit.Mvvm.ComponentModel;
namespace CursorLang.Core.Models;
/// <summary>
/// The settings of one placement mode: where the popup goes and how it looks there.
/// </summary>
/// <remarks>
/// Every mode keeps a set of its own. The popup next to the caret sits inside a text
/// being read and is wanted small and quiet; the one in the corner of the monitor is
/// looked for on purpose and is wanted large. A look shared by the modes meant setting
/// it up again after every switch — and switching modes to see what they do undid what
/// had just been set up.
///
/// The colours are <see cref="System.Drawing.Color"/> for the same reason as in
/// <see cref="AppSettings"/>: the agent reads them and must stay clear of WPF.
/// </remarks>
public abstract partial class PopupModeSettings : ObservableObject
{
/// <summary>The distance from whatever the popup is placed by, in WPF units.</summary>
[ObservableProperty]
private double _offset = 16;
/// <summary>The size of the layout name in the popup, in WPF units.</summary>
[ObservableProperty]
private double _fontSize = 20;
/// <summary>The popup opacity: 1.0 is fully opaque.</summary>
[ObservableProperty]
private double _opacity = 0.9;
/// <summary>The fill of the popup. The opacity is set by <see cref="Opacity"/>.</summary>
[ObservableProperty]
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.FromArgb(0xFF, 0xFF, 0xFF, 0xFF);
/// <summary>
/// Takes the offset and the look of another mode over. What tells the modes apart —
/// the side, the place on the monitor — is copied by the mode itself.
/// </summary>
protected void CopyLookFrom(PopupModeSettings other)
{
Offset = other.Offset;
FontSize = other.FontSize;
Opacity = other.Opacity;
BackgroundColor = other.BackgroundColor;
ForegroundColor = other.ForegroundColor;
}
}
/// <summary>The mode that puts the popup next to the mouse cursor.</summary>
public sealed partial class CursorModeSettings : PopupModeSettings
{
/// <summary>The corner or the side of the cursor the popup is put on.</summary>
[ObservableProperty]
private AnchorSide _side = AnchorSide.BottomRight;
/// <summary>Takes the values of another mode of the same kind over.</summary>
public void CopyFrom(CursorModeSettings other)
{
CopyLookFrom(other);
Side = other.Side;
}
}
/// <summary>
/// The mode that puts the popup next to the caret of the active input field.
/// </summary>
public sealed partial class CaretModeSettings : PopupModeSettings
{
/// <summary>The side of the caret the popup is put on: only left or right.</summary>
[ObservableProperty]
private CaretSide _side = CaretSide.Right;
/// <summary>
/// The side as the layout arithmetic wants it. Both of them line the popup up with
/// the caret rather than putting it above or below.
/// </summary>
[JsonIgnore]
public AnchorSide Anchor => Side == CaretSide.Left ? AnchorSide.Left : AnchorSide.Right;
/// <summary>Takes the values of another mode of the same kind over.</summary>
public void CopyFrom(CaretModeSettings other)
{
CopyLookFrom(other);
Side = other.Side;
}
// The side decides what the popup is lined up against, so it is a change of that too
partial void OnSideChanged(CaretSide value) => OnPropertyChanged(nameof(Anchor));
}
/// <summary>
/// The mode that puts the popup at a fixed place of the monitor holding the active
/// window. Here <see cref="PopupModeSettings.Offset"/> is the distance from the edge
/// of the monitor.
/// </summary>
/// <remarks>
/// In the middle of the monitor there is no edge to keep a distance from, so the offset
/// is zero there and the settings window does not offer it. Choosing the middle puts it
/// back to zero rather than remembering it for later: a setting that is not shown must
/// not be one that still applies.
/// </remarks>
public sealed partial class FixedPointModeSettings : PopupModeSettings
{
/// <summary>The place on the monitor the popup is put at.</summary>
[ObservableProperty]
private ScreenPosition _position = ScreenPosition.Center;
public FixedPointModeSettings()
{
// The middle is the default, and it has no edge to stand off from
Offset = 0;
}
/// <summary>Whether the popup is put at an edge of the monitor rather than in its middle.</summary>
[JsonIgnore]
public bool IsAtAnEdge => Position != ScreenPosition.Center;
/// <summary>Takes the values of another mode of the same kind over.</summary>
public void CopyFrom(FixedPointModeSettings other)
{
CopyLookFrom(other);
Position = other.Position;
}
partial void OnPositionChanged(ScreenPosition value)
{
OnPropertyChanged(nameof(IsAtAnEdge));
if (value == ScreenPosition.Center)
{
Offset = 0;
}
}
}
@@ -1,4 +1,4 @@
namespace CursorLang.Models;
namespace CursorLang.Core.Models;
/// <summary>
/// How the place for the popup is chosen.
@@ -10,7 +10,8 @@ public enum PopupPlacementMode
/// <summary>
/// Next to the caret in the active input field. When the application does not
/// report its position, the popup is shown at the mouse cursor.
/// report its position, the popup is shown as in <see cref="FixedPoint"/> — at the
/// place and with the look that mode is set up with.
/// </summary>
AtCaret,
@@ -19,7 +20,7 @@ public enum PopupPlacementMode
}
/// <summary>
/// Which side of the cursor or the caret to show the popup on.
/// Which side of the cursor to show the popup on.
/// </summary>
public enum AnchorSide
{
@@ -31,6 +32,21 @@ public enum AnchorSide
BottomRight,
}
/// <summary>
/// Which side of the caret to show the popup on.
/// </summary>
/// <remarks>
/// Only the two sides, unlike <see cref="AnchorSide"/>. The caret stands in a line of
/// text being written, and above or below it is exactly where the next line is: the
/// popup would cover what is being read. To the side it covers nothing, and the line
/// it lines up with is the one the caret is in.
/// </remarks>
public enum CaretSide
{
Left,
Right,
}
/// <summary>
/// The place on the monitor for the <see cref="PopupPlacementMode.FixedPoint"/> mode.
/// </summary>
@@ -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:
@@ -59,7 +59,13 @@
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="SettingsTitle" xml:space="preserve">
<value>CursorLang — Settings</value>
<value>CursorLang {0} — Settings</value>
</data>
<data name="TrayMenuSettings" xml:space="preserve">
<value>Settings</value>
</data>
<data name="TrayMenuExit" xml:space="preserve">
<value>Exit</value>
</data>
<data name="SectionInterface" xml:space="preserve">
<value>Interface</value>
@@ -79,8 +85,8 @@
<data name="AppTheme_Dark" xml:space="preserve">
<value>Dark</value>
</data>
<data name="SectionPlacement" xml:space="preserve">
<value>Placement</value>
<data name="SectionPopup" xml:space="preserve">
<value>Layout popup</value>
</data>
<data name="PlacementModeLabel" xml:space="preserve">
<value>Mode</value>
@@ -115,6 +121,12 @@
<data name="AnchorSide_Right" xml:space="preserve">
<value>Right</value>
</data>
<data name="CaretSide_Left" xml:space="preserve">
<value>Left</value>
</data>
<data name="CaretSide_Right" xml:space="preserve">
<value>Right</value>
</data>
<data name="CursorOffsetLabel" xml:space="preserve">
<value>Offset</value>
</data>
@@ -145,8 +157,8 @@
<data name="ScreenMarginLabel" xml:space="preserve">
<value>Margin from screen edge</value>
</data>
<data name="SectionAppearance" xml:space="preserve">
<value>Appearance</value>
<data name="PopupLookPerModeHint" xml:space="preserve">
<value>The look below is remembered for the chosen placement mode alone.</value>
</data>
<data name="FontSizeLabel" xml:space="preserve">
<value>Font size</value>
@@ -199,46 +211,4 @@
<data name="StartupLockedHint" xml:space="preserve">
<value>Startup for this app is now controlled by Windows: Settings — Apps — Startup.</value>
</data>
<data name="SectionUpdates" xml:space="preserve">
<value>Updates</value>
</data>
<data name="CurrentVersionLabel" xml:space="preserve">
<value>Installed version</value>
</data>
<data name="CheckUpdatesButton" xml:space="preserve">
<value>Check for updates</value>
</data>
<data name="UpdateAutoCheck" xml:space="preserve">
<value>Check for updates at startup</value>
</data>
<data name="UpdateChecking" xml:space="preserve">
<value>Checking for updates…</value>
</data>
<data name="UpdateUpToDate" xml:space="preserve">
<value>The installed version is the latest one.</value>
</data>
<data name="UpdateAvailable" xml:space="preserve">
<value>Version {0} is available.</value>
</data>
<data name="UpdateDownloading" xml:space="preserve">
<value>Downloading the package…</value>
</data>
<data name="UpdateReady" xml:space="preserve">
<value>The package has been downloaded.</value>
</data>
<data name="UpdateFailed" xml:space="preserve">
<value>Could not reach the releases. Check the connection and try again.</value>
</data>
<data name="DownloadUpdateButton" xml:space="preserve">
<value>Download</value>
</data>
<data name="InstallUpdateButton" xml:space="preserve">
<value>Install</value>
</data>
<data name="ReleasePageLink" xml:space="preserve">
<value>Release page</value>
</data>
<data name="UpdateInstallHint" xml:space="preserve">
<value>Windows will show the package and ask to confirm the installation. The new version takes over once the app is restarted.</value>
</data>
</root>
@@ -59,7 +59,13 @@
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="SettingsTitle" xml:space="preserve">
<value>CursorLang — Настройки</value>
<value>CursorLang {0} — Настройки</value>
</data>
<data name="TrayMenuSettings" xml:space="preserve">
<value>Настройки</value>
</data>
<data name="TrayMenuExit" xml:space="preserve">
<value>Выход</value>
</data>
<data name="SectionInterface" xml:space="preserve">
<value>Интерфейс</value>
@@ -79,8 +85,8 @@
<data name="AppTheme_Dark" xml:space="preserve">
<value>Тёмная</value>
</data>
<data name="SectionPlacement" xml:space="preserve">
<value>Расположение</value>
<data name="SectionPopup" xml:space="preserve">
<value>Подсказка о раскладке</value>
</data>
<data name="PlacementModeLabel" xml:space="preserve">
<value>Режим</value>
@@ -115,6 +121,12 @@
<data name="AnchorSide_Right" xml:space="preserve">
<value>Справа</value>
</data>
<data name="CaretSide_Left" xml:space="preserve">
<value>Слева</value>
</data>
<data name="CaretSide_Right" xml:space="preserve">
<value>Справа</value>
</data>
<data name="CursorOffsetLabel" xml:space="preserve">
<value>Отступ</value>
</data>
@@ -145,8 +157,8 @@
<data name="ScreenMarginLabel" xml:space="preserve">
<value>Отступ от края экрана</value>
</data>
<data name="SectionAppearance" xml:space="preserve">
<value>Внешний вид</value>
<data name="PopupLookPerModeHint" xml:space="preserve">
<value>Оформление ниже запоминается только для выбранного режима.</value>
</data>
<data name="FontSizeLabel" xml:space="preserve">
<value>Размер шрифта</value>
@@ -199,46 +211,4 @@
<data name="StartupLockedHint" xml:space="preserve">
<value>Автозапуском этого приложения теперь распоряжается Windows: «Параметры» — «Приложения» — «Автозагрузка».</value>
</data>
<data name="SectionUpdates" xml:space="preserve">
<value>Обновления</value>
</data>
<data name="CurrentVersionLabel" xml:space="preserve">
<value>Установленная версия</value>
</data>
<data name="CheckUpdatesButton" xml:space="preserve">
<value>Проверить обновления</value>
</data>
<data name="UpdateAutoCheck" xml:space="preserve">
<value>Проверять обновления при запуске</value>
</data>
<data name="UpdateChecking" xml:space="preserve">
<value>Идёт проверка обновлений…</value>
</data>
<data name="UpdateUpToDate" xml:space="preserve">
<value>Установлена последняя версия.</value>
</data>
<data name="UpdateAvailable" xml:space="preserve">
<value>Доступна версия {0}.</value>
</data>
<data name="UpdateDownloading" xml:space="preserve">
<value>Идёт загрузка пакета…</value>
</data>
<data name="UpdateReady" xml:space="preserve">
<value>Пакет скачан.</value>
</data>
<data name="UpdateFailed" xml:space="preserve">
<value>Не удалось обратиться к выпускам. Проверьте подключение и повторите попытку.</value>
</data>
<data name="DownloadUpdateButton" xml:space="preserve">
<value>Скачать</value>
</data>
<data name="InstallUpdateButton" xml:space="preserve">
<value>Установить</value>
</data>
<data name="ReleasePageLink" xml:space="preserve">
<value>Страница выпуска</value>
</data>
<data name="UpdateInstallHint" xml:space="preserve">
<value>Windows покажет пакет и попросит подтвердить установку. Новая версия начнёт работать после перезапуска приложения.</value>
</data>
</root>
@@ -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;
}
}
+37
View File
@@ -0,0 +1,37 @@
using System.Reflection;
using System.Runtime.InteropServices;
using CursorLang.Core.Interop;
using Windows.ApplicationModel;
namespace CursorLang.Core.Services;
/// <summary>
/// The version of the running application.
/// </summary>
/// <remarks>
/// The same application runs from a package and from a folder, and the two keep
/// their version in different places. The answer is computed once: it cannot
/// change while the process lives.
/// </remarks>
public static class AppVersion
{
public static Version Current { get; } = Detect();
private static Version Detect()
{
if (PackageIdentityNative.IsPackaged)
{
try
{
PackageVersion version = Package.Current.Id.Version;
return new Version(version.Major, version.Minor, version.Build, version.Revision);
}
catch (Exception e) when (e is COMException or InvalidOperationException)
{
// The package was built without a version in the manifest — the assembly version is left
}
}
return Assembly.GetEntryAssembly()?.GetName().Version ?? new Version(0, 0, 0, 0);
}
}
@@ -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,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,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,8 +1,8 @@
using System.Windows.Threading;
using CursorLang.Interop;
using CursorLang.Models;
using CursorLang.Core.Interop;
using CursorLang.Core.Models;
using CursorLang.Core.Threading;
namespace CursorLang.Services;
namespace CursorLang.Core.Services;
/// <summary>
/// The settings of layout tracking.
@@ -23,10 +23,13 @@ public sealed class KeyboardLayoutOptions
/// (ITfLanguageProfileNotifySink) report only language changes inside our own process
/// — both options were tried and did not work. One tick is three Win32 calls reading
/// data from kernel memory.
///
/// The timer ticks on the message loop of whatever thread starts it, the same as a
/// dispatcher timer did: the agent has a plain Win32 loop and no dispatcher to offer.
/// </remarks>
public sealed class KeyboardLayoutService : IKeyboardLayoutService, IDisposable
{
private readonly DispatcherTimer _pollTimer;
private readonly MessageTimer _pollTimer;
private readonly Func<IntPtr> _getForegroundWindow;
private readonly Func<int> _getActiveLocaleId;
private readonly Action _requestNextLayout;
@@ -57,7 +60,7 @@ public sealed class KeyboardLayoutService : IKeyboardLayoutService, IDisposable
_getActiveLocaleId = getActiveLocaleId;
_requestNextLayout = requestNextLayout;
_pollTimer = new DispatcherTimer { Interval = options.PollInterval };
_pollTimer = new MessageTimer { Interval = options.PollInterval };
_pollTimer.Tick += OnTick;
}
@@ -78,8 +81,8 @@ public sealed class KeyboardLayoutService : IKeyboardLayoutService, IDisposable
public void Dispose()
{
_pollTimer.Stop();
_pollTimer.Tick -= OnTick;
_pollTimer.Dispose();
}
private void OnTick(object? sender, EventArgs e) => Poll();
@@ -1,6 +1,6 @@
using CursorLang.Models;
using CursorLang.Core.Models;
namespace CursorLang.Services;
namespace CursorLang.Core.Services;
/// <summary>
/// Ties layout tracking to showing the popup.
@@ -1,18 +1,29 @@
using System.Globalization;
using System.Resources;
using System.Windows.Data;
using CommunityToolkit.Mvvm.ComponentModel;
namespace CursorLang.Services;
namespace CursorLang.Core.Services;
/// <summary>
/// Takes the strings from the resources and, when the language changes, asks WPF to
/// re-read every binding.
/// </summary>
/// <remarks>
/// Both processes use it: the settings window for its whole interface, the agent for
/// the three captions of the tray menu. The theme no longer reaches that menu — Windows
/// draws it — but the language still does.
/// </remarks>
public sealed class LocalizationService : ObservableObject, ILocalizationService
{
/// <summary>
/// The name WPF reports when an indexer changes. Spelt out rather than taken from
/// <c>Binding.IndexerName</c>: that constant lives in PresentationFramework, and
/// Core is read by the agent, which does not load WPF.
/// </summary>
public const string IndexerName = "Item[]";
private static readonly ResourceManager Resources =
new("CursorLang.Resources.Strings", typeof(App).Assembly);
new("CursorLang.Core.Resources.Strings", typeof(LocalizationService).Assembly);
private CultureInfo _culture = CultureInfo.GetCultureInfo("en");
@@ -41,7 +52,7 @@ public sealed class LocalizationService : ObservableObject, ILocalizationService
// We report a change of the indexer: that is how every binding of the
// {Binding Localization[Key]} kind updates, that is, all the interface text
OnPropertyChanged(Binding.IndexerName);
OnPropertyChanged(IndexerName);
}
}
}
@@ -1,7 +1,7 @@
using CursorLang.Interop;
using CursorLang.Models;
using CursorLang.Core.Interop;
using CursorLang.Core.Models;
namespace CursorLang.Services;
namespace CursorLang.Core.Services;
/// <summary>
/// Computes the screen point to show the popup at.
@@ -1,9 +1,8 @@
using System.IO;
using System.Security;
using CursorLang.Models;
using CursorLang.Core.Models;
using Microsoft.Win32;
namespace CursorLang.Services;
namespace CursorLang.Core.Services;
/// <summary>
/// Startup for a build that is not a package: a value under the Run key.
@@ -112,9 +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>
private static string? GetCommand() =>
Environment.ProcessPath is { Length: > 0 } path ? $"\"{path}\"" : null;
/// <remarks>
/// 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() =>
AgentExecutable.AgentPath is { Length: > 0 } path
? $"\"{path}\" {StartupLaunch.Argument}"
: null;
}
@@ -0,0 +1,98 @@
namespace CursorLang.Core.Services;
/// <summary>
/// Tells the settings window that the agent is quitting and it is to close with it.
/// </summary>
/// <remarks>
/// The one thing the agent says to the settings window, and the mirror image of
/// <see cref="SettingsSignal"/>. "Exit" in the tray menu means the application is done
/// with, and a settings window left alone on the screen after it is a window belonging
/// to nothing: the tray icon it was opened from is gone, and closing it would be the
/// user's only remaining move.
///
/// A named event rather than a window message, because the agent has no handle to send
/// one to: the settings window lives in a process the agent starts and deliberately
/// does not keep hold of. The name has no <c>Global</c> prefix, so it lives in the
/// session namespace — same reasoning as <see cref="SingleInstanceGate"/>, and the same
/// consequence: with fast user switching each user's halves talk to their own.
///
/// Only the settings window creates the object; the agent opens what is already there
/// and stays silent when there is nothing. Were it the other way round, the request
/// would sit in an auto-reset event waiting for the next settings window to open and
/// close it the moment it did.
/// </remarks>
internal sealed class SettingsCloseSignal : IDisposable
{
private const string EventName = "CursorLang.CloseSettings";
private readonly string _eventName;
private EventWaitHandle? _request;
private RegisteredWaitHandle? _wait;
/// <summary>
/// Listens on the name the two halves agree on.
/// </summary>
/// <param name="nameSuffix">
/// A namespace of its own. Empty for the application; the tests pass one so that
/// they do not answer for — or worse, close — a settings window someone is using.
/// </param>
internal SettingsCloseSignal(string nameSuffix = "") => _eventName = EventName + nameSuffix;
/// <summary>The agent asks for the window to be closed.</summary>
/// <remarks>
/// Raised on a thread pool thread, wherever the wait happened to be answered — a
/// window obeys only its own, so the handler has to get back to it.
/// </remarks>
internal event EventHandler? CloseRequested;
/// <summary>
/// Asks the settings window of this session, if one is open, to close. Silence is
/// a normal answer: most of the time the user quits with no window on the screen.
/// </summary>
/// <returns>Whether there was anybody to hear it.</returns>
internal static bool RequestClose(string nameSuffix = "")
{
if (!EventWaitHandle.TryOpenExisting(EventName + nameSuffix, out EventWaitHandle? request))
{
return false;
}
using (request)
{
return request.Set();
}
}
/// <summary>Starts waiting for the request. Called once, by the settings window.</summary>
internal void Listen()
{
if (_request is not null)
{
return;
}
_request = new EventWaitHandle(false, EventResetMode.AutoReset, _eventName);
// As in the gate: the thread pool holds the wait, there is no reason to keep a
// thread of our own for a request that may never come
_wait = ThreadPool.RegisterWaitForSingleObject(
_request,
OnCloseSignalled,
state: null,
Timeout.Infinite,
executeOnlyOnce: false);
}
public void Dispose()
{
_wait?.Unregister(null);
_wait = null;
_request?.Dispose();
_request = null;
}
private void OnCloseSignalled(object? state, bool timedOut) =>
CloseRequested?.Invoke(this, EventArgs.Empty);
}
@@ -1,24 +1,27 @@
using System.ComponentModel;
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Windows.Media;
using System.Windows.Threading;
using CursorLang.Interop;
using CursorLang.Models;
using CursorLang.Core.Interop;
using CursorLang.Core.Models;
using CursorLang.Core.Threading;
using Windows.Storage;
namespace CursorLang.Services;
namespace CursorLang.Core.Services;
/// <summary>
/// Keeps the settings in the settings.json file.
/// </summary>
/// <remarks>
/// The location of the file depends on how the application is installed. A package
/// from the Store keeps its settings in a folder of its own: Windows removes it
/// together with the application, and after the removal nothing superfluous is left
/// in the system — that is what Store applications are expected to do. A separately
/// installed application keeps its settings in %APPDATA%, as before.
/// The file is the whole of the connection between the two processes, and they use it
/// from opposite ends. The settings window calls <see cref="TrackChanges"/> and is the
/// only writer; the agent only ever reads, and re-reads when the window tells it to.
/// A second writer would mean two processes racing for one file and an edit going missing.
///
/// The location depends on how the application is installed. A package from the Store
/// keeps its settings in a folder of its own: Windows removes it together with the
/// application, and after the removal nothing superfluous is left in the system — that
/// is what Store applications are expected to do. A separately installed application
/// keeps its settings in %APPDATA%, as before.
///
/// Settings left over from a separately installed application are picked up by the
/// package on the first launch and moved over. The original file stays where it is:
@@ -35,15 +38,17 @@ public sealed class SettingsService : IDisposable
private const string FileName = "settings.json";
private readonly string _filePath;
private readonly string _inheritedFilePath;
private readonly DispatcherTimer _saveTimer;
private AppSettings? _settings;
// Sliders change their values continuously, so writing to disk
// is postponed until there is a pause in the changes
private static readonly TimeSpan SaveDelay = TimeSpan.FromMilliseconds(500);
private readonly string _filePath;
private readonly string _inheritedFilePath;
private readonly MessageTimer _saveTimer;
private AppSettings? _settings;
private bool _isTrackingChanges;
public SettingsService()
: this(
Path.Combine(GetSettingsFolder(), FileName),
@@ -62,15 +67,21 @@ public sealed class SettingsService : IDisposable
_filePath = filePath;
_inheritedFilePath = inheritedFilePath;
_saveTimer = new DispatcherTimer { Interval = saveDelay };
_saveTimer = new MessageTimer { Interval = saveDelay };
_saveTimer.Tick += OnSaveTimerTick;
}
/// <summary>
/// Reads the settings from disk or returns the default values,
/// and from then on saves any changes by itself.
/// Reads the settings from disk or returns the default values.
/// </summary>
public AppSettings Load()
/// <remarks>
/// Asking twice hands out the same instance rather than reading again. Everything
/// binds to what this returns — the window, the popup, the hook — and a second
/// instance would mean one of them editing settings nobody else can see.
/// </remarks>
public AppSettings Load() => _settings ??= ReadOrInherit();
private AppSettings ReadOrInherit()
{
AppSettings? stored = ReadFile(_filePath);
@@ -85,7 +96,6 @@ public sealed class SettingsService : IDisposable
}
_settings = stored ?? CreateDefault();
_settings.PropertyChanged += OnSettingsChanged;
// Moved settings are fixed in the new place right away rather than on the
// first edit: otherwise the application would read someone else's file every
@@ -98,6 +108,52 @@ public sealed class SettingsService : IDisposable
return _settings;
}
/// <summary>
/// Starts saving every change, after a pause. For the settings window: it is the
/// only process allowed to write.
/// </summary>
/// <remarks>
/// Reads the file if that has not happened yet. The settings window asks in exactly
/// that order — its container hands out this service first and the settings only
/// when something needs them — and a version of this that quietly did nothing
/// before the first read left the window saving nothing at all.
/// </remarks>
public void TrackChanges()
{
if (_isTrackingChanges)
{
return;
}
Load().PropertyChanged += OnSettingsChanged;
_isTrackingChanges = true;
}
/// <summary>
/// Re-reads the file. For the agent, when the settings window says it has written.
/// </summary>
/// <remarks>
/// There is nothing to wait for and nothing to debounce: the window writes the file
/// whole and moves it into place in one step, and only then says so. Nobody else
/// writes it — the agent does not watch the file, and an edit made behind the
/// application's back is not a case it is built for.
/// </remarks>
public void Reload()
{
if (_settings is not null && ReadFile(_filePath) is { } fresh)
{
_settings.CopyFrom(fresh);
}
}
/// <summary>
/// Writes the settings and tells the agent to pick them up.
/// </summary>
/// <remarks>
/// The file is written beside its destination and moved onto it, which on one
/// volume is a single step. That way the agent, which is told to re-read the moment
/// this returns, never meets a half-written file.
/// </remarks>
public void Save()
{
if (_settings is null)
@@ -105,25 +161,34 @@ public sealed class SettingsService : IDisposable
return;
}
_saveTimer.Stop();
try
{
Directory.CreateDirectory(Path.GetDirectoryName(_filePath)!);
File.WriteAllText(_filePath, JsonSerializer.Serialize(_settings, SerializerOptions));
string temporary = _filePath + ".tmp";
File.WriteAllText(temporary, JsonSerializer.Serialize(_settings, SerializerOptions));
File.Move(temporary, _filePath, overwrite: true);
}
catch (Exception e) when (e is IOException or UnauthorizedAccessException)
{
// The settings are not the kind of thing worth bringing the application down for
return;
}
SettingsSignal.NotifyAgent();
}
public void Dispose()
{
_saveTimer.Stop();
_saveTimer.Tick -= OnSaveTimerTick;
_saveTimer.Dispose();
if (_settings is not null)
if (_settings is not null && _isTrackingChanges)
{
_settings.PropertyChanged -= OnSettingsChanged;
_isTrackingChanges = false;
Save();
}
}
@@ -180,36 +245,5 @@ public sealed class SettingsService : IDisposable
_saveTimer.Start();
}
private void OnSaveTimerTick(object? sender, EventArgs e)
{
_saveTimer.Stop();
Save();
}
// Color is not serialized out of the box, and keeping it readable in the file is handy
private sealed class ColorJsonConverter : JsonConverter<Color>
{
public override Color Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
string? value = reader.GetString();
if (string.IsNullOrWhiteSpace(value))
{
return Colors.Black;
}
try
{
return (Color)ColorConverter.ConvertFromString(value)!;
}
catch (FormatException)
{
return Colors.Black;
}
}
public override void Write(Utf8JsonWriter writer, Color value, JsonSerializerOptions options)
{
writer.WriteStringValue(value.ToString());
}
}
private void OnSaveTimerTick(object? sender, EventArgs e) => Save();
}
@@ -0,0 +1,52 @@
using System.Runtime.InteropServices;
namespace CursorLang.Core.Services;
/// <summary>
/// Tells the agent that settings.json has changed.
/// </summary>
/// <remarks>
/// The message carries nothing but the fact. The temptation to send the changed values
/// along has to be resisted: the file would stop being the only source of truth, and
/// the two would part company the first time somebody edits it by hand. Nothing else
/// tells the agent — it does not watch the file — so a message that goes missing means
/// settings it does not pick up until it is restarted.
///
/// Order is what makes it safe. The settings window writes the file whole, moves it
/// into place in one step and only then signals, so by the time the agent reads there
/// is nothing half-written to read.
///
/// A registered message rather than <c>WM_APP + n</c>: the identifier is unique across
/// the system, so it cannot be confused with anything else that finds its way to that
/// window.
/// </remarks>
internal static class SettingsSignal
{
/// <summary>The window class the agent registers for its hidden window.</summary>
internal const string AgentWindowClass = "CursorLang.Agent.Window";
/// <summary>The message both sides agree on.</summary>
internal static uint Message { get; } = RegisterWindowMessage("CursorLang.SettingsChanged");
/// <summary>
/// Wakes the agent, if one is running in this session. Silence is a normal
/// answer: the settings window is perfectly usable with no agent behind it.
/// </summary>
internal static void NotifyAgent()
{
IntPtr agent = FindWindow(AgentWindowClass, null);
if (agent != IntPtr.Zero)
{
PostMessage(agent, Message, IntPtr.Zero, IntPtr.Zero);
}
}
[DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "RegisterWindowMessageW")]
private static extern uint RegisterWindowMessage(string lpString);
[DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "FindWindowW")]
private static extern IntPtr FindWindow(string lpClassName, string? lpWindowName);
[DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "PostMessageW")]
private static extern bool PostMessage(IntPtr hWnd, uint message, IntPtr wParam, IntPtr lParam);
}
@@ -1,7 +1,6 @@
using System.Windows.Threading;
using CursorLang.Interop;
using CursorLang.Core.Interop;
namespace CursorLang.Services;
namespace CursorLang.Core.Services;
/// <summary>
/// Lets only one instance of the application run: a second launch does not bring up
@@ -13,13 +12,22 @@ namespace CursorLang.Services;
/// odd picture with fast user switching: the second user would be left without the
/// application, and showing them the window of the first one is impossible anyway —
/// windows belong to a session.
///
/// Two processes use this now, and each guards its own slot: the agent so that one
/// background process runs, the settings window so that a second "Settings" from the
/// tray raises the window already open instead of a second one. Hence the name part.
/// </remarks>
public sealed class SingleInstanceGate : IDisposable
{
/// <summary>The agent's slot — one background process per session.</summary>
public const string AgentName = ".Agent";
/// <summary>The settings window's slot — one window per session.</summary>
public const string SettingsName = ".Settings";
private const string MutexName = "CursorLang.SingleInstance";
private const string ActivationEventName = "CursorLang.ActivationRequest";
private readonly Dispatcher _dispatcher = Dispatcher.CurrentDispatcher;
private readonly string _mutexName;
private readonly string _activationEventName;
@@ -28,30 +36,42 @@ 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>
/// Takes the single-instance slot. When the application is already running, asks
/// it to show itself and returns <c>false</c> — the caller is left to exit.
/// </summary>
public bool TryAcquire()
public bool TryAcquire() => TryAcquire(showRunningInstance: true);
/// <summary>
/// The same, with a say in what is to happen to the application already running.
/// </summary>
/// <param name="showRunningInstance">
/// Whether the running application is to be brought up. A launch by Windows
/// itself passes <c>false</c>: it was not asked for a window, and the
/// application already in the tray is answer enough.
/// </param>
public bool TryAcquire(bool showRunningInstance)
{
_mutex = new Mutex(initiallyOwned: false, _mutexName);
@@ -73,8 +93,12 @@ public sealed class SingleInstanceGate : IDisposable
if (!_isOwner)
{
ForegroundPermissionNative.GrantToAnyProcess();
_activationRequest.Set();
if (showRunningInstance)
{
ForegroundPermissionNative.GrantToAnyProcess();
_activationRequest.Set();
}
return false;
}
@@ -110,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);
}
+54
View File
@@ -0,0 +1,54 @@
using System.Runtime.InteropServices;
using CursorLang.Core.Interop;
using Windows.ApplicationModel;
using Windows.ApplicationModel.Activation;
namespace CursorLang.Core.Services;
/// <summary>
/// Whether Windows started the application by itself.
/// </summary>
/// <remarks>
/// A launch of its own accord ends up in the tray without a window: the user asked
/// for the application to be there when they sign in, not for a window to greet them
/// every morning. A launch by the user is another matter — the window is what they
/// clicked for.
///
/// The two builds tell the launches apart differently. A build in a folder is
/// started from the registry, and the command written there carries an argument of
/// its own — see <see cref="RegistryStartup"/>. A package has no say in its command
/// line, and Windows is asked about the activation instead.
/// </remarks>
internal static class StartupLaunch
{
/// <summary>What the registry entry adds to the path of the application.</summary>
internal const string Argument = "--startup";
/// <summary>Whether this launch is the doing of Windows rather than of the user.</summary>
internal static bool IsAutomatic(IReadOnlyList<string> arguments) =>
HasArgument(arguments) || IsStartupActivation();
/// <summary>The command line says the launch comes from the startup entry.</summary>
internal static bool HasArgument(IReadOnlyList<string> arguments) =>
arguments.Any(argument => string.Equals(argument, Argument, StringComparison.OrdinalIgnoreCase));
private static bool IsStartupActivation()
{
if (!PackageIdentityNative.IsPackaged)
{
return false;
}
try
{
return AppInstance.GetActivatedEventArgs() is { Kind: ActivationKind.StartupTask };
}
catch (Exception e) when (e is COMException or InvalidOperationException or NotSupportedException)
{
// Windows has nothing to say about the activation. A window shown when it
// was not asked for is a smaller mishap than an application that hides
// when the user has just started it
return false;
}
}
}
@@ -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.
+66
View File
@@ -0,0 +1,66 @@
using System.Runtime.InteropServices;
namespace CursorLang.Core.Threading;
/// <summary>
/// The Win32 message loop — what the application has instead of a dispatcher.
/// </summary>
/// <remarks>
/// It lives in Core rather than in the agent because the things that need a pumping
/// thread do: <see cref="MessageTimer"/> is used by the layout polling and by saving
/// the settings, and both are Core's. The settings window has a loop of its own, run
/// by WPF, and everything here works inside it just the same.
/// </remarks>
public static class MessageLoop
{
/// <summary>
/// Pumps messages until <c>WM_QUIT</c> and returns its exit code.
/// </summary>
/// <remarks>
/// A -1 from GetMessage means the window handle has already gone; going round
/// again would spin forever, so the loop gives up instead.
/// </remarks>
public static int Run()
{
while (true)
{
int result = GetMessage(out Message message, IntPtr.Zero, 0, 0);
if (result is 0 or -1)
{
return result == 0 ? (int)message.wParam : 1;
}
TranslateMessage(ref message);
DispatchMessage(ref message);
}
}
/// <summary>Asks the loop on this thread to finish.</summary>
public static void Quit(int exitCode = 0) => PostQuitMessage(exitCode);
[DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "GetMessageW")]
private static extern int GetMessage(out Message lpMsg, IntPtr hWnd, uint filterMin, uint filterMax);
[DllImport("user32.dll")]
private static extern bool TranslateMessage(ref Message lpMsg);
[DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "DispatchMessageW")]
private static extern IntPtr DispatchMessage(ref Message lpMsg);
[DllImport("user32.dll")]
private static extern void PostQuitMessage(int exitCode);
[StructLayout(LayoutKind.Sequential)]
private struct Message
{
public IntPtr hwnd;
public uint message;
public IntPtr wParam;
public IntPtr lParam;
public uint time;
public int x;
public int y;
public uint lPrivate;
}
}
+70
View File
@@ -0,0 +1,70 @@
using System.Runtime.InteropServices;
namespace CursorLang.Core.Threading;
/// <summary>
/// A timer that ticks on the message loop — the agent's stand-in for
/// <c>DispatcherTimer</c>.
/// </summary>
/// <remarks>
/// <c>SetTimer</c> with a null window binds the timer to the thread rather than to a
/// window, and <c>DispatchMessage</c> calls the callback straight from the loop. The
/// upshot is the same as with a dispatcher timer: the tick arrives on the thread that
/// owns the hook and the popup, so nothing needs marshalling and nothing races.
///
/// The callback lives in a field for the reason a hook procedure does: the only
/// reference to it is held by Win32, and a collected delegate takes the process down
/// with it at the first tick.
/// </remarks>
internal sealed class MessageTimer : IDisposable
{
/// <summary>Windows will not go below this, and pretending otherwise misleads.</summary>
private const uint MinimumIntervalMilliseconds = 10;
private readonly TimerProc _callback;
private nuint _id;
internal MessageTimer() => _callback = OnTimer;
internal event EventHandler? Tick;
internal TimeSpan Interval { get; set; } = TimeSpan.FromMilliseconds(100);
internal bool IsRunning => _id != 0;
/// <summary>Starts the timer, or restarts it from zero when it is already running.</summary>
internal void Start()
{
Stop();
var milliseconds = (uint)Math.Clamp(
Math.Round(Interval.TotalMilliseconds), MinimumIntervalMilliseconds, int.MaxValue);
_id = SetTimer(IntPtr.Zero, 0, milliseconds, _callback);
}
internal void Stop()
{
if (_id == 0)
{
return;
}
KillTimer(IntPtr.Zero, _id);
_id = 0;
}
public void Dispose() => Stop();
private void OnTimer(IntPtr window, uint message, nuint id, uint time) =>
Tick?.Invoke(this, EventArgs.Empty);
private delegate void TimerProc(IntPtr hWnd, uint message, nuint idEvent, uint time);
[DllImport("user32.dll", SetLastError = true)]
private static extern nuint SetTimer(IntPtr hWnd, nuint nIDEvent, uint uElapse, TimerProc lpTimerFunc);
[DllImport("user32.dll", SetLastError = true)]
private static extern bool KillTimer(IntPtr hWnd, nuint uIDEvent);
}
@@ -1,15 +1,17 @@
using CursorLang.Models;
using CursorLang.Services;
using CursorLang.ViewModels;
using CursorLang.Views;
using CursorLang.Core.Models;
using CursorLang.Core.Services;
using CursorLang.Settings.Services;
using CursorLang.Settings.ViewModels;
using CursorLang.Settings.Views;
using Microsoft.Extensions.DependencyInjection;
namespace CursorLang.Tests;
namespace CursorLang.Settings.Tests;
/// <summary>
/// What the container is made of. The tests cannot build the application whole
/// — it would raise windows and take the place of the single instance — but
/// checking that everything needed is declared and resolvable works without that.
/// What the container of the settings process is made of. The tests cannot build the
/// application whole — it would raise a window and take the place of the single
/// instance — but checking that everything needed is declared and resolvable works
/// without that.
/// </summary>
public sealed class AppTests
{
@@ -20,29 +22,37 @@ public sealed class AppTests
[InlineData(typeof(MainWindowPlacement))]
[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(Version))]
[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()
{
@@ -91,15 +101,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_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,16 @@
using CursorLang.Core.Models;
using CursorLang.Settings.Services;
namespace CursorLang.Settings.Tests.Infrastructure;
/// <summary>
/// A theme that paints nothing and only remembers the windows attached to it.
/// </summary>
internal sealed class FakeThemeService : IThemeService
{
public AppTheme CurrentTheme { get; set; } = AppTheme.Light;
internal List<System.Windows.Window> Registered { get; } = [];
public void Register(System.Windows.Window window) => Registered.Add(window);
}
@@ -1,7 +1,7 @@
using System.Windows;
using System.Windows.Threading;
namespace CursorLang.Tests.Infrastructure;
namespace CursorLang.Settings.Tests.Infrastructure;
/// <summary>
/// The user interface thread for the tests.
@@ -159,7 +159,7 @@ internal static class Sta
application.Resources.MergedDictionaries.Add(new ResourceDictionary
{
Source = new Uri(
"pack://application:,,,/CursorLang;component/Themes/Controls.xaml",
"pack://application:,,,/CursorLang.Settings;component/Themes/Controls.xaml",
UriKind.Absolute),
});
@@ -1,13 +1,11 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Interop;
using CursorLang.Interop;
using CursorLang.Models;
using CursorLang.Tests.Infrastructure;
using CursorLang.ViewModels;
using CursorLang.Views;
using CursorLang.Core.Interop;
using CursorLang.Settings.Interop;
using CursorLang.Settings.Tests.Infrastructure;
namespace CursorLang.Tests.Interop;
namespace CursorLang.Settings.Tests.Interop;
/// <summary>
/// Checks that need a real foreground window with an input field: the caret
@@ -59,31 +57,11 @@ public sealed class ForegroundWindowTests
Assert.Skip("The input field did not report the caret position");
}
var settings = new AppSettings
{
PlacementMode = PopupPlacementMode.AtCaret,
CaretSide = AnchorSide.BottomRight,
CaretOffset = 8,
};
PopupWindowNative.Rect field =
WindowPlacementNative.TryGetBounds(input.Handle)!.Value;
var viewModel = new LayoutPopupViewModel(settings) { ShortName = "RU" };
var popup = new LayoutPopupWindow(viewModel, settings);
try
{
popup.ShowPopup();
PopupWindowNative.Rect bounds =
WindowPlacementNative.TryGetBounds(new WindowInteropHelper(popup).Handle)!.Value;
// The tooltip landed to the right of and below the caret — as asked
Assert.True(bounds.Left >= caret.Value.Right);
Assert.True(bounds.Top >= caret.Value.Bottom);
}
finally
{
popup.Close();
}
Assert.True(CaretNative.IsInside(caret.Value, field),
"the caret reported by the input field is outside that field");
});
}
@@ -1,9 +1,10 @@
using System.Windows;
using System.Windows.Interop;
using CursorLang.Interop;
using CursorLang.Tests.Infrastructure;
using CursorLang.Core.Interop;
using CursorLang.Settings.Interop;
using CursorLang.Settings.Tests.Infrastructure;
namespace CursorLang.Tests.Interop;
namespace CursorLang.Settings.Tests.Interop;
/// <summary>
/// The Win32 wrappers: what is checked is that the calls are put together
@@ -93,23 +94,6 @@ public sealed class NativeWrappersTests
});
}
[Fact]
public void The_window_bounds_are_set_as_a_whole()
{
Sta.Run(() =>
{
using var window = new HandleWindow();
PopupWindowNative.SetBounds(window.Handle, 60, 70, 320, 240);
PopupWindowNative.Rect bounds = WindowPlacementNative.TryGetBounds(window.Handle)!.Value;
Assert.Equal(60, bounds.Left);
Assert.Equal(70, bounds.Top);
Assert.Equal(380, bounds.Right);
Assert.Equal(310, bounds.Bottom);
});
}
[Fact]
public void The_work_area_is_found_by_the_rectangle_of_the_window()
{
@@ -138,21 +122,6 @@ public sealed class NativeWrappersTests
Assert.True(work.Value.Right > work.Value.Left);
}
[Fact]
public void A_window_becomes_invisible_to_the_focus_and_the_switcher()
{
Sta.Run(() =>
{
using var window = new HandleWindow();
PopupWindowNative.MakePassive(window.Handle);
// Checked through the same wrapper: the style has to stick and not
// be reset by a repeated call
PopupWindowNative.MakePassive(window.Handle);
});
}
[Fact]
public void The_layout_of_the_foreground_window_is_read()
{
@@ -1,10 +1,11 @@
using System.Windows;
using System.Windows.Interop;
using CursorLang.Interop;
using CursorLang.Services;
using CursorLang.Tests.Infrastructure;
using CursorLang.Core.Interop;
using CursorLang.Settings.Interop;
using CursorLang.Settings.Services;
using CursorLang.Settings.Tests.Infrastructure;
namespace CursorLang.Tests.Services;
namespace CursorLang.Settings.Tests.Services;
/// <summary>
/// Placing the settings window: the centre maths and bringing the window back
@@ -3,12 +3,12 @@ using System.Windows.Controls;
using System.Windows.Interop;
using System.Windows.Media;
using System.Windows.Shapes;
using CursorLang.Models;
using CursorLang.Services;
using CursorLang.Tests.Infrastructure;
using CursorLang.Core.Models;
using CursorLang.Settings.Services;
using CursorLang.Settings.Tests.Infrastructure;
using Microsoft.Win32;
namespace CursorLang.Tests.Services;
namespace CursorLang.Settings.Tests.Services;
/// <summary>
/// The look of the windows. The palette lives in the application resources,
@@ -114,7 +114,7 @@ public sealed class ThemeServiceTests
{
using var service = new ThemeService(settings, static () => AppTheme.Dark);
settings.FontSize = 40;
settings.Current.FontSize = 40;
settings.Language = "ru";
Assert.Equal(AppTheme.Light, service.CurrentTheme);
@@ -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,9 @@
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.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.
@@ -43,7 +42,7 @@ public sealed class SettingsViewModelTests
var localization = new FakeLocalizationService();
using var viewModel = Create(settings, localization);
settings.FontSize = 30;
settings.Current.FontSize = 30;
Assert.Equal("en", localization.CurrentLanguage);
}
@@ -59,6 +58,18 @@ public sealed class SettingsViewModelTests
Assert.Equal(Enum.GetValues<ScreenPosition>(), viewModel.ScreenPositions.Select(option => option.Value));
}
// Next to the caret the popup only goes beside it: above or below is where the next
// line of the text is
[Fact]
public void The_caret_is_offered_two_sides_and_no_more()
{
using SettingsViewModel viewModel = Create();
Assert.Equal(
[CaretSide.Left, CaretSide.Right],
viewModel.CaretSides.Select(option => option.Value));
}
// A caption key is built from the type name and the value
[Fact]
public void The_option_captions_come_from_the_resources()
@@ -85,6 +96,7 @@ public sealed class SettingsViewModelTests
Assert.Same(first, viewModel.Themes[0]);
Assert.Equal("ru:AppTheme_System", first.Display);
Assert.Equal("ru:AnchorSide_TopLeft", viewModel.AnchorSides[0].Display);
Assert.Equal("ru:CaretSide_Left", viewModel.CaretSides[0].Display);
Assert.Equal("ru:ScreenPosition_TopLeft", viewModel.ScreenPositions[0].Display);
Assert.Equal("ru:PopupPlacementMode_AtCursor", viewModel.PlacementModes[0].Display);
}
@@ -114,8 +126,8 @@ public sealed class SettingsViewModelTests
var settings = new AppSettings();
using var viewModel = Create(settings);
Assert.Contains(settings.BackgroundColor, viewModel.BackgroundPalette);
Assert.Contains(settings.ForegroundColor, viewModel.TextPalette);
Assert.Contains(settings.Current.BackgroundColor, viewModel.BackgroundPalette);
Assert.Contains(settings.Current.ForegroundColor, viewModel.TextPalette);
}
[Fact]
@@ -133,7 +145,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]
@@ -272,11 +284,12 @@ public sealed class SettingsViewModelTests
private static SettingsViewModel Create(
AppSettings? settings = null,
ILocalizationService? localization = null,
IStartupService? startup = null) =>
IStartupService? startup = null,
Version? version = null) =>
new(settings ?? new AppSettings(),
localization ?? new FakeLocalizationService(),
startup ?? new FakeStartupService(),
Fake.Updates());
version ?? new Version(1, 0, 0, 0));
// The setting travels to Windows without being awaited: the window must not freeze
private static async Task WaitForStartupRequests(FakeStartupService startup, int count)
@@ -2,14 +2,16 @@ using System.Globalization;
using System.Windows;
using System.Windows.Data;
using System.Windows.Media;
using CursorLang.Models;
using CursorLang.Views;
using CursorLang.Core.Models;
using CursorLang.Settings.Views;
using DrawingColor = System.Drawing.Color;
namespace CursorLang.Tests.Views;
namespace CursorLang.Settings.Tests.Views;
/// <summary>
/// The binding converters: they decide what the settings window shows and what
/// it keeps out of sight.
/// it keeps out of sight — and they are the border between the colours the
/// settings hold, which are GDI ones, and the brushes WPF paints with.
/// </summary>
public sealed class ConvertersTests
{
@@ -85,7 +87,7 @@ public sealed class ConvertersTests
{
var converter = new ColorToHexConverter();
Assert.Equal("#0A1B2C", converter.Convert(Color.FromRgb(0x0A, 0x1B, 0x2C), typeof(string), null, Culture));
Assert.Equal("#0A1B2C", converter.Convert(DrawingColor.FromArgb(0x0A, 0x1B, 0x2C), typeof(string), null, Culture));
}
[Fact]
@@ -95,7 +97,7 @@ public sealed class ConvertersTests
Assert.Equal(
"#102030",
converter.Convert(Color.FromArgb(0x80, 0x10, 0x20, 0x30), typeof(string), null, Culture));
converter.Convert(DrawingColor.FromArgb(0x80, 0x10, 0x20, 0x30), typeof(string), null, Culture));
}
[Theory]
@@ -114,18 +116,18 @@ public sealed class ConvertersTests
{
var converter = new ColorToHexConverter();
Assert.Equal(Binding.DoNothing, converter.ConvertBack("#102030", typeof(Color), null, Culture));
Assert.Equal(Binding.DoNothing, converter.ConvertBack("#102030", typeof(DrawingColor), null, Culture));
}
[Fact]
public void A_colour_turns_into_a_brush()
{
var converter = new ColorToBrushConverter();
Color color = Color.FromRgb(0x10, 0x20, 0x30);
DrawingColor color = DrawingColor.FromArgb(0x10, 0x20, 0x30);
var brush = Assert.IsType<SolidColorBrush>(converter.Convert(color, typeof(Brush), null, Culture));
Assert.Equal(color, brush.Color);
Assert.Equal(Color.FromArgb(color.A, color.R, color.G, color.B), brush.Color);
}
[Theory]
@@ -143,9 +145,10 @@ public sealed class ConvertersTests
public void A_brush_converts_back_into_a_colour()
{
var converter = new ColorToBrushConverter();
Color color = Color.FromRgb(0x10, 0x20, 0x30);
DrawingColor color = DrawingColor.FromArgb(0x10, 0x20, 0x30);
var brush = new SolidColorBrush(Color.FromArgb(color.A, color.R, color.G, color.B));
Assert.Equal(color, converter.ConvertBack(new SolidColorBrush(color), typeof(Color), null, Culture));
Assert.Equal(color, converter.ConvertBack(brush, typeof(DrawingColor), null, Culture));
}
[Theory]
@@ -155,7 +158,7 @@ public sealed class ConvertersTests
{
var converter = new ColorToBrushConverter();
Assert.Equal(Binding.DoNothing, converter.ConvertBack(value, typeof(Color), null, Culture));
Assert.Equal(Binding.DoNothing, converter.ConvertBack(value, typeof(DrawingColor), null, Culture));
}
[Fact]
@@ -3,13 +3,15 @@ using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Data;
using System.Windows.Media;
using CursorLang.Models;
using CursorLang.Services;
using CursorLang.Tests.Infrastructure;
using CursorLang.ViewModels;
using CursorLang.Views;
using CursorLang.Core.Models;
using CursorLang.Core.Services;
using CursorLang.Settings.Services;
using CursorLang.Settings.Tests.Infrastructure;
using CursorLang.Settings.ViewModels;
using CursorLang.Settings.Views;
using CursorLang.Tests.Shared;
namespace CursorLang.Tests.Views;
namespace CursorLang.Settings.Tests.Views;
/// <summary>
/// The settings window as a whole: the markup, the bindings and the hook-up
@@ -122,7 +124,8 @@ public sealed class MainWindowTests
{
Sta.Run(() =>
{
var settings = new AppSettings { FontSize = 33 };
var settings = new AppSettings();
settings.Current.FontSize = 33;
using SettingsViewModel viewModel = CreateViewModel(settings);
Open(viewModel, window =>
@@ -131,6 +134,178 @@ public sealed class MainWindowTests
});
}
/// <summary>
/// The preview follows the mode: switching it shows the settings of the new one.
/// </summary>
/// <remarks>
/// The look is bound through the current mode, so a switch has to reach the window.
/// Bound to a mode by name, the sliders would keep showing the cursor mode whatever
/// was chosen above them.
/// </remarks>
[Fact]
public void A_switch_of_the_mode_shows_the_settings_of_that_mode()
{
Sta.Run(() =>
{
var settings = new AppSettings();
settings.AtCursor.FontSize = 24;
settings.FixedPoint.FontSize = 56;
using SettingsViewModel viewModel = CreateViewModel(settings);
Open(viewModel, window =>
{
Assert.Contains(FindAll<TextBlock>(window), text => Math.Abs(text.FontSize - 24) < 0.001);
settings.PlacementMode = PopupPlacementMode.FixedPoint;
window.UpdateLayout();
Assert.Contains(FindAll<TextBlock>(window), text => Math.Abs(text.FontSize - 56) < 0.001);
});
});
}
/// <summary>
/// In the middle of the monitor the offset from the edge cannot be set, but it stays
/// in place.
/// </summary>
/// <remarks>
/// There is no edge to stand off from there, so the setting goes grey. Taking the row
/// out of the section instead would move everything below it on every switch of the
/// place — the section is not to jump about.
/// </remarks>
[Fact]
public void In_the_middle_of_the_monitor_the_offset_from_the_edge_goes_grey()
{
Sta.Run(() =>
{
var settings = new AppSettings { PlacementMode = PopupPlacementMode.FixedPoint };
using SettingsViewModel viewModel = CreateViewModel(settings);
Open(viewModel, window =>
{
Slider offset = Assert.Single(FindSlidersBoundTo(window, "Settings.FixedPoint.Offset"));
Assert.True(offset.IsVisible);
Assert.False(offset.IsEnabled);
// And it looks the part: the template draws from brushes that know
// nothing of the state, so the style has to fade it
Assert.True(offset.Opacity < 1);
settings.FixedPoint.Position = ScreenPosition.TopRight;
window.UpdateLayout();
Assert.True(offset.IsVisible);
Assert.True(offset.IsEnabled);
Assert.Equal(1, offset.Opacity);
settings.FixedPoint.Position = ScreenPosition.Center;
window.UpdateLayout();
Assert.True(offset.IsVisible);
Assert.False(offset.IsEnabled);
Assert.True(offset.Opacity < 1);
});
});
}
// Another mode has no fixed point at all, and its offset has no place on screen
[Fact]
public void Away_from_the_fixed_point_its_offset_is_not_shown()
{
Sta.Run(() =>
{
var settings = new AppSettings { PlacementMode = PopupPlacementMode.AtCursor };
using SettingsViewModel viewModel = CreateViewModel(settings);
Open(viewModel, window =>
Assert.All(FindSlidersBoundTo(window, "Settings.FixedPoint.Offset"), slider =>
Assert.False(slider.IsVisible)));
});
}
/// <summary>
/// What the look belongs to is explained in a tooltip next to the mode.
/// </summary>
/// <remarks>
/// The sliders of the look show other numbers after a switch of the mode, and that
/// has to be explained. A line of text saying so would sit in the section forever,
/// while the question is asked once.
/// </remarks>
[Fact]
public void The_window_explains_that_the_look_belongs_to_the_mode()
{
Sta.Run(() =>
{
using SettingsViewModel viewModel = CreateViewModel();
Open(viewModel, window =>
{
// The text of a tooltip is bound but not computed until the tooltip is
// shown, so what it is bound to is what gets checked
List<TextBlock> hints =
[
.. FindAll<TextBlock>(window)
.Where(text => text.ToolTip is ToolTip tip &&
tip.Content is TextBlock content &&
PathOf(content, TextBlock.TextProperty) == "Localization[PopupLookPerModeHint]"),
];
TextBlock link = Assert.Single(hints);
Assert.Equal("en:MoreInfoLink", link.Text);
Assert.True(link.IsVisible);
});
});
}
// Beside the caret and nowhere else: the list offers the two sides it has
[Fact]
public void The_caret_is_offered_two_sides_in_the_window()
{
Sta.Run(() =>
{
var settings = new AppSettings { PlacementMode = PopupPlacementMode.AtCaret };
using SettingsViewModel viewModel = CreateViewModel(settings);
Open(viewModel, window =>
{
ComboBox sides = Assert.Single(
FindAll<ComboBox>(window),
box => BindingOperations.GetBinding(box, Selector.SelectedValueProperty)?.Path.Path
== "Settings.AtCaret.Side");
Assert.Equal(2, sides.Items.Count);
sides.SelectedValue = CaretSide.Left;
Assert.Equal(CaretSide.Left, settings.AtCaret.Side);
});
});
}
// The sliders of the look write into the mode chosen above them
[Fact]
public void The_look_is_edited_in_the_mode_that_is_chosen()
{
Sta.Run(() =>
{
var settings = new AppSettings { PlacementMode = PopupPlacementMode.AtCaret };
using SettingsViewModel viewModel = CreateViewModel(settings);
Open(viewModel, window =>
{
Slider slider = Assert.Single(FindSlidersBoundTo(window, "Settings.Current.FontSize"));
slider.Value = 44;
Assert.Equal(44, settings.AtCaret.FontSize);
Assert.Equal(20, settings.AtCursor.FontSize);
});
});
}
[Fact]
public void The_tooltip_colours_are_shown_as_swatches()
{
@@ -141,8 +316,8 @@ public sealed class MainWindowTests
// The chosen colour is shown as a swatch with a caption — in the
// same notation the settings file uses
Color chosen = viewModel.BackgroundPalette[2];
settings.BackgroundColor = chosen;
System.Drawing.Color chosen = viewModel.BackgroundPalette[2];
settings.Current.BackgroundColor = chosen;
string expected = $"#{chosen.R:X2}{chosen.G:X2}{chosen.B:X2}";
@@ -229,39 +404,44 @@ public sealed class MainWindowTests
});
}
// The version is nowhere else in the window, so the title has to carry it
[Fact]
public void The_updates_are_checked_by_the_button_in_the_window()
public void The_title_of_the_window_shows_the_version()
{
Sta.Run(() =>
{
var updates = new FakeUpdateService();
using UpdateViewModel section = CreateUpdates(updates);
using SettingsViewModel viewModel = CreateViewModel(updates: section);
var localization = new FakeLocalizationService();
localization.Strings["SettingsTitle"] = "CursorLang {0} — Settings";
Open(viewModel, window =>
{
Button check = Assert.Single(FindButtonsBoundTo(window, "Updates.CheckCommand"));
using SettingsViewModel viewModel = CreateViewModel(
localization: localization,
version: new Version(1, 2, 3, 0));
Assert.True(check.IsVisible);
check.Command.Execute(null);
Assert.Equal(1, updates.CheckCalls);
});
Open(viewModel, window => Assert.Equal("CursorLang 1.2.3 — Settings", window.Title));
});
}
// An app installed from the Store is updated by the Store
// The application lives in the tray, and the settings window is a guest on the
// screen: its close button puts it away rather than ends anything
/// <summary>
/// Closing the window closes it. The tray it used to hide into belongs to another
/// process now, and there is nothing shared left to keep alive.
/// </summary>
[Fact]
public void An_app_that_updates_itself_elsewhere_shows_no_updates_section()
public void Closing_the_window_really_closes_it()
{
Sta.Run(() =>
{
using UpdateViewModel section = CreateUpdates(new FakeUpdateService { IsSupported = false });
using SettingsViewModel viewModel = CreateViewModel(updates: section);
using SettingsViewModel viewModel = CreateViewModel();
Open(viewModel, window =>
Assert.All(FindButtonsBoundTo(window, "Updates.CheckCommand"), button =>
Assert.False(button.IsVisible)));
{
window.Close();
Assert.False(window.IsVisible);
Assert.Throws<InvalidOperationException>(window.Show);
});
});
}
@@ -287,11 +467,11 @@ public sealed class MainWindowTests
AppSettings? settings = null,
ILocalizationService? localization = null,
IStartupService? startup = null,
UpdateViewModel? updates = null) =>
Version? version = null) =>
new(settings ?? new AppSettings(),
localization ?? new FakeLocalizationService(),
startup ?? new FakeStartupService(),
updates ?? Fake.Updates());
version ?? new Version(1, 0, 0, 0));
private static void Open(SettingsViewModel viewModel, Action<MainWindow> check) =>
Open(viewModel, new FakeThemeService(), new MainWindowPlacement(), check);
@@ -326,13 +506,17 @@ public sealed class MainWindowTests
}
}
private static UpdateViewModel CreateUpdates(IUpdateService updates) =>
new(updates, new FakeLocalizationService(), new AppSettings(), new UpdateOptions());
private static IEnumerable<Button> FindButtonsBoundTo(DependencyObject root, string path) =>
FindAll<Button>(root).Where(button =>
BindingOperations.GetBinding(button, ButtonBase.CommandProperty)?.Path.Path == path);
private static string? PathOf(DependencyObject element, DependencyProperty property) =>
BindingOperations.GetBinding(element, property)?.Path.Path;
private static IEnumerable<Slider> FindSlidersBoundTo(DependencyObject root, string path) =>
FindAll<Slider>(root).Where(slider =>
BindingOperations.GetBinding(slider, RangeBase.ValueProperty)?.Path.Path == path);
private static IEnumerable<CheckBox> FindStartupCheckBoxes(DependencyObject root) =>
FindCheckBoxesBoundTo(root, nameof(SettingsViewModel.RunAtStartup));
@@ -0,0 +1,78 @@
using System.Collections;
using System.Globalization;
using System.IO;
using System.Reflection;
using System.Resources;
using System.Text.RegularExpressions;
using CursorLang.Core.Services;
namespace CursorLang.Settings.Tests.Views;
/// <summary>
/// The settings window markup against the resource strings.
/// </summary>
/// <remarks>
/// The resources live in Core and the markup lives here, so the check that the two
/// agree has to live here too. What the resources say among themselves — that every
/// key is translated, that the placeholders match — is checked by StringsTests, next
/// to the resources.
///
/// The markup is read as an embedded copy rather than off disk: a test that walks up
/// the folder tree looking for a .xaml file breaks the moment anything moves.
/// </remarks>
public sealed partial class MarkupStringsTests
{
private static readonly ResourceManager Resources =
new("CursorLang.Core.Resources.Strings", typeof(LocalizationService).Assembly);
/// <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 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()));
}
private static IEnumerable<string> NeutralKeys()
{
ResourceSet set = Resources.GetResourceSet(CultureInfo.InvariantCulture, true, true)!;
foreach (DictionaryEntry entry in set)
{
yield return (string)entry.Key;
}
}
private static string ReadSettingsWindowMarkup()
{
using Stream stream = Assembly.GetExecutingAssembly().GetManifestResourceStream("MainWindow.xaml")
?? throw new InvalidOperationException("The settings window markup is not embedded in the test assembly");
using var reader = new StreamReader(stream);
return reader.ReadToEnd();
}
[GeneratedRegex(@"Localization\[(?<key>\w+)\]")]
private static partial Regex LocalizationBinding();
}
@@ -1,7 +1,7 @@
<Application x:Class="CursorLang.App"
<Application x:Class="CursorLang.Settings.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
ShutdownMode="OnMainWindowClose">
ShutdownMode="OnExplicitShutdown">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>

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