lightweight variant #1
@@ -79,4 +79,4 @@ jobs:
|
||||
--configuration Release
|
||||
--no-build
|
||||
--nologo
|
||||
--settings CursorLang.Tests/coverage.runsettings
|
||||
--settings coverage.runsettings
|
||||
|
||||
@@ -92,7 +92,7 @@ jobs:
|
||||
--configuration Release
|
||||
--no-build
|
||||
--nologo
|
||||
--settings CursorLang.Tests/coverage.runsettings
|
||||
--settings coverage.runsettings
|
||||
|
||||
# The package comes out as Partner Center wants it — the Store puts its own
|
||||
# signature on it. The identity comes from repository variables and falls
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0-windows10.0.19041.0</TargetFramework>
|
||||
<SupportedOSPlatformVersion>10.0.17763.0</SupportedOSPlatformVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<OutputType>Exe</OutputType>
|
||||
<PlatformTarget>AnyCPU</PlatformTarget>
|
||||
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>CursorLang.Agent.Tests</RootNamespace>
|
||||
<AssemblyName>CursorLang.Agent.Tests</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3" Version="3.2.2"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.8.1"/>
|
||||
<PackageReference Include="coverlet.collector" Version="10.0.1"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\CursorLang.Core\CursorLang.Core.csproj"/>
|
||||
<ProjectReference Include="..\CursorLang.Tests.Shared\CursorLang.Tests.Shared.csproj"/>
|
||||
<ProjectReference Include="..\CursorLang.Agent\CursorLang.Agent.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AssemblyAttribute Include="System.Reflection.AssemblyMetadataAttribute">
|
||||
<_Parameter1>CursorLangExecutable</_Parameter1>
|
||||
<_Parameter2>$(MSBuildThisFileDirectory)..\CursorLang.Agent\bin\$(Configuration)\$(TargetFramework)\CursorLang.exe</_Parameter2>
|
||||
</AssemblyAttribute>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,27 +1,27 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using CursorLang.Services;
|
||||
using System.Text.RegularExpressions;
|
||||
using CursorLang.Core.Services;
|
||||
|
||||
namespace CursorLang.Tests;
|
||||
namespace CursorLang.Agent.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The application as a whole: the start, the single instance and the exit.
|
||||
/// The application as a whole: the agent starting, the settings window it opens, the
|
||||
/// single instance and the exit.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The application cannot be built inside the tests — it raises windows and
|
||||
/// takes the place of the single instance for the whole session. It is
|
||||
/// therefore started the way the user starts it: as a separate process.
|
||||
/// None of this can be built inside the tests — the agent installs a system hook and
|
||||
/// takes the place of the single instance for the whole session. It is therefore
|
||||
/// started the way the user starts it: as a separate process.
|
||||
///
|
||||
/// If the application is already running in this session, the checks skip
|
||||
/// themselves: meddling with someone else's running instance is not their business.
|
||||
///
|
||||
/// They skip themselves where there is no desktop to show a window on either — on a
|
||||
/// build agent living as a Windows service, for one.
|
||||
/// If the application is already running in this session, the checks skip themselves:
|
||||
/// meddling with someone else's running instance is not their business. They skip
|
||||
/// themselves where there is no desktop to show a window on either — on a build agent
|
||||
/// living as a Windows service, for one.
|
||||
/// </remarks>
|
||||
public sealed class EndToEndTests
|
||||
public sealed partial class EndToEndTests
|
||||
{
|
||||
private static readonly TimeSpan StartTimeout = TimeSpan.FromSeconds(30);
|
||||
private static readonly TimeSpan ExitTimeout = TimeSpan.FromSeconds(15);
|
||||
@@ -29,12 +29,48 @@ public sealed class EndToEndTests
|
||||
/// <summary>How long "the application went on working" is worth watching for.</summary>
|
||||
private static readonly TimeSpan StayTimeout = TimeSpan.FromSeconds(3);
|
||||
|
||||
/// <summary>
|
||||
/// The whole point of the background process, as a test rather than as a promise.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The agent must not load the WPF rendering stack. It is checked on the running
|
||||
/// process, not on its references: a reference costs nothing, a load costs the
|
||||
/// hundred megabytes the split exists to avoid.
|
||||
///
|
||||
/// UI Automation and the assemblies behind it — WindowsBase and PresentationCore —
|
||||
/// are deliberately not in the pattern: the caret fallback pulls them in on purpose
|
||||
/// and only when it runs. What must never appear is the renderer itself.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void The_application_starts_and_shows_the_settings_window()
|
||||
public void The_agent_runs_without_the_rendering_stack()
|
||||
{
|
||||
using Launch launch = Launch.Start(StartupLaunch.Argument);
|
||||
|
||||
Assert.False(launch.Process.WaitForExit(StayTimeout), "the agent ended by itself");
|
||||
launch.Process.Refresh();
|
||||
|
||||
var loaded = launch.Process.Modules
|
||||
.Cast<ProcessModule>()
|
||||
.Select(module => module.ModuleName)
|
||||
.Where(name => RenderingStack().IsMatch(name))
|
||||
.ToList();
|
||||
|
||||
Assert.True(loaded.Count == 0, $"the agent loaded {string.Join(", ", loaded)}");
|
||||
}
|
||||
|
||||
[GeneratedRegex("PresentationFramework|wpfgfx|milcore|PresentationNative", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex RenderingStack();
|
||||
|
||||
/// <summary>
|
||||
/// Started by the user, the application shows the settings window — which lives in
|
||||
/// a process of its own and is started by the agent.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_launch_by_the_user_opens_the_settings_window()
|
||||
{
|
||||
using Launch launch = Launch.Start();
|
||||
|
||||
Assert.NotEqual(IntPtr.Zero, launch.WaitForWindow());
|
||||
Assert.NotEqual(IntPtr.Zero, launch.WaitForSettingsWindow());
|
||||
Assert.False(launch.Process.HasExited);
|
||||
}
|
||||
|
||||
@@ -48,18 +84,16 @@ public sealed class EndToEndTests
|
||||
using Launch launch = Launch.Start(StartupLaunch.Argument);
|
||||
|
||||
// Nothing is expected to appear, so the wait is for the whole time
|
||||
Assert.False(launch.Process.WaitForExit(StayTimeout), "the application ended by itself");
|
||||
|
||||
launch.Process.Refresh();
|
||||
Assert.Equal(IntPtr.Zero, launch.Process.MainWindowHandle);
|
||||
Assert.False(launch.Process.WaitForExit(StayTimeout), "the agent ended by itself");
|
||||
Assert.Empty(Launch.SettingsProcesses());
|
||||
}
|
||||
|
||||
// A second run raises no second window but shows the window of the running one
|
||||
// A second run raises no second agent but asks the running one for the window
|
||||
[Fact]
|
||||
public void The_second_run_ends_by_itself()
|
||||
{
|
||||
using Launch launch = Launch.Start();
|
||||
launch.WaitForWindow();
|
||||
using Launch launch = Launch.Start(StartupLaunch.Argument);
|
||||
Assert.False(launch.Process.WaitForExit(StayTimeout), "the agent ended by itself");
|
||||
|
||||
using Process second = Launch.StartProcess();
|
||||
|
||||
@@ -70,19 +104,33 @@ public sealed class EndToEndTests
|
||||
Assert.False(launch.Process.HasExited);
|
||||
}
|
||||
|
||||
// The way out of the application is the tray menu alone: the close button of
|
||||
// the window merely puts the window away
|
||||
/// <summary>
|
||||
/// Closing the settings window ends that process and leaves the agent alone.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is the behaviour the split was for. The window used to hide itself into the
|
||||
/// tray, because closing it would have thrown away a visual tree the background half
|
||||
/// was still using; now there is nothing shared to throw away, and the memory the
|
||||
/// window took goes back to the system.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void Closing_the_window_leaves_the_application_in_the_tray()
|
||||
public void Closing_the_settings_window_leaves_the_agent_running()
|
||||
{
|
||||
using Launch launch = Launch.Start();
|
||||
launch.WaitForWindow();
|
||||
launch.WaitForSettingsWindow();
|
||||
|
||||
Assert.True(launch.Process.CloseMainWindow(), "the window did not accept the request to close");
|
||||
Assert.False(launch.Process.WaitForExit(StayTimeout), "the application ended together with its window");
|
||||
Process settings = Launch.SettingsProcesses().Single();
|
||||
try
|
||||
{
|
||||
Assert.True(settings.CloseMainWindow(), "the window did not accept the request to close");
|
||||
Assert.True(settings.WaitForExit(ExitTimeout), "the settings window outlived its own closing");
|
||||
}
|
||||
finally
|
||||
{
|
||||
settings.Dispose();
|
||||
}
|
||||
|
||||
launch.Process.Refresh();
|
||||
Assert.Equal(IntPtr.Zero, launch.Process.MainWindowHandle);
|
||||
Assert.False(launch.Process.HasExited);
|
||||
}
|
||||
|
||||
/// <summary>A started application that shuts down together with the check.</summary>
|
||||
@@ -102,7 +150,7 @@ public sealed class EndToEndTests
|
||||
|
||||
internal Process Process { get; }
|
||||
|
||||
/// <summary>Starts the application first — making sure the place is free.</summary>
|
||||
/// <summary>Starts the agent first — making sure the place is free.</summary>
|
||||
internal static Launch Start(params string[] arguments)
|
||||
{
|
||||
if (!HasInteractiveDesktop())
|
||||
@@ -110,7 +158,7 @@ public sealed class EndToEndTests
|
||||
Assert.Skip("There is no interactive desktop here — the application has nowhere to show its window");
|
||||
}
|
||||
|
||||
if (Process.GetProcessesByName("CursorLang").Length > 0)
|
||||
if (Process.GetProcessesByName("CursorLang").Length > 0 || SettingsProcesses().Length > 0)
|
||||
{
|
||||
Assert.Skip("The application is already running — this check keeps out of someone else's run");
|
||||
}
|
||||
@@ -118,7 +166,7 @@ public sealed class EndToEndTests
|
||||
return new Launch(StartProcess(arguments));
|
||||
}
|
||||
|
||||
/// <summary>Starts the application the way the user — or Windows — does.</summary>
|
||||
/// <summary>Starts the agent the way the user — or Windows — does.</summary>
|
||||
internal static Process StartProcess(params string[] arguments)
|
||||
{
|
||||
string path = ExecutablePath();
|
||||
@@ -138,11 +186,14 @@ public sealed class EndToEndTests
|
||||
return Process.Start(start)!;
|
||||
}
|
||||
|
||||
/// <summary>The settings window processes running right now, if any.</summary>
|
||||
internal static Process[] SettingsProcesses() => Process.GetProcessesByName("CursorLang.Settings");
|
||||
|
||||
/// <summary>
|
||||
/// Waits for the settings window: by the time it appears the application
|
||||
/// has raised its whole cast.
|
||||
/// Waits for the settings window. It belongs to another process now, so the
|
||||
/// wait is for that process to appear and put a window on the screen.
|
||||
/// </summary>
|
||||
internal IntPtr WaitForWindow()
|
||||
internal IntPtr WaitForSettingsWindow()
|
||||
{
|
||||
DateTime deadline = DateTime.UtcNow + StartTimeout;
|
||||
|
||||
@@ -152,12 +203,19 @@ public sealed class EndToEndTests
|
||||
|
||||
if (Process.HasExited)
|
||||
{
|
||||
Assert.Fail($"The application exited while starting with code {Process.ExitCode}");
|
||||
Assert.Fail($"The agent exited while starting with code {Process.ExitCode}");
|
||||
}
|
||||
|
||||
if (Process.MainWindowHandle != IntPtr.Zero)
|
||||
foreach (Process settings in SettingsProcesses())
|
||||
{
|
||||
return Process.MainWindowHandle;
|
||||
settings.Refresh();
|
||||
IntPtr window = settings.MainWindowHandle;
|
||||
settings.Dispose();
|
||||
|
||||
if (window != IntPtr.Zero)
|
||||
{
|
||||
return window;
|
||||
}
|
||||
}
|
||||
|
||||
Thread.Sleep(100);
|
||||
@@ -197,16 +255,25 @@ public sealed class EndToEndTests
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (Process settings in SettingsProcesses())
|
||||
{
|
||||
Kill(settings);
|
||||
}
|
||||
|
||||
Kill(Process);
|
||||
}
|
||||
|
||||
// The exit lives in a tray menu no test can reach, and the settings are saved
|
||||
// as they change, so nothing is lost by ending the processes outright
|
||||
private static void Kill(Process process)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Process.HasExited)
|
||||
if (!process.HasExited)
|
||||
{
|
||||
// Asking the window to close would only put it away into the
|
||||
// tray, and the exit lives in a menu no test can reach. The
|
||||
// settings are saved as they change, so nothing is lost here
|
||||
Process.Kill(entireProcessTree: true);
|
||||
Process.WaitForExit(ExitTimeout);
|
||||
process.Kill(entireProcessTree: true);
|
||||
process.WaitForExit(ExitTimeout);
|
||||
}
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
@@ -215,7 +282,7 @@ public sealed class EndToEndTests
|
||||
}
|
||||
finally
|
||||
{
|
||||
Process.Dispose();
|
||||
process.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
+67
-36
@@ -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);
|
||||
}
|
||||
}
|
||||
+38
-49
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using System.ComponentModel;
|
||||
using CursorLang.Agent.Services;
|
||||
using CursorLang.Agent.Windows;
|
||||
using CursorLang.Core.Models;
|
||||
using CursorLang.Core.Services;
|
||||
using CursorLang.Core.Threading;
|
||||
|
||||
namespace CursorLang.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// The background half of CursorLang: the hook, the layout polling, the popup and the
|
||||
/// tray icon, with a Win32 message loop underneath and no WPF anywhere.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The services are wired by hand rather than through a container, and that is a
|
||||
/// decision rather than an omission: the whole point of this process is how little it
|
||||
/// weighs, and a container is a megabyte of assembly and a graph of reflection on the
|
||||
/// way to the same object. There are ten of them and they are all listed here.
|
||||
/// </remarks>
|
||||
internal sealed class Agent : IDisposable
|
||||
{
|
||||
private readonly SingleInstanceGate _gate;
|
||||
private readonly AgentWindow _window;
|
||||
private readonly SettingsService _settingsService;
|
||||
private readonly AppSettings _settings;
|
||||
private readonly LocalizationService _localization;
|
||||
private readonly NativePopupWindow _popupWindow;
|
||||
private readonly LayoutPopupService _popupService;
|
||||
private readonly KeyboardLayoutService _layoutService;
|
||||
private readonly CapsLockHotkeyService _hotkeyService;
|
||||
private readonly LayoutNotificationCoordinator _notifications;
|
||||
private readonly CapsLockSwitchCoordinator _capsLock;
|
||||
private readonly NativeTrayIcon _tray;
|
||||
|
||||
internal Agent(SingleInstanceGate gate)
|
||||
{
|
||||
_gate = gate;
|
||||
|
||||
_window = new AgentWindow();
|
||||
_window.AddFilter(OnWindowMessage);
|
||||
|
||||
_settingsService = new SettingsService();
|
||||
_settings = _settingsService.Load();
|
||||
|
||||
_localization = new LocalizationService { CurrentLanguage = _settings.Language };
|
||||
_settings.PropertyChanged += OnSettingsChanged;
|
||||
|
||||
_popupWindow = new NativePopupWindow(_settings);
|
||||
_popupService = new LayoutPopupService(_popupWindow, _settings);
|
||||
|
||||
_layoutService = new KeyboardLayoutService(new KeyboardLayoutOptions());
|
||||
_hotkeyService = new CapsLockHotkeyService(_settings, _window.Post);
|
||||
|
||||
_notifications = new LayoutNotificationCoordinator(_layoutService, _popupService);
|
||||
_capsLock = new CapsLockSwitchCoordinator(_hotkeyService, _layoutService, _popupService, _settings);
|
||||
|
||||
_tray = new NativeTrayIcon(_window, _localization);
|
||||
}
|
||||
|
||||
/// <summary>Starts everything and pumps messages until the user asks to quit.</summary>
|
||||
internal int Run(bool automatic)
|
||||
{
|
||||
_gate.ActivationRequested += OnActivationRequested;
|
||||
|
||||
_tray.OpenRequested += OnOpenRequested;
|
||||
_tray.ExitRequested += OnExitRequested;
|
||||
|
||||
bool hasTray = _tray.Install();
|
||||
|
||||
_notifications.Start();
|
||||
_capsLock.Start();
|
||||
|
||||
if (!automatic || !hasTray)
|
||||
{
|
||||
SettingsLauncher.Open();
|
||||
}
|
||||
|
||||
return MessageLoop.Run();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_gate.ActivationRequested -= OnActivationRequested;
|
||||
|
||||
_tray.OpenRequested -= OnOpenRequested;
|
||||
_tray.ExitRequested -= OnExitRequested;
|
||||
|
||||
_settings.PropertyChanged -= OnSettingsChanged;
|
||||
|
||||
_capsLock.Dispose();
|
||||
_notifications.Dispose();
|
||||
_hotkeyService.Dispose();
|
||||
_layoutService.Dispose();
|
||||
_popupService.Dispose();
|
||||
_settingsService.Dispose();
|
||||
_tray.Dispose();
|
||||
_window.Dispose();
|
||||
}
|
||||
|
||||
// The settings window has written the file and says so. The write was a single
|
||||
// atomic move, so there is nothing to wait for and nothing half-written to read
|
||||
private bool OnWindowMessage(uint message, IntPtr wParam, IntPtr lParam)
|
||||
{
|
||||
if (message != SettingsSignal.Message)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_settingsService.Reload();
|
||||
return true;
|
||||
}
|
||||
|
||||
private void OnSettingsChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName == nameof(AppSettings.Language))
|
||||
{
|
||||
_localization.CurrentLanguage = _settings.Language;
|
||||
}
|
||||
}
|
||||
|
||||
// A second launch of the agent, from the Start menu for instance. The one already
|
||||
// running answers the way the user expects a second launch to be answered
|
||||
private void OnActivationRequested(object? sender, EventArgs e) =>
|
||||
_window.Post(() => SettingsLauncher.Open());
|
||||
|
||||
private void OnOpenRequested(object? sender, EventArgs e) => SettingsLauncher.Open();
|
||||
|
||||
private void OnExitRequested(object? sender, EventArgs e) => _window.Quit();
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0-windows10.0.19041.0</TargetFramework>
|
||||
<SupportedOSPlatformVersion>10.0.17763.0</SupportedOSPlatformVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<RootNamespace>CursorLang.Agent</RootNamespace>
|
||||
<AssemblyName>CursorLang</AssemblyName>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<ApplicationIcon>..\CursorLang.Core\Resources\CursorLang.ico</ApplicationIcon>
|
||||
<PlatformTarget>AnyCPU</PlatformTarget>
|
||||
<RuntimeIdentifiers>win-x64;win-arm64</RuntimeIdentifiers>
|
||||
<SelfContained Condition="'$(RuntimeIdentifier)' != ''">true</SelfContained>
|
||||
<PublishTrimmed>false</PublishTrimmed>
|
||||
<PublishReadyToRun Condition="'$(RuntimeIdentifier)' == 'win-x64'">true</PublishReadyToRun>
|
||||
<Version>1.0.0</Version>
|
||||
<AssemblyVersion>1.0.0.0</AssemblyVersion>
|
||||
<FileVersion>1.0.0.0</FileVersion>
|
||||
<Product>CursorLang</Product>
|
||||
<Company>Aleksandr Neychev</Company>
|
||||
<Description>Shows the keyboard layout at the cursor</Description>
|
||||
<Copyright>Copyright (c) 2026</Copyright>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\CursorLang.Core\CursorLang.Core.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\CursorLang.Settings\CursorLang.Settings.csproj"
|
||||
ReferenceOutputAssembly="false"
|
||||
Private="false"/>
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="PlaceTheSettingsWindowBesideTheAgent" AfterTargets="Build">
|
||||
<ItemGroup>
|
||||
<SettingsOutput Include="..\CursorLang.Settings\bin\$(Configuration)\$(TargetFramework)\**\*"/>
|
||||
</ItemGroup>
|
||||
|
||||
<Copy SourceFiles="@(SettingsOutput)"
|
||||
DestinationFolder="$(OutDir)%(RecursiveDir)"
|
||||
SkipUnchangedFiles="true"/>
|
||||
</Target>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="CursorLang.Agent.Tests"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using CursorLang.Core.Interop;
|
||||
|
||||
namespace CursorLang.Agent.Interop;
|
||||
|
||||
/// <summary>
|
||||
/// The system context menu — the one the tray icon raises.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A menu built this way is drawn by Windows, so the theme and the language chosen in
|
||||
/// the application no longer reach it. That is the accepted price of leaving WPF: a WPF
|
||||
/// <c>ContextMenu</c> costs the whole rendering stack in the background process.
|
||||
/// </remarks>
|
||||
internal static class MenuNative
|
||||
{
|
||||
private const uint MF_STRING = 0x00000000;
|
||||
private const uint MF_SEPARATOR = 0x00000800;
|
||||
private const uint MF_GRAYED = 0x00000001;
|
||||
|
||||
private const uint TPM_LEFTALIGN = 0x0000;
|
||||
private const uint TPM_RIGHTBUTTON = 0x0002;
|
||||
private const uint TPM_RETURNCMD = 0x0100;
|
||||
|
||||
/// <summary>An item of the menu being built.</summary>
|
||||
/// <param name="Id">What <see cref="Track"/> gives back when the item is chosen.</param>
|
||||
/// <param name="Caption">The text, or <c>null</c> for a separator.</param>
|
||||
/// <param name="IsEnabled">A greyed item is shown but cannot be chosen.</param>
|
||||
internal readonly record struct Item(int Id, string? Caption, bool IsEnabled = true)
|
||||
{
|
||||
internal static Item Separator => new(0, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raises the menu at a screen point and returns the identifier of the chosen item,
|
||||
/// or zero when the user dismissed it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>TPM_RETURNCMD</c> means the answer comes back from the call itself instead of
|
||||
/// as a <c>WM_COMMAND</c> later, which keeps the whole menu in one place. The call
|
||||
/// does not return until the user is done with the menu — that is how a modal menu
|
||||
/// works, and the message loop keeps running inside it.
|
||||
///
|
||||
/// The window is brought to the foreground first and poked with an empty message
|
||||
/// afterwards: without the first the menu never closes on a click elsewhere, and
|
||||
/// without the second it stays on screen after the choice is made. Both are
|
||||
/// long-standing quirks of a menu owned by a window the user cannot see.
|
||||
/// </remarks>
|
||||
internal static int Track(IntPtr owner, PopupWindowNative.Point at, IReadOnlyList<Item> items)
|
||||
{
|
||||
IntPtr menu = CreatePopupMenu();
|
||||
if (menu == IntPtr.Zero)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
foreach (Item item in items)
|
||||
{
|
||||
if (item.Caption is null)
|
||||
{
|
||||
AppendMenu(menu, MF_SEPARATOR, IntPtr.Zero, null);
|
||||
continue;
|
||||
}
|
||||
|
||||
uint flags = MF_STRING | (item.IsEnabled ? 0 : MF_GRAYED);
|
||||
AppendMenu(menu, flags, new IntPtr(item.Id), item.Caption);
|
||||
}
|
||||
|
||||
TrayIconNative.BringToForeground(owner);
|
||||
|
||||
int chosen = TrackPopupMenuEx(
|
||||
menu, TPM_LEFTALIGN | TPM_RIGHTBUTTON | TPM_RETURNCMD,
|
||||
at.X, at.Y, owner, IntPtr.Zero);
|
||||
|
||||
WindowNative.PostMessage(owner, WindowNative.WM_NULL, IntPtr.Zero, IntPtr.Zero);
|
||||
|
||||
return chosen;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DestroyMenu(menu);
|
||||
}
|
||||
}
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr CreatePopupMenu();
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern bool DestroyMenu(IntPtr hMenu);
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "AppendMenuW")]
|
||||
private static extern bool AppendMenu(IntPtr hMenu, uint uFlags, IntPtr uIDNewItem, string? lpNewItem);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern int TrackPopupMenuEx(IntPtr hMenu, uint uFlags, int x, int y,
|
||||
IntPtr hwnd, IntPtr lptpm);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using CursorLang.Core.Interop;
|
||||
|
||||
namespace CursorLang.Interop;
|
||||
namespace CursorLang.Agent.Interop;
|
||||
|
||||
/// <summary>
|
||||
/// Win32 API for the notification area: the icon itself, the messages it sends
|
||||
@@ -28,7 +29,6 @@ internal static class TrayIconNative
|
||||
internal const int ContextMenuNotification = 0x007B;
|
||||
|
||||
private const int NIM_ADD = 0x00000000;
|
||||
private const int NIM_MODIFY = 0x00000001;
|
||||
private const int NIM_DELETE = 0x00000002;
|
||||
private const int NIM_SETVERSION = 0x00000004;
|
||||
|
||||
@@ -71,13 +71,6 @@ internal static class TrayIconNative
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Replaces the image and the tooltip of an icon already there.</summary>
|
||||
internal static bool Modify(IntPtr window, int id, IntPtr icon, string tooltip)
|
||||
{
|
||||
NotifyIconData data = Describe(window, id, icon, tooltip);
|
||||
return Shell_NotifyIcon(NIM_MODIFY, ref data);
|
||||
}
|
||||
|
||||
/// <summary>Takes the icon away. A forgotten icon stays in the tray until hovered.</summary>
|
||||
internal static void Remove(IntPtr window, int id)
|
||||
{
|
||||
@@ -134,6 +127,16 @@ internal static class TrayIconNative
|
||||
/// <summary>The notification the icon has sent: it sits in the low word of lParam.</summary>
|
||||
internal static int NotificationOf(IntPtr lParam) => (int)(lParam.ToInt64() & 0xFFFF);
|
||||
|
||||
/// <summary>
|
||||
/// The point of the click. Version 4 of the protocol reports it in screen pixels
|
||||
/// in <c>wParam</c> — exactly what <c>TrackPopupMenuEx</c> expects.
|
||||
/// </summary>
|
||||
internal static PopupWindowNative.Point PointOf(IntPtr wParam) => new()
|
||||
{
|
||||
X = (short)(wParam.ToInt64() & 0xFFFF),
|
||||
Y = (short)((wParam.ToInt64() >> 16) & 0xFFFF),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Brings the window to the foreground.
|
||||
/// </summary>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+42
-12
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
+13
-12
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using System.Collections.Concurrent;
|
||||
using CursorLang.Agent.Interop;
|
||||
|
||||
namespace CursorLang.Agent.Windows;
|
||||
|
||||
/// <summary>
|
||||
/// The window the agent lives around: never shown, but it owns the tray icon and it
|
||||
/// is the way back onto the message loop from a callback.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A window with no <c>WS_VISIBLE</c> shows nowhere, yet is a window in every other
|
||||
/// way. A message-only window would do as well were it not for the news of Explorer
|
||||
/// restarting: that one is broadcast, and broadcasts pass such windows by.
|
||||
/// </remarks>
|
||||
internal sealed class AgentWindow : NativeWindow
|
||||
{
|
||||
/// <summary>
|
||||
/// A message hook. Returning <c>true</c> means the message has been dealt with.
|
||||
/// </summary>
|
||||
internal delegate bool MessageFilter(uint message, IntPtr wParam, IntPtr lParam);
|
||||
|
||||
private const string ClassName = "CursorLang.Agent.Window";
|
||||
|
||||
/// <summary>Drain the queue of posted work. WM_APP is free for the application.</summary>
|
||||
private const uint WM_INVOKE = WindowNative.WM_APP + 100;
|
||||
|
||||
private readonly List<MessageFilter> _filters = [];
|
||||
private readonly ConcurrentQueue<Action> _posted = new();
|
||||
|
||||
internal AgentWindow()
|
||||
: base(ClassName, "CursorLang agent", WindowNative.WS_POPUP, WindowNative.WS_EX_TOOLWINDOW)
|
||||
{
|
||||
}
|
||||
|
||||
internal void AddFilter(MessageFilter filter) => _filters.Add(filter);
|
||||
|
||||
/// <summary>
|
||||
/// Runs the action on the message loop, after the current message is done with.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is what the agent has instead of <c>Dispatcher.BeginInvoke</c>. The caller
|
||||
/// that matters is the keyboard hook: Windows removes a hook whose procedure takes
|
||||
/// too long, so the procedure only records what happened and the answer — showing
|
||||
/// the popup, switching the layout — waits for the message after this one.
|
||||
/// </remarks>
|
||||
internal void Post(Action action)
|
||||
{
|
||||
_posted.Enqueue(action);
|
||||
WindowNative.PostMessage(Handle, WM_INVOKE, IntPtr.Zero, IntPtr.Zero);
|
||||
}
|
||||
|
||||
/// <summary>Asks the message loop to finish.</summary>
|
||||
internal void Quit() => WindowNative.PostQuitMessage(0);
|
||||
|
||||
protected override bool OnMessage(uint message, IntPtr wParam, IntPtr lParam, out IntPtr result)
|
||||
{
|
||||
result = IntPtr.Zero;
|
||||
|
||||
if (message == WM_INVOKE)
|
||||
{
|
||||
while (_posted.TryDequeue(out Action? action))
|
||||
{
|
||||
action();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message is WindowNative.WM_CLOSE or WindowNative.WM_ENDSESSION)
|
||||
{
|
||||
Quit();
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach (MessageFilter filter in _filters)
|
||||
{
|
||||
if (filter(message, wParam, lParam))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
using System.Drawing;
|
||||
using System.Runtime.InteropServices;
|
||||
using CursorLang.Agent.Interop;
|
||||
using CursorLang.Agent.Services;
|
||||
using CursorLang.Core.Interop;
|
||||
using CursorLang.Core.Models;
|
||||
using CursorLang.Core.Services;
|
||||
|
||||
namespace CursorLang.Agent.Windows;
|
||||
|
||||
/// <summary>
|
||||
/// The popup with the short name of the layout, drawn by Win32 alone.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A like-for-like replacement of the WPF popup this once was: a rounded rectangle of
|
||||
/// radius 4 with 10×4 padding, the fill and the text colour from the settings, the whole
|
||||
/// thing at the opacity from the settings, the name in Segoe UI SemiBold at the size
|
||||
/// from the settings.
|
||||
///
|
||||
/// The picture is drawn into an off-screen bitmap and handed to the window whole, by
|
||||
/// <c>UpdateLayeredWindow</c>. Painting on demand instead — a <c>WM_PAINT</c> after the
|
||||
/// window is shown — is what the first version did, and it had the popup appear holding
|
||||
/// the picture of the previous show: hiding a window does not throw its content away,
|
||||
/// and the content is always the other layout. Here there is nothing to be stale,
|
||||
/// because the window is never shown before its picture is in place.
|
||||
///
|
||||
/// It also does away with two devices the painted version needed: the corners came from
|
||||
/// a window region, which cuts without antialiasing, and the opacity from
|
||||
/// <c>SetLayeredWindowAttributes</c>. Both are now just pixels in the bitmap.
|
||||
///
|
||||
/// Responsible only for showing the popup, its size and its place on screen: when to
|
||||
/// take it down is decided by <see cref="LayoutPopupService"/>.
|
||||
/// </remarks>
|
||||
internal sealed class NativePopupWindow : NativeWindow, ILayoutPopupWindow
|
||||
{
|
||||
private const string ClassName = "CursorLang.Agent.Popup";
|
||||
|
||||
// The numbers of the XAML: Border CornerRadius="4" Padding="10,4", all in WPF units
|
||||
private const double CornerRadius = 4;
|
||||
private const double PaddingX = 10;
|
||||
private const double PaddingY = 4;
|
||||
|
||||
private readonly AppSettings _settings;
|
||||
|
||||
private IntPtr _font;
|
||||
private double _fontSize;
|
||||
private double _fontScale;
|
||||
|
||||
internal NativePopupWindow(AppSettings settings)
|
||||
: base(
|
||||
ClassName,
|
||||
"CursorLang popup",
|
||||
WindowNative.WS_POPUP,
|
||||
WindowNative.WS_EX_LAYERED | WindowNative.WS_EX_TOOLWINDOW |
|
||||
WindowNative.WS_EX_NOACTIVATE | WindowNative.WS_EX_TRANSPARENT |
|
||||
WindowNative.WS_EX_TOPMOST)
|
||||
{
|
||||
_settings = settings;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows the popup with the given text at the place set by the settings.
|
||||
/// </summary>
|
||||
public void ShowPopup(string text)
|
||||
{
|
||||
if (Handle == IntPtr.Zero)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bool atFixedPoint = _settings.PlacementMode == PopupPlacementMode.FixedPoint;
|
||||
PopupWindowNative.Rect work = default;
|
||||
PopupWindowNative.Rect anchor = default;
|
||||
double scale;
|
||||
|
||||
if (atFixedPoint)
|
||||
{
|
||||
(work, scale) = PopupWindowNative.GetActiveMonitorWorkArea();
|
||||
}
|
||||
else
|
||||
{
|
||||
anchor = GetAnchor();
|
||||
scale = PopupWindowNative.GetScaleAt(new PopupWindowNative.Point { X = anchor.Left, Y = anchor.Top });
|
||||
}
|
||||
|
||||
EnsureFont(scale);
|
||||
|
||||
Size measured = MeasureText(text);
|
||||
int width = measured.Width + (2 * PopupLayout.ToPixels(PaddingX, scale));
|
||||
int height = measured.Height + (2 * PopupLayout.ToPixels(PaddingY, scale));
|
||||
|
||||
PopupWindowNative.Point position = atFixedPoint
|
||||
? PopupLayout.OnScreen(
|
||||
work, _settings.ScreenPosition, PopupLayout.ToPixels(_settings.ScreenMargin, scale), width, height)
|
||||
: PopupLayout.NearAnchor(anchor, AnchorSideForMode(), OffsetForMode(scale), width, height);
|
||||
|
||||
if (!Draw(text, position, width, height, PopupLayout.ToPixels(CornerRadius, scale)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!WindowNative.IsWindowVisible(Handle))
|
||||
{
|
||||
WindowNative.ShowWindow(Handle, WindowNative.SW_SHOWNOACTIVATE);
|
||||
}
|
||||
}
|
||||
|
||||
public void Hide()
|
||||
{
|
||||
if (Handle != IntPtr.Zero)
|
||||
{
|
||||
WindowNative.ShowWindow(Handle, WindowNative.SW_HIDE);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Destroys the window. The agent only does this on the way out.</summary>
|
||||
public void Close() => Dispose();
|
||||
|
||||
public override void Dispose()
|
||||
{
|
||||
ReleaseFont();
|
||||
base.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Draws the popup off screen and hands the finished picture to the window.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The bitmap is thrown away afterwards rather than kept: it is a few tens of
|
||||
/// kilobytes for the length of one call, the popup is shown rarely, and a cached one
|
||||
/// would have to be rebuilt on every change of size, colour or scale anyway.
|
||||
/// </remarks>
|
||||
private bool Draw(string text, PopupWindowNative.Point at, int width, int height, int radius)
|
||||
{
|
||||
IntPtr screen = GdiNative.GetDC(IntPtr.Zero);
|
||||
if (screen == IntPtr.Zero)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
IntPtr memory = IntPtr.Zero;
|
||||
IntPtr surface = IntPtr.Zero;
|
||||
|
||||
try
|
||||
{
|
||||
memory = GdiNative.CreateCompatibleDC(screen);
|
||||
if (memory == IntPtr.Zero)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
surface = GdiNative.CreateSurface(memory, width, height, out IntPtr bits);
|
||||
if (surface == IntPtr.Zero)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
GdiNative.SelectObject(memory, surface);
|
||||
|
||||
Fill(bits, width, height);
|
||||
DrawText(memory, text, width, height);
|
||||
|
||||
// GDI writes nothing into the alpha channel, so the letters it just drew are
|
||||
// sitting at zero alpha and would come out invisible. The inside of the
|
||||
// popup is opaque anyway, so the whole surface is simply declared so — and
|
||||
// the corners are rounded off afterwards, which is the only place alpha
|
||||
// varies
|
||||
MakeOpaque(bits, width, height);
|
||||
RoundTheCorners(bits, width, height, radius);
|
||||
|
||||
var size = new WindowNative.Size { Width = width, Height = height };
|
||||
var alpha = (byte)Math.Clamp(Math.Round(_settings.Opacity * 255), 0, 255);
|
||||
|
||||
return WindowNative.SetContent(Handle, at, size, memory, alpha);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// The context goes first: a bitmap still selected into one cannot be
|
||||
// deleted, and this way that holds however the method was left
|
||||
if (memory != IntPtr.Zero)
|
||||
{
|
||||
GdiNative.DeleteDC(memory);
|
||||
}
|
||||
|
||||
if (surface != IntPtr.Zero)
|
||||
{
|
||||
GdiNative.DeleteObject(surface);
|
||||
}
|
||||
|
||||
GdiNative.ReleaseDC(IntPtr.Zero, screen);
|
||||
}
|
||||
}
|
||||
|
||||
private void Fill(IntPtr bits, int width, int height)
|
||||
{
|
||||
Color background = _settings.BackgroundColor;
|
||||
|
||||
// Straight into the bitmap rather than through a brush: the pixels have to be
|
||||
// written anyway to carry an alpha channel GDI would not touch
|
||||
int packed = (255 << 24) | (background.R << 16) | (background.G << 8) | background.B;
|
||||
var row = new int[width];
|
||||
Array.Fill(row, packed);
|
||||
|
||||
for (var y = 0; y < height; y++)
|
||||
{
|
||||
Marshal.Copy(row, 0, bits + (y * width * 4), width);
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawText(IntPtr deviceContext, string text, int width, int height)
|
||||
{
|
||||
if (_font == IntPtr.Zero || text.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var bounds = new PopupWindowNative.Rect { Left = 0, Top = 0, Right = width, Bottom = height };
|
||||
|
||||
IntPtr previousFont = GdiNative.SelectObject(deviceContext, _font);
|
||||
GdiNative.SetBkMode(deviceContext, GdiNative.TRANSPARENT);
|
||||
GdiNative.SetTextColor(deviceContext, GdiNative.ToColorRef(_settings.ForegroundColor));
|
||||
|
||||
GdiNative.DrawText(deviceContext, text, text.Length, ref bounds,
|
||||
GdiNative.DT_SINGLELINE | GdiNative.DT_CENTER | GdiNative.DT_VCENTER |
|
||||
GdiNative.DT_NOPREFIX | GdiNative.DT_NOCLIP);
|
||||
|
||||
GdiNative.SelectObject(deviceContext, previousFont);
|
||||
}
|
||||
|
||||
private static void MakeOpaque(IntPtr bits, int width, int height)
|
||||
{
|
||||
var row = new int[width];
|
||||
|
||||
for (var y = 0; y < height; y++)
|
||||
{
|
||||
IntPtr line = bits + (y * width * 4);
|
||||
Marshal.Copy(line, row, 0, width);
|
||||
|
||||
for (var x = 0; x < width; x++)
|
||||
{
|
||||
row[x] = (int)((uint)row[x] | 0xFF000000);
|
||||
}
|
||||
|
||||
Marshal.Copy(row, 0, line, width);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cuts the four corners to a radius, fading the edge rather than stepping it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The painted version cut them with a window region, which is a yes-or-no mask and
|
||||
/// left a visible staircase at 200% scale. Here the corner pixels carry a partial
|
||||
/// alpha worked out from how far the pixel centre is past the arc, which is what
|
||||
/// antialiasing amounts to. The colours are premultiplied to match, as
|
||||
/// <c>UpdateLayeredWindow</c> expects.
|
||||
/// </remarks>
|
||||
private static void RoundTheCorners(IntPtr bits, int width, int height, int radius)
|
||||
{
|
||||
if (radius <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
radius = Math.Min(radius, Math.Min(width, height) / 2);
|
||||
|
||||
var row = new int[width];
|
||||
|
||||
for (var y = 0; y < height; y++)
|
||||
{
|
||||
bool nearTop = y < radius;
|
||||
bool nearBottom = y >= height - radius;
|
||||
if (!nearTop && !nearBottom)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
IntPtr line = bits + (y * width * 4);
|
||||
Marshal.Copy(line, row, 0, width);
|
||||
|
||||
double centreY = nearTop ? radius - 0.5 : height - radius - 0.5;
|
||||
|
||||
for (var x = 0; x < width; x++)
|
||||
{
|
||||
bool nearLeft = x < radius;
|
||||
bool nearRight = x >= width - radius;
|
||||
if (!nearLeft && !nearRight)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
double centreX = nearLeft ? radius - 0.5 : width - radius - 0.5;
|
||||
double distance = Math.Sqrt(
|
||||
((x - centreX) * (x - centreX)) + ((y - centreY) * (y - centreY)));
|
||||
|
||||
// One pixel of softness across the arc: fully inside, fully outside,
|
||||
// and a ramp in between
|
||||
double coverage = Math.Clamp(radius - distance + 0.5, 0, 1);
|
||||
if (coverage >= 1)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
row[x] = Premultiply(row[x], coverage);
|
||||
}
|
||||
|
||||
Marshal.Copy(row, 0, line, width);
|
||||
}
|
||||
}
|
||||
|
||||
private static int Premultiply(int pixel, double coverage)
|
||||
{
|
||||
var value = (uint)pixel;
|
||||
var alpha = (uint)Math.Round(((value >> 24) & 0xFF) * coverage);
|
||||
|
||||
uint red = (uint)Math.Round(((value >> 16) & 0xFF) * coverage);
|
||||
uint green = (uint)Math.Round(((value >> 8) & 0xFF) * coverage);
|
||||
uint blue = (uint)Math.Round((value & 0xFF) * coverage);
|
||||
|
||||
return (int)((alpha << 24) | (red << 16) | (green << 8) | blue);
|
||||
}
|
||||
|
||||
// The anchor point: the caret in the input field or the mouse cursor. The cursor
|
||||
// is a rectangle of zero size, so the corner computation is shared by both
|
||||
private PopupWindowNative.Rect GetAnchor()
|
||||
{
|
||||
if (_settings.PlacementMode == PopupPlacementMode.AtCaret &&
|
||||
CaretNative.TryGetCaretRect() is { } caret)
|
||||
{
|
||||
return caret;
|
||||
}
|
||||
|
||||
return PopupLayout.AsAnchor(PopupWindowNative.GetCursorPosition());
|
||||
}
|
||||
|
||||
// Every anchor mode has a side and an offset of its own
|
||||
private AnchorSide AnchorSideForMode() =>
|
||||
_settings.PlacementMode == PopupPlacementMode.AtCaret ? _settings.CaretSide : _settings.CursorSide;
|
||||
|
||||
private int OffsetForMode(double scale) => PopupLayout.ToPixels(
|
||||
_settings.PlacementMode == PopupPlacementMode.AtCaret ? _settings.CaretOffset : _settings.CursorOffset,
|
||||
scale);
|
||||
|
||||
private Size MeasureText(string text)
|
||||
{
|
||||
IntPtr deviceContext = GdiNative.GetDC(Handle);
|
||||
if (deviceContext == IntPtr.Zero)
|
||||
{
|
||||
return Size.Empty;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
IntPtr previousFont = GdiNative.SelectObject(deviceContext, _font);
|
||||
Size measured = GdiNative.MeasureText(deviceContext, text);
|
||||
GdiNative.SelectObject(deviceContext, previousFont);
|
||||
|
||||
return measured;
|
||||
}
|
||||
finally
|
||||
{
|
||||
GdiNative.ReleaseDC(Handle, deviceContext);
|
||||
}
|
||||
}
|
||||
|
||||
// The font is rebuilt only when the size in the settings or the monitor scale
|
||||
// changes: it is the one expensive thing a show does
|
||||
private void EnsureFont(double scale)
|
||||
{
|
||||
if (_font != IntPtr.Zero &&
|
||||
Math.Abs(_fontSize - _settings.FontSize) < 0.01 &&
|
||||
Math.Abs(_fontScale - scale) < 0.01)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ReleaseFont();
|
||||
|
||||
_fontSize = _settings.FontSize;
|
||||
_fontScale = scale;
|
||||
_font = GdiNative.CreateFont(_fontSize, scale);
|
||||
}
|
||||
|
||||
private void ReleaseFont()
|
||||
{
|
||||
if (_font != IntPtr.Zero)
|
||||
{
|
||||
GdiNative.DeleteObject(_font);
|
||||
_font = IntPtr.Zero;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
+4
-4
@@ -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)
|
||||
{
|
||||
+6
-6
@@ -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();
|
||||
+6
-6
@@ -1,10 +1,10 @@
|
||||
using System.ComponentModel;
|
||||
using System.Drawing;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using System.Windows.Media;
|
||||
using CursorLang.Models;
|
||||
using CursorLang.Core.Models;
|
||||
|
||||
namespace CursorLang.Tests.Models;
|
||||
namespace CursorLang.Core.Tests.Models;
|
||||
|
||||
public sealed class AppSettingsTests
|
||||
{
|
||||
@@ -26,8 +26,8 @@ public sealed class AppSettingsTests
|
||||
Assert.Equal(0.9, settings.Opacity);
|
||||
Assert.Equal(500, settings.DurationMilliseconds);
|
||||
Assert.Equal(300, settings.CapsLockHoldMilliseconds);
|
||||
Assert.Equal(Color.FromRgb(0x20, 0x20, 0x20), settings.BackgroundColor);
|
||||
Assert.Equal(Color.FromRgb(0xFF, 0xFF, 0xFF), settings.ForegroundColor);
|
||||
Assert.Equal(Color.FromArgb(0x20, 0x20, 0x20), settings.BackgroundColor);
|
||||
Assert.Equal(Color.FromArgb(0xFF, 0xFF, 0xFF), settings.ForegroundColor);
|
||||
}
|
||||
|
||||
// The app must not change how the system behaves until it is asked to
|
||||
@@ -135,7 +135,7 @@ public sealed class AppSettingsTests
|
||||
string text => text + "-other",
|
||||
double number => number + 1,
|
||||
bool flag => !flag,
|
||||
Color color => Color.FromRgb((byte)(color.R + 1), color.G, color.B),
|
||||
Color color => Color.FromArgb((byte)(color.R + 1), color.G, color.B),
|
||||
Enum value => NextEnumValue(value),
|
||||
DateTimeOffset moment => moment.AddDays(1),
|
||||
|
||||
+2
-2
@@ -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
|
||||
{
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
using CursorLang.Models;
|
||||
using CursorLang.Core.Models;
|
||||
|
||||
namespace CursorLang.Tests.Models;
|
||||
namespace CursorLang.Core.Tests.Models;
|
||||
|
||||
public sealed class LayoutChangedEventArgsTests
|
||||
{
|
||||
+5
-47
@@ -1,21 +1,19 @@
|
||||
using System.Collections;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Resources;
|
||||
using System.Text.RegularExpressions;
|
||||
using CursorLang.Models;
|
||||
using CursorLang.Core.Models;
|
||||
using CursorLang.Core.Services;
|
||||
|
||||
namespace CursorLang.Tests.Resources;
|
||||
namespace CursorLang.Core.Tests.Resources;
|
||||
|
||||
/// <summary>
|
||||
/// Checks of the resources themselves: they carry every caption in the settings
|
||||
/// window, and a missing key only shows on a live window.
|
||||
/// </summary>
|
||||
public sealed partial class StringsTests
|
||||
public sealed class StringsTests
|
||||
{
|
||||
private static readonly ResourceManager Resources =
|
||||
new("CursorLang.Resources.Strings", typeof(App).Assembly);
|
||||
new("CursorLang.Core.Resources.Strings", typeof(LocalizationService).Assembly);
|
||||
|
||||
private static readonly CultureInfo English = CultureInfo.GetCultureInfo("en");
|
||||
private static readonly CultureInfo Russian = CultureInfo.GetCultureInfo("ru");
|
||||
@@ -80,28 +78,6 @@ public sealed partial class StringsTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every key the settings window markup asks for has to exist in the
|
||||
/// resources: otherwise the user sees the key itself in its place.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Every_key_from_the_settings_window_markup_exists_in_the_resources()
|
||||
{
|
||||
HashSet<string> known = [.. NeutralKeys()];
|
||||
List<string> missing = [];
|
||||
|
||||
foreach (Match match in LocalizationBinding().Matches(ReadSettingsWindowMarkup()))
|
||||
{
|
||||
string key = match.Groups["key"].Value;
|
||||
if (!known.Contains(key))
|
||||
{
|
||||
missing.Add(key);
|
||||
}
|
||||
}
|
||||
|
||||
Assert.Empty(missing);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The version of an update is put into the string by the app, so the place
|
||||
/// for it has to be there in both languages.
|
||||
@@ -113,13 +89,6 @@ public sealed partial class StringsTests
|
||||
Assert.Contains("{0}", Resources.GetString("UpdateAvailable", Russian), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>The markup does ask for strings — otherwise the check above means nothing.</summary>
|
||||
[Fact]
|
||||
public void The_settings_window_markup_asks_for_resource_strings()
|
||||
{
|
||||
Assert.NotEmpty(LocalizationBinding().Matches(ReadSettingsWindowMarkup()));
|
||||
}
|
||||
|
||||
public static TheoryData<string> EnumKeys()
|
||||
{
|
||||
var data = new TheoryData<string>();
|
||||
@@ -164,15 +133,4 @@ public sealed partial class StringsTests
|
||||
private static ResourceSet RussianSet() =>
|
||||
Resources.GetResourceSet(Russian, createIfNotExists: true, tryParents: false)!;
|
||||
|
||||
private static string ReadSettingsWindowMarkup()
|
||||
{
|
||||
using Stream stream = Assembly.GetExecutingAssembly().GetManifestResourceStream("MainWindow.xaml")
|
||||
?? throw new InvalidOperationException("The settings window markup is not embedded in the test assembly");
|
||||
|
||||
using var reader = new StreamReader(stream);
|
||||
return reader.ReadToEnd();
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"Localization\[(?<key>\w+)\]")]
|
||||
private static partial Regex LocalizationBinding();
|
||||
}
|
||||
+4
-4
@@ -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.
|
||||
+4
-5
@@ -1,11 +1,10 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Runtime.InteropServices;
|
||||
using CursorLang.Models;
|
||||
using CursorLang.Services;
|
||||
using CursorLang.Tests.Infrastructure;
|
||||
using CursorLang.Core.Models;
|
||||
using CursorLang.Core.Services;
|
||||
using CursorLang.Tests.Shared;
|
||||
|
||||
namespace CursorLang.Tests.Services;
|
||||
namespace CursorLang.Core.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Reading the release list of Gitea. The answer of the server is not ours to
|
||||
+39
-41
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -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.
|
||||
+2
-2
@@ -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
|
||||
{
|
||||
+4
-4
@@ -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
|
||||
+4
-4
@@ -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
|
||||
+138
-40
@@ -1,16 +1,16 @@
|
||||
using System.Drawing;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Windows.Media;
|
||||
using CursorLang.Models;
|
||||
using CursorLang.Services;
|
||||
using CursorLang.Tests.Infrastructure;
|
||||
using CursorLang.Tests.Models;
|
||||
using CursorLang.Core.Models;
|
||||
using CursorLang.Core.Services;
|
||||
using CursorLang.Core.Tests.Models;
|
||||
using CursorLang.Tests.Shared;
|
||||
|
||||
namespace CursorLang.Tests.Services;
|
||||
namespace CursorLang.Core.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Keeping the settings in a file. Everything happens in a temporary folder:
|
||||
/// Keeping the settings in a file. Only the settings window writes, and it asks for
|
||||
/// that with TrackChanges; the agent loads the same file and never saves. Everything happens in a temporary folder:
|
||||
/// the tests have no business touching the user's own settings.
|
||||
/// </summary>
|
||||
public sealed class SettingsServiceTests
|
||||
@@ -23,7 +23,7 @@ public sealed class SettingsServiceTests
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
|
||||
AppSettings settings = Sta.Run(() =>
|
||||
AppSettings settings = Pump.Run(() =>
|
||||
{
|
||||
using SettingsService service = Create(folder);
|
||||
return service.Load();
|
||||
@@ -43,7 +43,7 @@ public sealed class SettingsServiceTests
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
|
||||
AppSettings settings = Sta.Run(() =>
|
||||
AppSettings settings = Pump.Run(() =>
|
||||
{
|
||||
CultureInfo previous = CultureInfo.CurrentUICulture;
|
||||
try
|
||||
@@ -67,7 +67,7 @@ public sealed class SettingsServiceTests
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
|
||||
Sta.Run(() =>
|
||||
Pump.Run(() =>
|
||||
{
|
||||
using SettingsService service = Create(folder);
|
||||
AppSettings settings = service.Load();
|
||||
@@ -75,13 +75,13 @@ public sealed class SettingsServiceTests
|
||||
settings.FontSize = 42;
|
||||
settings.Theme = AppTheme.Dark;
|
||||
settings.PlacementMode = PopupPlacementMode.AtCaret;
|
||||
settings.BackgroundColor = Color.FromRgb(0x11, 0x22, 0x33);
|
||||
settings.BackgroundColor = Color.FromArgb(0x11, 0x22, 0x33);
|
||||
settings.UseCapsLockHotkey = true;
|
||||
|
||||
service.Save();
|
||||
});
|
||||
|
||||
AppSettings restored = Sta.Run(() =>
|
||||
AppSettings restored = Pump.Run(() =>
|
||||
{
|
||||
using SettingsService service = Create(folder);
|
||||
return service.Load();
|
||||
@@ -90,7 +90,7 @@ public sealed class SettingsServiceTests
|
||||
Assert.Equal(42, restored.FontSize);
|
||||
Assert.Equal(AppTheme.Dark, restored.Theme);
|
||||
Assert.Equal(PopupPlacementMode.AtCaret, restored.PlacementMode);
|
||||
Assert.Equal(Color.FromRgb(0x11, 0x22, 0x33), restored.BackgroundColor);
|
||||
Assert.Equal(Color.FromArgb(0x11, 0x22, 0x33), restored.BackgroundColor);
|
||||
Assert.True(restored.UseCapsLockHotkey);
|
||||
}
|
||||
|
||||
@@ -99,12 +99,12 @@ public sealed class SettingsServiceTests
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
|
||||
Sta.Run(() =>
|
||||
Pump.Run(() =>
|
||||
{
|
||||
using SettingsService service = Create(folder);
|
||||
AppSettings settings = service.Load();
|
||||
settings.Theme = AppTheme.Dark;
|
||||
settings.BackgroundColor = Color.FromRgb(0x20, 0x20, 0x20);
|
||||
settings.BackgroundColor = Color.FromArgb(0x20, 0x20, 0x20);
|
||||
service.Save();
|
||||
});
|
||||
|
||||
@@ -124,17 +124,18 @@ public sealed class SettingsServiceTests
|
||||
using var folder = new TempFolder();
|
||||
string path = folder.File("settings.json");
|
||||
|
||||
Sta.Run(() =>
|
||||
Pump.Run(() =>
|
||||
{
|
||||
using SettingsService service = Create(folder);
|
||||
AppSettings settings = service.Load();
|
||||
service.TrackChanges();
|
||||
|
||||
settings.FontSize = 33;
|
||||
|
||||
// Right after the edit there is nothing on disk yet: the write is deferred
|
||||
Assert.False(File.Exists(path));
|
||||
|
||||
Sta.WaitFor(() => File.Exists(path), "the settings were written by the timer");
|
||||
Pump.WaitFor(() => File.Exists(path), "the settings were written by the timer");
|
||||
});
|
||||
|
||||
Assert.Contains("\"FontSize\": 33", File.ReadAllText(path), StringComparison.Ordinal);
|
||||
@@ -147,19 +148,114 @@ public sealed class SettingsServiceTests
|
||||
using var folder = new TempFolder();
|
||||
string path = folder.File("settings.json");
|
||||
|
||||
Sta.Run(() =>
|
||||
Pump.Run(() =>
|
||||
{
|
||||
using SettingsService service = new(path, folder.File("inherited.json"), TimeSpan.FromMilliseconds(150));
|
||||
AppSettings settings = service.Load();
|
||||
service.TrackChanges();
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
settings.Opacity = 0.5 + (i * 0.01);
|
||||
Assert.False(File.Exists(path));
|
||||
Sta.Pause(TimeSpan.FromMilliseconds(10));
|
||||
Pump.Pause(TimeSpan.FromMilliseconds(10));
|
||||
}
|
||||
|
||||
Sta.WaitFor(() => File.Exists(path), "the write happened after the pause in edits");
|
||||
Pump.WaitFor(() => File.Exists(path), "the write happened after the pause in edits");
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asking to track changes before reading the file still tracks them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The settings window asks in exactly that order: its container hands out the
|
||||
/// service first and the settings only when something needs them. A version of this
|
||||
/// that quietly did nothing when the file had not been read yet left the window
|
||||
/// saving nothing at all — neither while it was open nor when it was closed.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void Tracking_asked_for_before_the_file_is_read_still_saves()
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
string path = folder.File("settings.json");
|
||||
|
||||
Pump.Run(() =>
|
||||
{
|
||||
using SettingsService service = Create(folder);
|
||||
|
||||
// Before Load, the way the settings window does it
|
||||
service.TrackChanges();
|
||||
|
||||
AppSettings settings = service.Load();
|
||||
settings.FontSize = 29;
|
||||
|
||||
Pump.WaitFor(() => File.Exists(path), "the settings were written by the timer");
|
||||
});
|
||||
|
||||
Assert.Contains("\"FontSize\": 29", File.ReadAllText(path), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
// Two reads would mean two instances, and the window would edit one while the
|
||||
// service saved the other
|
||||
[Fact]
|
||||
public void Reading_twice_hands_out_the_same_settings()
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
|
||||
Pump.Run(() =>
|
||||
{
|
||||
using SettingsService service = Create(folder);
|
||||
|
||||
Assert.Same(service.Load(), service.Load());
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-reading pours the file into the instance everything is already bound to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is the agent's whole side of the connection: the settings window writes and
|
||||
/// says so, and the agent calls this. Replacing the instance instead of filling it
|
||||
/// would leave the popup, the hook and the timers bound to the old one.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void Re_reading_lands_in_the_settings_already_in_hand()
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
string path = folder.File("settings.json");
|
||||
|
||||
Pump.Run(() =>
|
||||
{
|
||||
using SettingsService service = Create(folder);
|
||||
AppSettings settings = service.Load();
|
||||
|
||||
File.WriteAllText(path, """{"FontSize": 31, "BackgroundColor": "#FF102030"}""");
|
||||
service.Reload();
|
||||
|
||||
Assert.Equal(31, settings.FontSize);
|
||||
Assert.Equal(Color.FromArgb(0x10, 0x20, 0x30), settings.BackgroundColor);
|
||||
});
|
||||
}
|
||||
|
||||
// A file that has gone missing or turned to nonsense leaves the settings alone:
|
||||
// showing the popup with yesterday's colours beats showing it with none
|
||||
[Fact]
|
||||
public void Re_reading_an_unreadable_file_keeps_what_was_already_there()
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
string path = folder.File("settings.json");
|
||||
|
||||
Pump.Run(() =>
|
||||
{
|
||||
using SettingsService service = Create(folder);
|
||||
AppSettings settings = service.Load();
|
||||
settings.FontSize = 44;
|
||||
|
||||
File.WriteAllText(path, "not json at all");
|
||||
service.Reload();
|
||||
|
||||
Assert.Equal(44, settings.FontSize);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -168,10 +264,11 @@ public sealed class SettingsServiceTests
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
|
||||
Sta.Run(() =>
|
||||
Pump.Run(() =>
|
||||
{
|
||||
SettingsService service = Create(folder);
|
||||
AppSettings settings = service.Load();
|
||||
service.TrackChanges();
|
||||
settings.FontSize = 27;
|
||||
|
||||
service.Dispose();
|
||||
@@ -189,16 +286,17 @@ public sealed class SettingsServiceTests
|
||||
using var folder = new TempFolder();
|
||||
string path = folder.File("settings.json");
|
||||
|
||||
Sta.Run(() =>
|
||||
Pump.Run(() =>
|
||||
{
|
||||
SettingsService service = Create(folder);
|
||||
AppSettings settings = service.Load();
|
||||
service.TrackChanges();
|
||||
service.Dispose();
|
||||
|
||||
string afterDispose = File.ReadAllText(path);
|
||||
|
||||
settings.FontSize = 99;
|
||||
Sta.Pause(TimeSpan.FromMilliseconds(60));
|
||||
Pump.Pause(TimeSpan.FromMilliseconds(60));
|
||||
|
||||
Assert.Equal(afterDispose, File.ReadAllText(path));
|
||||
});
|
||||
@@ -213,7 +311,7 @@ public sealed class SettingsServiceTests
|
||||
|
||||
File.WriteAllText(inherited, """{"FontSize": 31, "Language": "ru"}""");
|
||||
|
||||
AppSettings settings = Sta.Run(() =>
|
||||
AppSettings settings = Pump.Run(() =>
|
||||
{
|
||||
using SettingsService service = new(own, inherited, SaveDelay);
|
||||
return service.Load();
|
||||
@@ -237,7 +335,7 @@ public sealed class SettingsServiceTests
|
||||
|
||||
File.WriteAllText(inherited, original);
|
||||
|
||||
Sta.Run(() =>
|
||||
Pump.Run(() =>
|
||||
{
|
||||
using SettingsService service = new(folder.File("settings.json"), inherited, SaveDelay);
|
||||
_ = service.Load();
|
||||
@@ -256,7 +354,7 @@ public sealed class SettingsServiceTests
|
||||
File.WriteAllText(own, """{"FontSize": 12}""");
|
||||
File.WriteAllText(inherited, """{"FontSize": 31}""");
|
||||
|
||||
AppSettings settings = Sta.Run(() =>
|
||||
AppSettings settings = Pump.Run(() =>
|
||||
{
|
||||
using SettingsService service = new(own, inherited, SaveDelay);
|
||||
return service.Load();
|
||||
@@ -272,7 +370,7 @@ public sealed class SettingsServiceTests
|
||||
using var folder = new TempFolder();
|
||||
string path = folder.File("settings.json");
|
||||
|
||||
Sta.Run(() =>
|
||||
Pump.Run(() =>
|
||||
{
|
||||
using SettingsService service = new(path, path, SaveDelay);
|
||||
_ = service.Load();
|
||||
@@ -288,7 +386,7 @@ public sealed class SettingsServiceTests
|
||||
using var folder = new TempFolder();
|
||||
File.WriteAllText(folder.File("settings.json"), "{this is not json");
|
||||
|
||||
AppSettings settings = Sta.Run(() =>
|
||||
AppSettings settings = Pump.Run(() =>
|
||||
{
|
||||
using SettingsService service = Create(folder);
|
||||
return service.Load();
|
||||
@@ -303,7 +401,7 @@ public sealed class SettingsServiceTests
|
||||
using var folder = new TempFolder();
|
||||
File.WriteAllText(folder.File("settings.json"), """{"FontSize": 15, "SomethingNew": true}""");
|
||||
|
||||
AppSettings settings = Sta.Run(() =>
|
||||
AppSettings settings = Pump.Run(() =>
|
||||
{
|
||||
using SettingsService service = Create(folder);
|
||||
return service.Load();
|
||||
@@ -321,13 +419,13 @@ public sealed class SettingsServiceTests
|
||||
using var folder = new TempFolder();
|
||||
File.WriteAllText(folder.File("settings.json"), $$"""{"BackgroundColor": {{stored}}}""");
|
||||
|
||||
AppSettings settings = Sta.Run(() =>
|
||||
AppSettings settings = Pump.Run(() =>
|
||||
{
|
||||
using SettingsService service = Create(folder);
|
||||
return service.Load();
|
||||
});
|
||||
|
||||
Assert.Equal(Color.FromRgb(r, g, b), settings.BackgroundColor);
|
||||
Assert.Equal(Color.FromArgb(r, g, b), settings.BackgroundColor);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -339,13 +437,13 @@ public sealed class SettingsServiceTests
|
||||
using var folder = new TempFolder();
|
||||
File.WriteAllText(folder.File("settings.json"), $$"""{"BackgroundColor": {{stored}}}""");
|
||||
|
||||
AppSettings settings = Sta.Run(() =>
|
||||
AppSettings settings = Pump.Run(() =>
|
||||
{
|
||||
using SettingsService service = Create(folder);
|
||||
return service.Load();
|
||||
});
|
||||
|
||||
Assert.Equal(Colors.Black, settings.BackgroundColor);
|
||||
Assert.Equal(Color.Black, settings.BackgroundColor);
|
||||
}
|
||||
|
||||
// The service creates the settings folder itself
|
||||
@@ -355,7 +453,7 @@ public sealed class SettingsServiceTests
|
||||
using var folder = new TempFolder();
|
||||
string nested = Path.Combine(folder.Path, "a", "b", "settings.json");
|
||||
|
||||
Sta.Run(() =>
|
||||
Pump.Run(() =>
|
||||
{
|
||||
using SettingsService service = new(nested, folder.File("inherited.json"), SaveDelay);
|
||||
_ = service.Load();
|
||||
@@ -375,7 +473,7 @@ public sealed class SettingsServiceTests
|
||||
string path = folder.File("settings.json");
|
||||
Directory.CreateDirectory(path);
|
||||
|
||||
Sta.Run(() =>
|
||||
Pump.Run(() =>
|
||||
{
|
||||
using SettingsService service = new(path, folder.File("inherited.json"), SaveDelay);
|
||||
AppSettings settings = service.Load();
|
||||
@@ -392,7 +490,7 @@ public sealed class SettingsServiceTests
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
|
||||
Sta.Run(() =>
|
||||
Pump.Run(() =>
|
||||
{
|
||||
using SettingsService service = Create(folder);
|
||||
service.Save();
|
||||
@@ -406,7 +504,7 @@ public sealed class SettingsServiceTests
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
|
||||
Sta.Run(() =>
|
||||
Pump.Run(() =>
|
||||
{
|
||||
SettingsService service = Create(folder);
|
||||
service.Dispose();
|
||||
@@ -420,7 +518,7 @@ public sealed class SettingsServiceTests
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
|
||||
Sta.Run(() =>
|
||||
Pump.Run(() =>
|
||||
{
|
||||
using SettingsService service = Create(folder);
|
||||
_ = service.Load();
|
||||
@@ -441,7 +539,7 @@ public sealed class SettingsServiceTests
|
||||
[Fact]
|
||||
public void The_storage_place_is_chosen_on_its_own()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
Pump.Run(() =>
|
||||
{
|
||||
// Nothing is read and nothing is written: only the fact that a path
|
||||
// gets chosen without error is under test
|
||||
+43
-40
@@ -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);
|
||||
|
||||
+40
-2
@@ -1,6 +1,7 @@
|
||||
using CursorLang.Services;
|
||||
using CursorLang.Core.Services;
|
||||
using CursorLang.Tests.Shared;
|
||||
|
||||
namespace CursorLang.Tests.Services;
|
||||
namespace CursorLang.Core.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Telling a launch by Windows apart from a launch by the user: the first one goes
|
||||
@@ -49,6 +50,8 @@ public sealed class StartupLaunchTests
|
||||
[Fact]
|
||||
public void The_startup_entry_is_written_with_the_argument()
|
||||
{
|
||||
using var agent = new StagedAgentExecutable();
|
||||
|
||||
string? command = RegistryStartup.GetCommand();
|
||||
|
||||
Assert.NotNull(command);
|
||||
@@ -57,4 +60,39 @@ public sealed class StartupLaunchTests
|
||||
// And the path itself stays quoted: it has spaces in it more often than not
|
||||
Assert.StartsWith("\"", command, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Windows must start the agent, whoever asked for it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The checkbox lives in the settings window, which is a process of its own. Were
|
||||
/// the entry written from the path of whoever is running, the startup list would
|
||||
/// hold the settings window — a process that shows a window and exits, instead of
|
||||
/// the one that is supposed to sit in the tray.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void The_startup_entry_names_the_agent_rather_than_whoever_wrote_it()
|
||||
{
|
||||
using var agent = new StagedAgentExecutable();
|
||||
|
||||
string? command = RegistryStartup.GetCommand();
|
||||
|
||||
Assert.NotNull(command);
|
||||
Assert.Contains("CursorLang.exe", command, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("CursorLang.Settings.exe", command, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
// Without an agent on disk there is nothing to put in the startup list, and
|
||||
// pointing Windows at a file that is not there would be worse than saying nothing
|
||||
[Fact]
|
||||
public void Without_an_agent_on_disk_there_is_no_entry_to_write()
|
||||
{
|
||||
string agent = Path.Combine(AppContext.BaseDirectory, "CursorLang.exe");
|
||||
if (File.Exists(agent))
|
||||
{
|
||||
Assert.Skip("The agent is built into the test output folder — nothing to check here");
|
||||
}
|
||||
|
||||
Assert.Null(RegistryStartup.GetCommand());
|
||||
}
|
||||
}
|
||||
+7
-4
@@ -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());
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
using CursorLang.Services;
|
||||
using CursorLang.Core.Services;
|
||||
|
||||
namespace CursorLang.Tests.Services;
|
||||
namespace CursorLang.Core.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Where the app looks for its releases. The values belong to the build, and a
|
||||
+4
-6
@@ -1,12 +1,10 @@
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using CursorLang.Models;
|
||||
using CursorLang.Services;
|
||||
using CursorLang.Tests.Infrastructure;
|
||||
using CursorLang.Core.Models;
|
||||
using CursorLang.Core.Services;
|
||||
using CursorLang.Tests.Shared;
|
||||
|
||||
namespace CursorLang.Tests.Services;
|
||||
namespace CursorLang.Core.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// What the app does with a release once it has found one: whether it is newer
|
||||
@@ -0,0 +1,129 @@
|
||||
using CursorLang.Core.Threading;
|
||||
using CursorLang.Tests.Shared;
|
||||
|
||||
namespace CursorLang.Core.Tests.Threading;
|
||||
|
||||
/// <summary>
|
||||
/// The timer the agent has instead of a dispatcher timer.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// It ticks on the message loop, so everything here runs on the pump thread — a timer
|
||||
/// started on one thread and awaited on another would never be seen to fire.
|
||||
/// </remarks>
|
||||
public sealed class MessageTimerTests
|
||||
{
|
||||
[Fact]
|
||||
public void A_started_timer_ticks()
|
||||
{
|
||||
int ticks = 0;
|
||||
|
||||
Pump.Run(() =>
|
||||
{
|
||||
using var timer = new MessageTimer();
|
||||
timer.Interval = TimeSpan.FromMilliseconds(15);
|
||||
timer.Tick += (_, _) => ticks++;
|
||||
timer.Start();
|
||||
|
||||
Pump.WaitFor(() => ticks > 0, "the timer ticked");
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_timer_keeps_ticking_until_it_is_stopped()
|
||||
{
|
||||
var ticks = 0;
|
||||
|
||||
Pump.Run(() =>
|
||||
{
|
||||
using var timer = new MessageTimer();
|
||||
timer.Interval = TimeSpan.FromMilliseconds(15);
|
||||
timer.Tick += (_, _) => ticks++;
|
||||
timer.Start();
|
||||
|
||||
Pump.WaitFor(() => ticks >= 3, "the timer ticked more than once");
|
||||
});
|
||||
}
|
||||
|
||||
// A stopped countdown does not go off, however long the loop runs afterwards
|
||||
[Fact]
|
||||
public void A_stopped_timer_does_not_tick()
|
||||
{
|
||||
var ticks = 0;
|
||||
|
||||
Pump.Run(() =>
|
||||
{
|
||||
using var timer = new MessageTimer();
|
||||
timer.Interval = TimeSpan.FromMilliseconds(10);
|
||||
timer.Tick += (_, _) => ticks++;
|
||||
timer.Start();
|
||||
timer.Stop();
|
||||
|
||||
Pump.Pause(TimeSpan.FromMilliseconds(80));
|
||||
});
|
||||
|
||||
Assert.Equal(0, ticks);
|
||||
}
|
||||
|
||||
// Restarting means from zero, so a countdown kept short by repeated restarts
|
||||
// never reaches its end
|
||||
[Fact]
|
||||
public void Restarting_begins_the_countdown_again()
|
||||
{
|
||||
var ticks = 0;
|
||||
|
||||
Pump.Run(() =>
|
||||
{
|
||||
using var timer = new MessageTimer();
|
||||
timer.Interval = TimeSpan.FromMilliseconds(60);
|
||||
timer.Tick += (_, _) => ticks++;
|
||||
|
||||
for (var i = 0; i < 6; i++)
|
||||
{
|
||||
timer.Start();
|
||||
Pump.Pause(TimeSpan.FromMilliseconds(20));
|
||||
}
|
||||
|
||||
Assert.Equal(0, ticks);
|
||||
|
||||
Pump.WaitFor(() => ticks > 0, "left alone, the timer reached its end");
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Closing_the_timer_ends_the_ticking()
|
||||
{
|
||||
var ticks = 0;
|
||||
|
||||
Pump.Run(() =>
|
||||
{
|
||||
var timer = new MessageTimer { Interval = TimeSpan.FromMilliseconds(15) };
|
||||
timer.Tick += (_, _) => ticks++;
|
||||
timer.Start();
|
||||
|
||||
Pump.WaitFor(() => ticks > 0, "the timer ticked");
|
||||
timer.Dispose();
|
||||
|
||||
int seen = ticks;
|
||||
Pump.Pause(TimeSpan.FromMilliseconds(80));
|
||||
|
||||
Assert.Equal(seen, ticks);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_timer_that_was_never_started_says_so()
|
||||
{
|
||||
Pump.Run(() =>
|
||||
{
|
||||
using var timer = new MessageTimer();
|
||||
|
||||
Assert.False(timer.IsRunning);
|
||||
|
||||
timer.Start();
|
||||
Assert.True(timer.IsRunning);
|
||||
|
||||
timer.Stop();
|
||||
Assert.False(timer.IsRunning);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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")]
|
||||
@@ -0,0 +1,40 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0-windows10.0.19041.0</TargetFramework>
|
||||
<SupportedOSPlatformVersion>10.0.17763.0</SupportedOSPlatformVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<RootNamespace>CursorLang.Core</RootNamespace>
|
||||
<AssemblyName>CursorLang.Core</AssemblyName>
|
||||
<PlatformTarget>AnyCPU</PlatformTarget>
|
||||
<RuntimeIdentifiers>win-x64;win-arm64</RuntimeIdentifiers>
|
||||
<Version>1.0.0</Version>
|
||||
<AssemblyVersion>1.0.0.0</AssemblyVersion>
|
||||
<FileVersion>1.0.0.0</FileVersion>
|
||||
<Product>CursorLang</Product>
|
||||
<Company>Aleksandr Neychev</Company>
|
||||
<Description>Shared part of CursorLang: models, settings, layout tracking, updates</Description>
|
||||
<Copyright>Copyright (c) 2026</Copyright>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<CaretUiAutomation Condition="'$(CaretUiAutomation)' == ''">true</CaretUiAutomation>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(CaretUiAutomation)' == 'true'">
|
||||
<DefineConstants>$(DefineConstants);CARET_UI_AUTOMATION</DefineConstants>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.WindowsDesktop.App.WPF" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,9 +1,11 @@
|
||||
using System.Runtime.InteropServices;
|
||||
#if CARET_UI_AUTOMATION
|
||||
using System.Windows.Automation;
|
||||
using System.Windows.Automation.Text;
|
||||
#endif
|
||||
using Accessibility;
|
||||
|
||||
namespace CursorLang.Interop;
|
||||
namespace CursorLang.Core.Interop;
|
||||
|
||||
/// <summary>
|
||||
/// Locates the caret in the active input field — including one in another application.
|
||||
@@ -12,6 +14,12 @@ namespace CursorLang.Interop;
|
||||
/// There is no single way to do it: classic Win32 applications create a system caret,
|
||||
/// while Chrome, Electron and others draw it themselves and report its position only
|
||||
/// through accessibility interfaces. So we ask the system first, then the application.
|
||||
///
|
||||
/// The UI Automation step is behind <c>CARET_UI_AUTOMATION</c>: it is the one part of
|
||||
/// the background process that reaches into the WPF half of the desktop runtime —
|
||||
/// TextPatternRange hands its rectangles back as System.Windows.Rect, which lives in
|
||||
/// WindowsBase — and it was measured at +3.9 MB private. It is also the last of the
|
||||
/// three steps and rarely reached. See the switch in CursorLang.Core.csproj.
|
||||
/// </remarks>
|
||||
internal static class CaretNative
|
||||
{
|
||||
@@ -95,6 +103,7 @@ internal static class CaretNative
|
||||
inner.Left >= outer.Left && inner.Right <= outer.Right &&
|
||||
inner.Top >= outer.Top && inner.Bottom <= outer.Bottom;
|
||||
|
||||
#if CARET_UI_AUTOMATION
|
||||
/// <summary>How long we wait for another application to answer over UI Automation.</summary>
|
||||
private static readonly TimeSpan AutomationTimeout = TimeSpan.FromMilliseconds(150);
|
||||
|
||||
@@ -154,6 +163,11 @@ internal static class CaretNative
|
||||
return null;
|
||||
}
|
||||
}
|
||||
#else
|
||||
// Built without UI Automation: Chromium and Electron keep the system caret and MSAA
|
||||
// steps above, and where those stay silent the popup falls back to the cursor
|
||||
private static PopupWindowNative.Rect? TryGetAutomationCaret() => null;
|
||||
#endif
|
||||
|
||||
// The system caret: its coordinates come relative to the window that owns it
|
||||
private static PopupWindowNative.Rect? TryGetSystemCaret(ForegroundInputNative.GuiThreadInfo info)
|
||||
+1
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-38
@@ -1,6 +1,6 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace CursorLang.Interop;
|
||||
namespace CursorLang.Core.Interop;
|
||||
|
||||
/// <summary>
|
||||
/// Win32 API for the popup window: styles, positioning near the cursor
|
||||
@@ -21,12 +21,6 @@ internal static class PopupWindowNative
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr GetForegroundWindow();
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern int GetWindowLong(IntPtr hWnd, int nIndex);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern int SetWindowLong(IntPtr hWnd, int nIndex, int dwNewLong);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter,
|
||||
int X, int Y, int cx, int cy, uint uFlags);
|
||||
@@ -61,12 +55,6 @@ internal static class PopupWindowNative
|
||||
public uint dwFlags;
|
||||
}
|
||||
|
||||
private const int GWL_EXSTYLE = -20;
|
||||
// The window does not take focus away from the active application
|
||||
private const int WS_EX_NOACTIVATE = 0x08000000;
|
||||
// And does not show up in Alt+Tab
|
||||
private const int WS_EX_TOOLWINDOW = 0x00000080;
|
||||
|
||||
private const uint SWP_NOSIZE = 0x0001;
|
||||
private const uint SWP_NOZORDER = 0x0004;
|
||||
private const uint SWP_NOACTIVATE = 0x0010;
|
||||
@@ -80,16 +68,6 @@ internal static class PopupWindowNative
|
||||
return cursor;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The popup shows up on top of other applications, so it must neither
|
||||
/// activate itself nor steal input focus from the active window.
|
||||
/// </summary>
|
||||
internal static void MakePassive(IntPtr hWnd)
|
||||
{
|
||||
int exStyle = GetWindowLong(hWnd, GWL_EXSTYLE);
|
||||
SetWindowLong(hWnd, GWL_EXSTYLE, exStyle | WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Moves the window to a screen point without changing its size or z-order.
|
||||
/// The coordinates are physical pixels: monitors have different scaling, while
|
||||
@@ -101,21 +79,6 @@ internal static class PopupWindowNative
|
||||
SetWindowPos(hWnd, IntPtr.Zero, x, y, 0, 0, SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the window position and size in physical pixels.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The size is set this way rather than through Width/Height: on the first show the
|
||||
/// window is still subject to the system minimum window size (SM_CXMIN×SM_CYMIN)
|
||||
/// and the popup comes out noticeably larger than its text. By the time this is
|
||||
/// called the window is already shown and has become a popup window, which that
|
||||
/// restriction does not apply to.
|
||||
/// </remarks>
|
||||
internal static void SetBounds(IntPtr hWnd, int x, int y, int width, int height)
|
||||
{
|
||||
SetWindowPos(hWnd, IntPtr.Zero, x, y, width, height, SWP_NOZORDER | SWP_NOACTIVATE);
|
||||
}
|
||||
|
||||
/// <summary>The scale of the monitor the point is on (1.0 at 96 DPI).</summary>
|
||||
internal static double GetScaleAt(Point point) =>
|
||||
GetScaleOf(MonitorFromPoint(point, MONITOR_DEFAULTTONEAREST));
|
||||
@@ -1,14 +1,23 @@
|
||||
using System.Drawing;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Windows.Media;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace CursorLang.Models;
|
||||
namespace CursorLang.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// The application settings. Every change applies on the fly: the popup and the
|
||||
/// settings window are bound to these properties, and <c>SettingsService</c> saves
|
||||
/// them to disk.
|
||||
/// The application settings, as the settings window writes them to settings.json.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The colours are <see cref="System.Drawing.Color"/> rather than
|
||||
/// <c>System.Windows.Media.Color</c>. The former lives in System.Drawing.Primitives,
|
||||
/// which is part of the base runtime and brings neither WPF nor GDI+ along; the latter
|
||||
/// is WindowsBase, and this type is read by the agent, which must stay clear of it.
|
||||
/// The settings window turns them into brushes in its converters.
|
||||
///
|
||||
/// Both processes hold an instance of this, but only the settings window writes: the
|
||||
/// agent re-reads the file and pours the fresh values into the instance it already has,
|
||||
/// so everything subscribed to it stays subscribed. See <see cref="CopyFrom"/>.
|
||||
/// </remarks>
|
||||
public sealed partial class AppSettings : ObservableObject
|
||||
{
|
||||
/// <summary>The interface language as a culture code: "ru", "en".</summary>
|
||||
@@ -94,11 +103,11 @@ public sealed partial class AppSettings : ObservableObject
|
||||
|
||||
/// <summary>The fill of the popup. The opacity is set by <see cref="Opacity"/>.</summary>
|
||||
[ObservableProperty]
|
||||
private Color _backgroundColor = Color.FromRgb(0x20, 0x20, 0x20);
|
||||
private Color _backgroundColor = Color.FromArgb(0xFF, 0x20, 0x20, 0x20);
|
||||
|
||||
/// <summary>The colour of the layout name in the popup.</summary>
|
||||
[ObservableProperty]
|
||||
private Color _foregroundColor = Color.FromRgb(0xFF, 0xFF, 0xFF);
|
||||
private Color _foregroundColor = Color.FromArgb(0xFF, 0xFF, 0xFF, 0xFF);
|
||||
|
||||
/// <summary><see cref="DurationMilliseconds"/> as a <see cref="TimeSpan"/>.</summary>
|
||||
[JsonIgnore]
|
||||
@@ -107,4 +116,35 @@ public sealed partial class AppSettings : ObservableObject
|
||||
/// <summary><see cref="CapsLockHoldMilliseconds"/> as a <see cref="TimeSpan"/>.</summary>
|
||||
[JsonIgnore]
|
||||
public TimeSpan CapsLockHoldDelay => TimeSpan.FromMilliseconds(CapsLockHoldMilliseconds);
|
||||
|
||||
/// <summary>
|
||||
/// Takes the values of another instance over, raising a change notification for
|
||||
/// every property that has actually moved.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is how the agent learns about an edit: the settings window is a separate
|
||||
/// process, so the fresh values arrive as a freshly parsed instance and are poured
|
||||
/// into the one everything is already bound to, rather than replacing it.
|
||||
/// </remarks>
|
||||
public void CopyFrom(AppSettings other)
|
||||
{
|
||||
Language = other.Language;
|
||||
Theme = other.Theme;
|
||||
PlacementMode = other.PlacementMode;
|
||||
CursorSide = other.CursorSide;
|
||||
CursorOffset = other.CursorOffset;
|
||||
CaretSide = other.CaretSide;
|
||||
CaretOffset = other.CaretOffset;
|
||||
ScreenPosition = other.ScreenPosition;
|
||||
ScreenMargin = other.ScreenMargin;
|
||||
FontSize = other.FontSize;
|
||||
Opacity = other.Opacity;
|
||||
DurationMilliseconds = other.DurationMilliseconds;
|
||||
UseCapsLockHotkey = other.UseCapsLockHotkey;
|
||||
CapsLockHoldMilliseconds = other.CapsLockHoldMilliseconds;
|
||||
CheckForUpdates = other.CheckForUpdates;
|
||||
LastUpdateCheck = other.LastUpdateCheck;
|
||||
BackgroundColor = other.BackgroundColor;
|
||||
ForegroundColor = other.ForegroundColor;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace CursorLang.Models;
|
||||
namespace CursorLang.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// The look of the settings window. By default the application follows the Windows
|
||||
@@ -1,6 +1,6 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace CursorLang.Models;
|
||||
namespace CursorLang.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// A keyboard layout in a form convenient for display.
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
namespace CursorLang.Models;
|
||||
namespace CursorLang.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Why the current layout has changed.
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace CursorLang.Models;
|
||||
namespace CursorLang.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// How the place for the popup is chosen.
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace CursorLang.Models;
|
||||
namespace CursorLang.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// A file attached to a release.
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace CursorLang.Models;
|
||||
namespace CursorLang.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// The state of startup. Follows <c>StartupTaskState</c> of Windows: the user and
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace CursorLang.Models;
|
||||
namespace CursorLang.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Which step the update is at. One value — one state of the interface:
|
||||
@@ -215,7 +215,7 @@
|
||||
<value>Check for updates</value>
|
||||
</data>
|
||||
<data name="UpdateAutoCheck" xml:space="preserve">
|
||||
<value>Check for updates at startup</value>
|
||||
<value>Check for updates</value>
|
||||
</data>
|
||||
<data name="UpdateChecking" xml:space="preserve">
|
||||
<value>Checking for updates…</value>
|
||||
@@ -215,7 +215,7 @@
|
||||
<value>Проверить обновления</value>
|
||||
</data>
|
||||
<data name="UpdateAutoCheck" xml:space="preserve">
|
||||
<value>Проверять обновления при запуске</value>
|
||||
<value>Проверять обновления</value>
|
||||
</data>
|
||||
<data name="UpdateChecking" xml:space="preserve">
|
||||
<value>Идёт проверка обновлений…</value>
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace CursorLang.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Where the background half of the application lives on disk.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Two processes now share one folder, and each of them at some point needs the path
|
||||
/// of the other: the settings window registers the agent for startup and must not
|
||||
/// register itself, and the agent starts the settings window from the tray menu.
|
||||
/// <c>Environment.ProcessPath</c> answers the wrong question for both, so the paths
|
||||
/// are worked out from the folder the assemblies were loaded from.
|
||||
/// </remarks>
|
||||
internal static class AgentExecutable
|
||||
{
|
||||
/// <summary>The background process — the one Windows starts at sign-in.</summary>
|
||||
internal const string AgentFileName = "CursorLang.exe";
|
||||
|
||||
/// <summary>The settings window, started on demand and gone when closed.</summary>
|
||||
internal const string SettingsFileName = "CursorLang.Settings.exe";
|
||||
|
||||
/// <summary>
|
||||
/// The full path of the agent, or <c>null</c> when it is not next to us — which
|
||||
/// happens in the tests and would happen to a half-copied installation.
|
||||
/// </summary>
|
||||
internal static string? AgentPath => Beside(AgentFileName);
|
||||
|
||||
/// <summary>The full path of the settings window, on the same terms.</summary>
|
||||
internal static string? SettingsPath => Beside(SettingsFileName);
|
||||
|
||||
private static string? Beside(string fileName)
|
||||
{
|
||||
string path = Path.Combine(AppContext.BaseDirectory, fileName);
|
||||
return File.Exists(path) ? path : null;
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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}");
|
||||
}
|
||||
+2
-4
@@ -1,11 +1,9 @@
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text.Json;
|
||||
using CursorLang.Models;
|
||||
using CursorLang.Core.Models;
|
||||
|
||||
namespace CursorLang.Services;
|
||||
namespace CursorLang.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea releases.
|
||||
+1
-1
@@ -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
|
||||
+2
-2
@@ -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 —
|
||||
+2
-2
@@ -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.
|
||||
+10
-3
@@ -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
-1
@@ -1,6 +1,6 @@
|
||||
using System.ComponentModel;
|
||||
|
||||
namespace CursorLang.Services;
|
||||
namespace CursorLang.Core.Services;
|
||||
|
||||
/// <summary>An interface language to choose from in the settings.</summary>
|
||||
/// <param name="Code">The culture code: "ru", "en".</param>
|
||||
@@ -1,7 +1,6 @@
|
||||
using System.Net.Http;
|
||||
using CursorLang.Models;
|
||||
using CursorLang.Core.Models;
|
||||
|
||||
namespace CursorLang.Services;
|
||||
namespace CursorLang.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// The release list of the repository.
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
using CursorLang.Models;
|
||||
using CursorLang.Core.Models;
|
||||
|
||||
namespace CursorLang.Services;
|
||||
namespace CursorLang.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Starting the app together with Windows.
|
||||
@@ -1,6 +1,6 @@
|
||||
using CursorLang.Models;
|
||||
using CursorLang.Core.Models;
|
||||
|
||||
namespace CursorLang.Services;
|
||||
namespace CursorLang.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Checking for and installing new versions of the application.
|
||||
+10
-7
@@ -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();
|
||||
+2
-2
@@ -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.
|
||||
+15
-4
@@ -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.
|
||||
+12
-9
@@ -1,9 +1,8 @@
|
||||
using System.IO;
|
||||
using System.Security;
|
||||
using CursorLang.Models;
|
||||
using CursorLang.Core.Models;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace CursorLang.Services;
|
||||
namespace CursorLang.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Startup for a build that is not a package: a value under the Run key.
|
||||
@@ -112,16 +111,20 @@ internal sealed class RegistryStartup
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What Windows is to run. <c>null</c> — the path of the running program is
|
||||
/// unknown, and there is nothing to write down.
|
||||
/// What Windows is to run. <c>null</c> — the agent is not where it should be, and
|
||||
/// there is nothing to write down.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The argument is how the application recognises a launch of this kind and goes
|
||||
/// straight to the tray: see <see cref="StartupLaunch"/>. The user starting the
|
||||
/// application themselves passes no such thing and gets the window.
|
||||
/// The agent by name rather than <c>Environment.ProcessPath</c>: this setting is
|
||||
/// switched from the settings window, and its own path would put the wrong process
|
||||
/// into the startup list — one that shows a window and exits.
|
||||
///
|
||||
/// The argument is how the agent recognises a launch of this kind and goes straight
|
||||
/// to the tray without the settings window: see <see cref="StartupLaunch"/>. The
|
||||
/// user starting the application themselves passes no such thing and gets the window.
|
||||
/// </remarks>
|
||||
internal static string? GetCommand() =>
|
||||
Environment.ProcessPath is { Length: > 0 } path
|
||||
AgentExecutable.AgentPath is { Length: > 0 } path
|
||||
? $"\"{path}\" {StartupLaunch.Argument}"
|
||||
: null;
|
||||
}
|
||||
+90
-56
@@ -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);
|
||||
}
|
||||
+25
-17
@@ -1,7 +1,6 @@
|
||||
using System.Windows.Threading;
|
||||
using CursorLang.Interop;
|
||||
using CursorLang.Core.Interop;
|
||||
|
||||
namespace CursorLang.Services;
|
||||
namespace CursorLang.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Lets only one instance of the application run: a second launch does not bring up
|
||||
@@ -13,13 +12,22 @@ namespace CursorLang.Services;
|
||||
/// odd picture with fast user switching: the second user would be left without the
|
||||
/// application, and showing them the window of the first one is impossible anyway —
|
||||
/// windows belong to a session.
|
||||
///
|
||||
/// Two processes use this now, and each guards its own slot: the agent so that one
|
||||
/// background process runs, the settings window so that a second "Settings" from the
|
||||
/// tray raises the window already open instead of a second one. Hence the name part.
|
||||
/// </remarks>
|
||||
public sealed class SingleInstanceGate : IDisposable
|
||||
{
|
||||
/// <summary>The agent's slot — one background process per session.</summary>
|
||||
public const string AgentName = ".Agent";
|
||||
|
||||
/// <summary>The settings window's slot — one window per session.</summary>
|
||||
public const string SettingsName = ".Settings";
|
||||
|
||||
private const string MutexName = "CursorLang.SingleInstance";
|
||||
private const string ActivationEventName = "CursorLang.ActivationRequest";
|
||||
|
||||
private readonly Dispatcher _dispatcher = Dispatcher.CurrentDispatcher;
|
||||
private readonly string _mutexName;
|
||||
private readonly string _activationEventName;
|
||||
|
||||
@@ -28,23 +36,25 @@ public sealed class SingleInstanceGate : IDisposable
|
||||
private RegisteredWaitHandle? _activationWait;
|
||||
private bool _isOwner;
|
||||
|
||||
public SingleInstanceGate()
|
||||
: this(string.Empty)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a distinguishing part to the kernel object names. Needed by the tests:
|
||||
/// otherwise they would share the single-instance slot with the running
|
||||
/// application and get in its way.
|
||||
/// Takes a named slot. The name tells the agent's slot from the settings window's,
|
||||
/// and the tests use one of their own: otherwise they would share a slot with the
|
||||
/// running application and get in its way.
|
||||
/// </summary>
|
||||
internal SingleInstanceGate(string nameSuffix)
|
||||
public SingleInstanceGate(string nameSuffix)
|
||||
{
|
||||
_mutexName = MutexName + nameSuffix;
|
||||
_activationEventName = ActivationEventName + nameSuffix;
|
||||
}
|
||||
|
||||
/// <summary>Another launch asks for the window to be shown.</summary>
|
||||
/// <summary>
|
||||
/// Another launch asks for the window to be shown.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Raised on a thread pool thread, wherever the wait happened to be answered. The
|
||||
/// two hosts get back to their own thread differently — one through the dispatcher,
|
||||
/// one by posting to its window — so neither is assumed here.
|
||||
/// </remarks>
|
||||
public event EventHandler? ActivationRequested;
|
||||
|
||||
/// <summary>
|
||||
@@ -124,8 +134,6 @@ public sealed class SingleInstanceGate : IDisposable
|
||||
_mutex = null;
|
||||
}
|
||||
|
||||
// The thread pool reports the request from wherever it happens to be, while the
|
||||
// window obeys only its own thread
|
||||
private void OnActivationSignalled(object? state, bool timedOut) =>
|
||||
_dispatcher.BeginInvoke(() => ActivationRequested?.Invoke(this, EventArgs.Empty));
|
||||
ActivationRequested?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using CursorLang.Interop;
|
||||
using CursorLang.Core.Interop;
|
||||
using Windows.ApplicationModel;
|
||||
using Windows.ApplicationModel.Activation;
|
||||
|
||||
namespace CursorLang.Services;
|
||||
namespace CursorLang.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Whether Windows started the application by itself.
|
||||
@@ -1,9 +1,9 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using CursorLang.Interop;
|
||||
using CursorLang.Models;
|
||||
using CursorLang.Core.Interop;
|
||||
using CursorLang.Core.Models;
|
||||
using Windows.ApplicationModel;
|
||||
|
||||
namespace CursorLang.Services;
|
||||
namespace CursorLang.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Startup, arranged by whatever means the current build has.
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace CursorLang.Services;
|
||||
namespace CursorLang.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Where the application learns about new versions from.
|
||||
@@ -1,14 +1,12 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
using CursorLang.Interop;
|
||||
using CursorLang.Models;
|
||||
using CursorLang.Core.Interop;
|
||||
using CursorLang.Core.Models;
|
||||
using Windows.ApplicationModel;
|
||||
|
||||
namespace CursorLang.Services;
|
||||
namespace CursorLang.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Learns about new versions from the repository and hands the downloaded package
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace CursorLang.Core.Threading;
|
||||
|
||||
/// <summary>
|
||||
/// A timer that ticks on the message loop — the agent's stand-in for
|
||||
/// <c>DispatcherTimer</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>SetTimer</c> with a null window binds the timer to the thread rather than to a
|
||||
/// window, and <c>DispatchMessage</c> calls the callback straight from the loop. The
|
||||
/// upshot is the same as with a dispatcher timer: the tick arrives on the thread that
|
||||
/// owns the hook and the popup, so nothing needs marshalling and nothing races.
|
||||
///
|
||||
/// The callback lives in a field for the reason a hook procedure does: the only
|
||||
/// reference to it is held by Win32, and a collected delegate takes the process down
|
||||
/// with it at the first tick.
|
||||
/// </remarks>
|
||||
internal sealed class MessageTimer : IDisposable
|
||||
{
|
||||
/// <summary>Windows will not go below this, and pretending otherwise misleads.</summary>
|
||||
private const uint MinimumIntervalMilliseconds = 10;
|
||||
|
||||
private readonly TimerProc _callback;
|
||||
|
||||
private nuint _id;
|
||||
|
||||
internal MessageTimer() => _callback = OnTimer;
|
||||
|
||||
internal event EventHandler? Tick;
|
||||
|
||||
internal TimeSpan Interval { get; set; } = TimeSpan.FromMilliseconds(100);
|
||||
|
||||
internal bool IsRunning => _id != 0;
|
||||
|
||||
/// <summary>Starts the timer, or restarts it from zero when it is already running.</summary>
|
||||
internal void Start()
|
||||
{
|
||||
Stop();
|
||||
|
||||
var milliseconds = (uint)Math.Clamp(
|
||||
Math.Round(Interval.TotalMilliseconds), MinimumIntervalMilliseconds, int.MaxValue);
|
||||
|
||||
_id = SetTimer(IntPtr.Zero, 0, milliseconds, _callback);
|
||||
}
|
||||
|
||||
internal void Stop()
|
||||
{
|
||||
if (_id == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
KillTimer(IntPtr.Zero, _id);
|
||||
_id = 0;
|
||||
}
|
||||
|
||||
public void Dispose() => Stop();
|
||||
|
||||
private void OnTimer(IntPtr window, uint message, nuint id, uint time) =>
|
||||
Tick?.Invoke(this, EventArgs.Empty);
|
||||
|
||||
private delegate void TimerProc(IntPtr hWnd, uint message, nuint idEvent, uint time);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern nuint SetTimer(IntPtr hWnd, nuint nIDEvent, uint uElapse, TimerProc lpTimerFunc);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern bool KillTimer(IntPtr hWnd, nuint uIDEvent);
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
using CursorLang.Models;
|
||||
using CursorLang.Services;
|
||||
using CursorLang.ViewModels;
|
||||
using CursorLang.Views;
|
||||
using CursorLang.Core.Models;
|
||||
using CursorLang.Core.Services;
|
||||
using CursorLang.Settings.Services;
|
||||
using CursorLang.Settings.ViewModels;
|
||||
using CursorLang.Settings.Views;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace CursorLang.Tests;
|
||||
namespace CursorLang.Settings.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// What the container is made of. The tests cannot build the application whole
|
||||
/// — it would raise windows and take the place of the single instance — but
|
||||
/// checking that everything needed is declared and resolvable works without that.
|
||||
/// What the container of the settings process is made of. The tests cannot build the
|
||||
/// application whole — it would raise a window and take the place of the single
|
||||
/// instance — but checking that everything needed is declared and resolvable works
|
||||
/// without that.
|
||||
/// </summary>
|
||||
public sealed class AppTests
|
||||
{
|
||||
@@ -18,34 +20,41 @@ public sealed class AppTests
|
||||
[InlineData(typeof(ThemeService))]
|
||||
[InlineData(typeof(IThemeService))]
|
||||
[InlineData(typeof(MainWindowPlacement))]
|
||||
[InlineData(typeof(MainWindowPresenter))]
|
||||
[InlineData(typeof(TrayIcon))]
|
||||
[InlineData(typeof(ITrayIcon))]
|
||||
[InlineData(typeof(ILocalizationService))]
|
||||
[InlineData(typeof(IStartupService))]
|
||||
[InlineData(typeof(IUpdateService))]
|
||||
[InlineData(typeof(UpdateOptions))]
|
||||
[InlineData(typeof(IKeyboardLayoutService))]
|
||||
[InlineData(typeof(ILayoutPopupService))]
|
||||
[InlineData(typeof(ICapsLockHotkeyService))]
|
||||
[InlineData(typeof(ILayoutPopupWindow))]
|
||||
[InlineData(typeof(LayoutNotificationCoordinator))]
|
||||
[InlineData(typeof(CapsLockSwitchCoordinator))]
|
||||
[InlineData(typeof(LayoutPopupViewModel))]
|
||||
[InlineData(typeof(SettingsViewModel))]
|
||||
[InlineData(typeof(UpdateViewModel))]
|
||||
[InlineData(typeof(LayoutPopupWindow))]
|
||||
[InlineData(typeof(MainWindow))]
|
||||
[InlineData(typeof(KeyboardLayoutOptions))]
|
||||
[InlineData(typeof(AppSettings))]
|
||||
public void Everything_the_app_needs_is_declared_in_the_container(Type service)
|
||||
public void Everything_the_window_needs_is_declared_in_the_container(Type service)
|
||||
{
|
||||
Assert.Contains(Describe(), descriptor => descriptor.ServiceType == service);
|
||||
}
|
||||
|
||||
// The settings, the tooltip and the layout watch have to be shared by the
|
||||
// whole application: a second copy of them would mean a second
|
||||
// tooltip or lost settings
|
||||
/// <summary>
|
||||
/// The background half is not in here, and must not be.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The hook, the popup and the layout polling belong to the agent process now. A
|
||||
/// registration of any of them here would mean two applications watching the
|
||||
/// keyboard at once — and the second of them holding WPF while it did so.
|
||||
/// </remarks>
|
||||
[Theory]
|
||||
[InlineData("KeyboardLayoutService")]
|
||||
[InlineData("LayoutPopupService")]
|
||||
[InlineData("CapsLockHotkeyService")]
|
||||
[InlineData("LayoutNotificationCoordinator")]
|
||||
[InlineData("CapsLockSwitchCoordinator")]
|
||||
[InlineData("TrayIcon")]
|
||||
public void The_background_half_is_not_in_the_settings_container(string name)
|
||||
{
|
||||
Assert.DoesNotContain(Describe(), descriptor => descriptor.ServiceType.Name.Contains(name));
|
||||
}
|
||||
|
||||
// The settings and the theme have to be shared by the whole window: a second copy
|
||||
// of them would mean lost edits or half the controls in the wrong colours
|
||||
[Fact]
|
||||
public void Everything_in_the_container_is_declared_as_a_single_copy()
|
||||
{
|
||||
@@ -94,24 +103,6 @@ public sealed class AppTests
|
||||
Assert.NotNull(settings.ImplementationFactory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_tooltip_window_and_its_interface_are_one_window()
|
||||
{
|
||||
ServiceDescriptor window = Describe()
|
||||
.Single(descriptor => descriptor.ServiceType == typeof(ILayoutPopupWindow));
|
||||
|
||||
Assert.NotNull(window.ImplementationFactory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_tray_icon_and_its_interface_are_one_icon()
|
||||
{
|
||||
ServiceDescriptor tray = Describe()
|
||||
.Single(descriptor => descriptor.ServiceType == typeof(ITrayIcon));
|
||||
|
||||
Assert.NotNull(tray.ImplementationFactory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_theme_and_its_interface_are_one_service()
|
||||
{
|
||||
+13
-16
@@ -13,9 +13,14 @@
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<NoWarn>$(NoWarn);IDE0130</NoWarn>
|
||||
<RootNamespace>CursorLang.Settings.Tests</RootNamespace>
|
||||
<AssemblyName>CursorLang.Settings.Tests</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3" Version="3.2.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
|
||||
@@ -24,22 +29,15 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\CursorLang\CursorLang.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AssemblyAttribute Include="System.Reflection.AssemblyMetadataAttribute">
|
||||
<_Parameter1>CursorLangExecutable</_Parameter1>
|
||||
<_Parameter2>$(MSBuildThisFileDirectory)..\CursorLang\bin\$(Configuration)\$(TargetFramework)\CursorLang.exe</_Parameter2>
|
||||
</AssemblyAttribute>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
<ProjectReference Include="..\CursorLang.Core\CursorLang.Core.csproj" />
|
||||
<ProjectReference Include="..\CursorLang.Tests.Shared\CursorLang.Tests.Shared.csproj" />
|
||||
<ProjectReference Include="..\CursorLang.Settings\CursorLang.Settings.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- The markup is embedded as plain text so that the check for missing resource
|
||||
keys reads what the window really says, without walking the folder tree -->
|
||||
<PropertyGroup>
|
||||
<MainWindowMarkup>$(MSBuildThisFileDirectory)..\CursorLang\Views\MainWindow.xaml</MainWindowMarkup>
|
||||
<MainWindowMarkup>$(MSBuildThisFileDirectory)..\CursorLang.Settings\Views\MainWindow.xaml</MainWindowMarkup>
|
||||
</PropertyGroup>
|
||||
|
||||
<Target Name="EmbedMainWindowMarkup" BeforeTargets="PrepareForBuild">
|
||||
@@ -52,8 +50,7 @@
|
||||
SkipUnchangedFiles="true" />
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="$(MainWindowMarkupCopy)"
|
||||
LogicalName="MainWindow.xaml" />
|
||||
<EmbeddedResource Include="$(MainWindowMarkupCopy)" LogicalName="MainWindow.xaml" />
|
||||
<FileWrites Include="$(MainWindowMarkupCopy)" />
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
@@ -0,0 +1,28 @@
|
||||
using CursorLang.Core.Models;
|
||||
using CursorLang.Core.Services;
|
||||
using CursorLang.Settings.Services;
|
||||
using CursorLang.Settings.ViewModels;
|
||||
using CursorLang.Tests.Shared;
|
||||
|
||||
namespace CursorLang.Settings.Tests.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Parts every test needs but few tests care about.
|
||||
/// </summary>
|
||||
internal static class Fake
|
||||
{
|
||||
internal static UpdateViewModel Updates() =>
|
||||
new(new FakeUpdateService(), new FakeLocalizationService(), new AppSettings(), new UpdateOptions());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A theme that paints nothing and only remembers the windows attached to it.
|
||||
/// </summary>
|
||||
internal sealed class FakeThemeService : IThemeService
|
||||
{
|
||||
public AppTheme CurrentTheme { get; set; } = AppTheme.Light;
|
||||
|
||||
internal List<System.Windows.Window> Registered { get; } = [];
|
||||
|
||||
public void Register(System.Windows.Window window) => Registered.Add(window);
|
||||
}
|
||||
+2
-2
@@ -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),
|
||||
});
|
||||
|
||||
+8
-30
@@ -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");
|
||||
});
|
||||
}
|
||||
|
||||
+4
-35
@@ -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()
|
||||
{
|
||||
+5
-4
@@ -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
|
||||
+9
-5
@@ -3,12 +3,12 @@ using System.Windows.Controls;
|
||||
using System.Windows.Interop;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Shapes;
|
||||
using CursorLang.Models;
|
||||
using CursorLang.Services;
|
||||
using CursorLang.Tests.Infrastructure;
|
||||
using CursorLang.Core.Models;
|
||||
using CursorLang.Settings.Services;
|
||||
using CursorLang.Settings.Tests.Infrastructure;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace CursorLang.Tests.Services;
|
||||
namespace CursorLang.Settings.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// The look of the windows. The palette lives in the application resources,
|
||||
@@ -325,7 +325,11 @@ public sealed class ThemeServiceTests
|
||||
[Fact]
|
||||
public void The_palette_address_names_the_application_assembly()
|
||||
{
|
||||
Assert.Contains("CursorLang;component", ThemeService.PaletteUri(AppTheme.Dark).ToString(), StringComparison.Ordinal);
|
||||
// The palettes live with the settings window, not with the agent next to it
|
||||
Assert.Contains(
|
||||
"CursorLang.Settings;component",
|
||||
ThemeService.PaletteUri(AppTheme.Dark).ToString(),
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
+3
-3
@@ -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
|
||||
{
|
||||
+7
-7
@@ -1,10 +1,10 @@
|
||||
using System.Windows.Media;
|
||||
using CursorLang.Models;
|
||||
using CursorLang.Services;
|
||||
using CursorLang.Tests.Infrastructure;
|
||||
using CursorLang.ViewModels;
|
||||
using CursorLang.Core.Models;
|
||||
using CursorLang.Core.Services;
|
||||
using CursorLang.Settings.Tests.Infrastructure;
|
||||
using CursorLang.Settings.ViewModels;
|
||||
using CursorLang.Tests.Shared;
|
||||
|
||||
namespace CursorLang.Tests.ViewModels;
|
||||
namespace CursorLang.Settings.Tests.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// The settings window in terms of what it shows and what it is in charge of.
|
||||
@@ -133,7 +133,7 @@ public sealed class SettingsViewModelTests
|
||||
{
|
||||
using SettingsViewModel viewModel = Create();
|
||||
|
||||
Assert.All(viewModel.BackgroundPalette, color => Assert.IsType<Color>(color));
|
||||
Assert.All(viewModel.BackgroundPalette, color => Assert.IsType<System.Drawing.Color>(color));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user