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