From 4662ac8312e3d5988b6422ee2272810ff7949552 Mon Sep 17 00:00:00 2001 From: Dimitar Radenkov Date: Sun, 19 Jul 2026 09:41:17 +0300 Subject: [PATCH 1/6] Refactor settings persistence and hotkey management; add round-trip tests for UserSettings --- .../Services/SettingsRoundTripTests.cs | 194 ++++++++++++++ Pointframe/Models/HotkeyBinding.cs | 38 +++ .../Infrastructure/UserSettingsService.cs | 76 +----- Pointframe/ViewModels/OverlayViewModel.cs | 40 +-- Pointframe/ViewModels/SettingsViewModel.cs | 237 +++++------------- Pointframe/Views/SettingsWindow.xaml.cs | 158 ++---------- 6 files changed, 328 insertions(+), 415 deletions(-) create mode 100644 Pointframe.Tests/Services/SettingsRoundTripTests.cs create mode 100644 Pointframe/Models/HotkeyBinding.cs diff --git a/Pointframe.Tests/Services/SettingsRoundTripTests.cs b/Pointframe.Tests/Services/SettingsRoundTripTests.cs new file mode 100644 index 0000000..b968384 --- /dev/null +++ b/Pointframe.Tests/Services/SettingsRoundTripTests.cs @@ -0,0 +1,194 @@ +using System.IO; +using System.Text.Json; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Pointframe.Models; +using Pointframe.Services; +using Pointframe.ViewModels; +using Xunit; + +namespace Pointframe.Tests.Services; + +// Guards the documented settings trap: a UserSettings property that is not carried +// through every persistence path (service save/load, Update's clone, SettingsViewModel.Save) +// is silently dropped. CreateFullyPopulatedSettings must set every property to a +// non-default value; the guard test enforces that, and the round-trip tests then prove +// no path drops a value. When adding a new setting, extend CreateFullyPopulatedSettings — +// the guard test fails until you do. +public sealed class SettingsRoundTripTests : IDisposable +{ + private readonly string _tempDirectory = Path.Combine( + Path.GetTempPath(), + "SnippingTool.Tests", + Guid.NewGuid().ToString("N")); + + [Fact] + public void CreateFullyPopulatedSettings_DiffersFromDefaultsOnEveryProperty() + { + var populated = CreateFullyPopulatedSettings(); + var defaults = new UserSettings(); + + foreach (var property in typeof(UserSettings).GetProperties()) + { + var populatedValue = property.GetValue(populated); + var defaultValue = property.GetValue(defaults); + + if (populatedValue is WatermarkSettings populatedWatermark) + { + AssertWatermarkFullyPopulated(property.Name, populatedWatermark); + continue; + } + + Assert.False( + JsonSerializer.Serialize(populatedValue) == JsonSerializer.Serialize(defaultValue), + $"CreateFullyPopulatedSettings must set UserSettings.{property.Name} to a non-default value " + + "so the round-trip tests can detect a persistence path dropping it."); + } + } + + [Fact] + public void SaveAndReload_PreservesEveryProperty() + { + var settingsPath = Path.Combine(_tempDirectory, "settings.json"); + var populated = CreateFullyPopulatedSettings(); + + new UserSettingsService(NullLogger.Instance, settingsPath).Save(populated); + var reloaded = new UserSettingsService(NullLogger.Instance, settingsPath); + + Assert.Equal(ToJson(populated), ToJson(reloaded.Current)); + } + + [Fact] + public void Update_WithNoOpMutation_PreservesEveryProperty() + { + var settingsPath = Path.Combine(_tempDirectory, "settings.json"); + var sut = new UserSettingsService(NullLogger.Instance, settingsPath); + sut.Save(CreateFullyPopulatedSettings()); + var before = ToJson(sut.Current); + + sut.Update(_ => { }); + + Assert.Equal(before, ToJson(sut.Current)); + } + + [Fact] + public void SettingsViewModel_Save_PreservesEveryProperty() + { + var populated = CreateFullyPopulatedSettings(); + var settingsService = new Mock(); + settingsService.SetupGet(s => s.Current).Returns(populated); + UserSettings? saved = null; + settingsService.Setup(s => s.Save(It.IsAny())).Callback(s => saved = s); + var microphoneService = Mock.Of(service => + service.GetAvailableCaptureDeviceNames() == new[] { populated.RecordingMicrophoneDeviceName! } && + service.GetDefaultCaptureDeviceName() == populated.RecordingMicrophoneDeviceName); + var vm = new SettingsViewModel( + settingsService.Object, + Mock.Of(), + Mock.Of(), + microphoneService); + + vm.SaveCommand.Execute(null); + + Assert.NotNull(saved); + Assert.Equal(ToJson(populated), ToJson(saved!)); + } + + // Values must survive SettingsViewModel's load/save transformations unchanged: + // colors in canonical #AARRGGBB uppercase form, cursor highlight size inside the + // 8..96 clamp range, microphone device name present in the mocked device list, and + // ScreenshotWatermark equal to VideoWatermark (the VM edits one shared watermark state). + private static UserSettings CreateFullyPopulatedSettings() + { + return new UserSettings + { + ScreenshotSavePath = @"C:\changed\screenshots", + AutoSaveScreenshots = false, + RecordingOutputPath = @"C:\changed\videos", + RecordMicrophone = false, + RecordingMicrophoneDeviceName = "Changed Mic", + RecordingFps = 60, + GifFps = 15, + HudGapPixels = 12, + RecordingCursorHighlightEnabled = false, + RecordingClickRippleEnabled = false, + RecordingCursorHighlightSize = 42d, + DefaultAnnotationColor = "#FF336699", + DefaultStrokeThickness = 5.5, + CaptureDelaySeconds = 5, + RegionCaptureHotkey = 0x41, + RegionCaptureHotkeyModifiers = HotkeyModifiers.Alt, + WholeScreenRecordHotkey = 0x42, + WholeScreenRecordHotkeyModifiers = HotkeyModifiers.Ctrl, + CleanWindowCaptureHotkey = 0x44, + CleanWindowCaptureHotkeyModifiers = HotkeyModifiers.Alt, + OverlayCopyHotkey = 0x31, + OverlayCopyHotkeyModifiers = HotkeyModifiers.Alt, + OverlaySaveAsHotkey = 0x32, + OverlaySaveAsHotkeyModifiers = HotkeyModifiers.Alt, + OverlayUndoHotkey = 0x33, + OverlayUndoHotkeyModifiers = HotkeyModifiers.Alt, + OverlayRedoHotkey = 0x34, + OverlayRedoHotkeyModifiers = HotkeyModifiers.Alt, + OverlayToggleShortcutsHotkey = 0x35, + OverlayToggleShortcutsHotkeyModifiers = HotkeyModifiers.Alt, + OverlayCloseHotkey = 0x36, + OverlayCloseHotkeyModifiers = HotkeyModifiers.Alt, + AutoUpdateCheckInterval = UpdateCheckInterval.EveryDay, + LastAutoUpdateCheckUtc = new DateTime(2026, 5, 4, 3, 2, 1, DateTimeKind.Utc), + Theme = AppTheme.Dark, + StylePresets = + [ + new AnnotationStylePreset { Name = "Changed A", Color = "#FF112233", StrokeThickness = 4.5 }, + new AnnotationStylePreset { Name = "Changed B", Color = "#FF445566", StrokeThickness = 6.5 }, + ], + ScreenshotWatermark = CreatePopulatedWatermark(), + VideoWatermark = CreatePopulatedWatermark(), + InstallId = "changed-install-id", + InstallCreatedUtc = new DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc), + FirstCaptureCompletedTracked = true, + FirstRecordingCompletedTracked = true, + }; + } + + private static TWatermark CreatePopulatedWatermark() + where TWatermark : WatermarkSettings, new() + { + return new TWatermark + { + Enabled = true, + TextTemplate = WatermarkTextTemplate.TimeOnly, + Position = WatermarkPosition.TopLeft, + FontSize = 24, + ColorHex = "#FFABCDEF", + BackgroundEnabled = false, + Opacity = 0.7, + Margin = 21, + ApplyToCopy = false, + ApplyToSave = false, + }; + } + + private static void AssertWatermarkFullyPopulated(string propertyName, WatermarkSettings populated) + { + var defaults = new WatermarkSettings(); + foreach (var property in typeof(WatermarkSettings).GetProperties()) + { + Assert.False( + JsonSerializer.Serialize(property.GetValue(populated)) == JsonSerializer.Serialize(property.GetValue(defaults)), + $"CreateFullyPopulatedSettings must set UserSettings.{propertyName}.{property.Name} to a non-default value " + + "so the round-trip tests can detect a persistence path dropping it."); + } + } + + private static string ToJson(UserSettings settings) => + JsonSerializer.Serialize(settings, new JsonSerializerOptions { WriteIndented = true }); + + public void Dispose() + { + if (Directory.Exists(_tempDirectory)) + { + Directory.Delete(_tempDirectory, recursive: true); + } + } +} diff --git a/Pointframe/Models/HotkeyBinding.cs b/Pointframe/Models/HotkeyBinding.cs new file mode 100644 index 0000000..d4b4396 --- /dev/null +++ b/Pointframe/Models/HotkeyBinding.cs @@ -0,0 +1,38 @@ +using System.Windows.Input; + +namespace Pointframe.Models; + +public readonly record struct HotkeyBinding(uint Key, HotkeyModifiers Modifiers) +{ + private const uint VkSnapshot = 0x2C; // KeyInterop maps VK_SNAPSHOT to "Snapshot", not the key's engraved name + + public string DisplayName + { + get + { + if (Key == 0) + { + return "Not set"; + } + + var parts = new List(); + if (Modifiers.HasFlag(HotkeyModifiers.Ctrl)) + { + parts.Add("Ctrl"); + } + + if (Modifiers.HasFlag(HotkeyModifiers.Shift)) + { + parts.Add("Shift"); + } + + if (Modifiers.HasFlag(HotkeyModifiers.Alt)) + { + parts.Add("Alt"); + } + + parts.Add(Key == VkSnapshot ? "Print Screen" : KeyInterop.KeyFromVirtualKey((int)Key).ToString()); + return string.Join("+", parts); + } + } +} diff --git a/Pointframe/Services/Infrastructure/UserSettingsService.cs b/Pointframe/Services/Infrastructure/UserSettingsService.cs index 081032c..85be9f9 100644 --- a/Pointframe/Services/Infrastructure/UserSettingsService.cs +++ b/Pointframe/Services/Infrastructure/UserSettingsService.cs @@ -112,74 +112,16 @@ private static string GetSettingsPath() return GetDefaultSettingsPath(); } - private static UserSettings Clone(UserSettings settings) => - new() - { - ScreenshotSavePath = settings.ScreenshotSavePath, - AutoSaveScreenshots = settings.AutoSaveScreenshots, - RecordingOutputPath = settings.RecordingOutputPath, - RecordMicrophone = settings.RecordMicrophone, - RecordingMicrophoneDeviceName = settings.RecordingMicrophoneDeviceName, - RecordingFps = settings.RecordingFps, - GifFps = settings.GifFps, - HudGapPixels = settings.HudGapPixels, - RecordingCursorHighlightEnabled = settings.RecordingCursorHighlightEnabled, - RecordingClickRippleEnabled = settings.RecordingClickRippleEnabled, - RecordingCursorHighlightSize = settings.RecordingCursorHighlightSize, - DefaultAnnotationColor = settings.DefaultAnnotationColor, - DefaultStrokeThickness = settings.DefaultStrokeThickness, - CaptureDelaySeconds = settings.CaptureDelaySeconds, - RegionCaptureHotkey = settings.RegionCaptureHotkey, - RegionCaptureHotkeyModifiers = settings.RegionCaptureHotkeyModifiers, - WholeScreenRecordHotkey = settings.WholeScreenRecordHotkey, - WholeScreenRecordHotkeyModifiers = settings.WholeScreenRecordHotkeyModifiers, - CleanWindowCaptureHotkey = settings.CleanWindowCaptureHotkey, - CleanWindowCaptureHotkeyModifiers = settings.CleanWindowCaptureHotkeyModifiers, - OverlayCopyHotkey = settings.OverlayCopyHotkey, - OverlayCopyHotkeyModifiers = settings.OverlayCopyHotkeyModifiers, - OverlaySaveAsHotkey = settings.OverlaySaveAsHotkey, - OverlaySaveAsHotkeyModifiers = settings.OverlaySaveAsHotkeyModifiers, - OverlayUndoHotkey = settings.OverlayUndoHotkey, - OverlayUndoHotkeyModifiers = settings.OverlayUndoHotkeyModifiers, - OverlayRedoHotkey = settings.OverlayRedoHotkey, - OverlayRedoHotkeyModifiers = settings.OverlayRedoHotkeyModifiers, - OverlayToggleShortcutsHotkey = settings.OverlayToggleShortcutsHotkey, - OverlayToggleShortcutsHotkeyModifiers = settings.OverlayToggleShortcutsHotkeyModifiers, - OverlayCloseHotkey = settings.OverlayCloseHotkey, - OverlayCloseHotkeyModifiers = settings.OverlayCloseHotkeyModifiers, - AutoUpdateCheckInterval = settings.AutoUpdateCheckInterval, - LastAutoUpdateCheckUtc = settings.LastAutoUpdateCheckUtc, - Theme = settings.Theme, - StylePresets = [.. (settings.StylePresets ?? []).Select(p => new Pointframe.Models.AnnotationStylePreset - { - Name = p.Name, - Color = p.Color, - StrokeThickness = p.StrokeThickness, - })], - ScreenshotWatermark = CloneScreenshotWatermark(settings.ScreenshotWatermark), - VideoWatermark = CloneVideoWatermark(settings.VideoWatermark), - InstallId = settings.InstallId, - InstallCreatedUtc = settings.InstallCreatedUtc, - FirstCaptureCompletedTracked = settings.FirstCaptureCompletedTracked, - FirstRecordingCompletedTracked = settings.FirstRecordingCompletedTracked, - }; - - private static Pointframe.Models.ScreenshotWatermarkSettings CloneScreenshotWatermark(Pointframe.Models.WatermarkSettings? source) + private static UserSettings Clone(UserSettings settings) { - source ??= new Pointframe.Models.ScreenshotWatermarkSettings(); - return new Pointframe.Models.ScreenshotWatermarkSettings - { - Enabled = source.Enabled, - TextTemplate = source.TextTemplate, - Position = source.Position, - FontSize = source.FontSize, - ColorHex = source.ColorHex, - BackgroundEnabled = source.BackgroundEnabled, - Opacity = source.Opacity, - Margin = source.Margin, - ApplyToCopy = source.ApplyToCopy, - ApplyToSave = source.ApplyToSave, - }; + // Round-tripping through the persistence serializer keeps clone fidelity + // identical to Save/Load by construction — a property the serializer would + // drop here is already dropped on disk. + var clone = JsonSerializer.Deserialize(JsonSerializer.Serialize(settings))!; + clone.StylePresets ??= []; + clone.ScreenshotWatermark ??= new Pointframe.Models.ScreenshotWatermarkSettings(); + clone.VideoWatermark ??= new Pointframe.Models.VideoWatermarkSettings(); + return clone; } private static Pointframe.Models.VideoWatermarkSettings CloneVideoWatermark(Pointframe.Models.WatermarkSettings? source) diff --git a/Pointframe/ViewModels/OverlayViewModel.cs b/Pointframe/ViewModels/OverlayViewModel.cs index 47b0492..1ffa6a6 100644 --- a/Pointframe/ViewModels/OverlayViewModel.cs +++ b/Pointframe/ViewModels/OverlayViewModel.cs @@ -1,5 +1,4 @@ using System.Windows; -using System.Windows.Input; using Pointframe.Services; using Pointframe.Services.Messaging; @@ -56,12 +55,12 @@ partial void OnCurrentPhaseChanged(Phase value) => [ObservableProperty] private bool _isTextLassoActive; - public string OverlayCopyHotkeyDisplayName => BuildHotkeyDisplayName(_settings.Current.OverlayCopyHotkey, _settings.Current.OverlayCopyHotkeyModifiers); - public string OverlaySaveAsHotkeyDisplayName => BuildHotkeyDisplayName(_settings.Current.OverlaySaveAsHotkey, _settings.Current.OverlaySaveAsHotkeyModifiers); - public string OverlayUndoHotkeyDisplayName => BuildHotkeyDisplayName(_settings.Current.OverlayUndoHotkey, _settings.Current.OverlayUndoHotkeyModifiers); - public string OverlayRedoHotkeyDisplayName => BuildHotkeyDisplayName(_settings.Current.OverlayRedoHotkey, _settings.Current.OverlayRedoHotkeyModifiers); - public string OverlayToggleShortcutsHotkeyDisplayName => BuildHotkeyDisplayName(_settings.Current.OverlayToggleShortcutsHotkey, _settings.Current.OverlayToggleShortcutsHotkeyModifiers); - public string OverlayCloseHotkeyDisplayName => BuildHotkeyDisplayName(_settings.Current.OverlayCloseHotkey, _settings.Current.OverlayCloseHotkeyModifiers); + public string OverlayCopyHotkeyDisplayName => new HotkeyBinding(_settings.Current.OverlayCopyHotkey, _settings.Current.OverlayCopyHotkeyModifiers).DisplayName; + public string OverlaySaveAsHotkeyDisplayName => new HotkeyBinding(_settings.Current.OverlaySaveAsHotkey, _settings.Current.OverlaySaveAsHotkeyModifiers).DisplayName; + public string OverlayUndoHotkeyDisplayName => new HotkeyBinding(_settings.Current.OverlayUndoHotkey, _settings.Current.OverlayUndoHotkeyModifiers).DisplayName; + public string OverlayRedoHotkeyDisplayName => new HotkeyBinding(_settings.Current.OverlayRedoHotkey, _settings.Current.OverlayRedoHotkeyModifiers).DisplayName; + public string OverlayToggleShortcutsHotkeyDisplayName => new HotkeyBinding(_settings.Current.OverlayToggleShortcutsHotkey, _settings.Current.OverlayToggleShortcutsHotkeyModifiers).DisplayName; + public string OverlayCloseHotkeyDisplayName => new HotkeyBinding(_settings.Current.OverlayCloseHotkey, _settings.Current.OverlayCloseHotkeyModifiers).DisplayName; public string CopyToolTip => $"Copy to clipboard ({OverlayCopyHotkeyDisplayName})"; public string SaveAsToolTip => $"Save As ({OverlaySaveAsHotkeyDisplayName})"; @@ -217,33 +216,6 @@ private void SaveBitmapToPath(BitmapSource bitmap, string savePath, string captu _ = _eventAggregator.Publish(new CaptureCompletedMessage(savePath, captureAction)); } - private static string BuildHotkeyDisplayName(uint vk, HotkeyModifiers modifiers) - { - if (vk == 0) - { - return "Not set"; - } - - var parts = new List(); - if (modifiers.HasFlag(HotkeyModifiers.Ctrl)) - { - parts.Add("Ctrl"); - } - - if (modifiers.HasFlag(HotkeyModifiers.Shift)) - { - parts.Add("Shift"); - } - - if (modifiers.HasFlag(HotkeyModifiers.Alt)) - { - parts.Add("Alt"); - } - - parts.Add(vk == 0x2C ? "Print Screen" : KeyInterop.KeyFromVirtualKey((int)vk).ToString()); - return string.Join("+", parts); - } - [RelayCommand] private void PickColor() { diff --git a/Pointframe/ViewModels/SettingsViewModel.cs b/Pointframe/ViewModels/SettingsViewModel.cs index c8ee338..a2b24df 100644 --- a/Pointframe/ViewModels/SettingsViewModel.cs +++ b/Pointframe/ViewModels/SettingsViewModel.cs @@ -1,5 +1,4 @@ using System.Collections.ObjectModel; -using System.Windows.Input; using System.Windows.Media; using Pointframe.Services; @@ -18,6 +17,44 @@ public partial class SettingsViewModel : ObservableObject new(SettingsSection.App, "App", "Appearance, update checks, and reset actions."), ]; + private sealed record OverlayShortcutDescriptor( + string Key, + string Label, + Func SettingOf, + Func Get, + Action Set); + + private static readonly OverlayShortcutDescriptor[] OverlayShortcutDescriptors = + [ + new("OverlayCopy", "Copy snip", + s => new(s.OverlayCopyHotkey, s.OverlayCopyHotkeyModifiers), + vm => new(vm.OverlayCopyHotkey, vm.OverlayCopyHotkeyModifiers), + (vm, b) => (vm.OverlayCopyHotkey, vm.OverlayCopyHotkeyModifiers) = (b.Key, b.Modifiers)), + new("OverlaySaveAs", "Save As", + s => new(s.OverlaySaveAsHotkey, s.OverlaySaveAsHotkeyModifiers), + vm => new(vm.OverlaySaveAsHotkey, vm.OverlaySaveAsHotkeyModifiers), + (vm, b) => (vm.OverlaySaveAsHotkey, vm.OverlaySaveAsHotkeyModifiers) = (b.Key, b.Modifiers)), + new("OverlayUndo", "Undo", + s => new(s.OverlayUndoHotkey, s.OverlayUndoHotkeyModifiers), + vm => new(vm.OverlayUndoHotkey, vm.OverlayUndoHotkeyModifiers), + (vm, b) => (vm.OverlayUndoHotkey, vm.OverlayUndoHotkeyModifiers) = (b.Key, b.Modifiers)), + new("OverlayRedo", "Redo", + s => new(s.OverlayRedoHotkey, s.OverlayRedoHotkeyModifiers), + vm => new(vm.OverlayRedoHotkey, vm.OverlayRedoHotkeyModifiers), + (vm, b) => (vm.OverlayRedoHotkey, vm.OverlayRedoHotkeyModifiers) = (b.Key, b.Modifiers)), + new("OverlayToggleShortcuts", "Show/hide overlay shortcuts", + s => new(s.OverlayToggleShortcutsHotkey, s.OverlayToggleShortcutsHotkeyModifiers), + vm => new(vm.OverlayToggleShortcutsHotkey, vm.OverlayToggleShortcutsHotkeyModifiers), + (vm, b) => (vm.OverlayToggleShortcutsHotkey, vm.OverlayToggleShortcutsHotkeyModifiers) = (b.Key, b.Modifiers)), + new("OverlayClose", "Close overlay", + s => new(s.OverlayCloseHotkey, s.OverlayCloseHotkeyModifiers), + vm => new(vm.OverlayCloseHotkey, vm.OverlayCloseHotkeyModifiers), + (vm, b) => (vm.OverlayCloseHotkey, vm.OverlayCloseHotkeyModifiers) = (b.Key, b.Modifiers)), + ]; + + private static OverlayShortcutDescriptor? FindOverlayShortcut(string shortcutKey) => + Array.Find(OverlayShortcutDescriptors, descriptor => descriptor.Key == shortcutKey); + private readonly IDialogService _dialogService; private readonly IMicrophoneDeviceService _microphoneDeviceService; private readonly IUserSettingsService _settingsService; @@ -268,15 +305,15 @@ public SettingsViewModel(IUserSettingsService settingsService, IThemeService the public SettingsSectionItem SelectedSectionItem => Array.Find(SectionItems, item => item.Section == SelectedSection) ?? SectionItems[0]; - public string RegionCaptureHotkeyDisplayName => BuildHotkeyDisplayName(RegionCaptureHotkey, RegionCaptureHotkeyModifiers); - public string WholeScreenRecordHotkeyDisplayName => BuildHotkeyDisplayName(WholeScreenRecordHotkey, WholeScreenRecordHotkeyModifiers); - public string CleanWindowCaptureHotkeyDisplayName => BuildHotkeyDisplayName(CleanWindowCaptureHotkey, CleanWindowCaptureHotkeyModifiers); - public string OverlayCopyHotkeyDisplayName => BuildHotkeyDisplayName(OverlayCopyHotkey, OverlayCopyHotkeyModifiers); - public string OverlaySaveAsHotkeyDisplayName => BuildHotkeyDisplayName(OverlaySaveAsHotkey, OverlaySaveAsHotkeyModifiers); - public string OverlayUndoHotkeyDisplayName => BuildHotkeyDisplayName(OverlayUndoHotkey, OverlayUndoHotkeyModifiers); - public string OverlayRedoHotkeyDisplayName => BuildHotkeyDisplayName(OverlayRedoHotkey, OverlayRedoHotkeyModifiers); - public string OverlayToggleShortcutsHotkeyDisplayName => BuildHotkeyDisplayName(OverlayToggleShortcutsHotkey, OverlayToggleShortcutsHotkeyModifiers); - public string OverlayCloseHotkeyDisplayName => BuildHotkeyDisplayName(OverlayCloseHotkey, OverlayCloseHotkeyModifiers); + public string RegionCaptureHotkeyDisplayName => new HotkeyBinding(RegionCaptureHotkey, RegionCaptureHotkeyModifiers).DisplayName; + public string WholeScreenRecordHotkeyDisplayName => new HotkeyBinding(WholeScreenRecordHotkey, WholeScreenRecordHotkeyModifiers).DisplayName; + public string CleanWindowCaptureHotkeyDisplayName => new HotkeyBinding(CleanWindowCaptureHotkey, CleanWindowCaptureHotkeyModifiers).DisplayName; + public string OverlayCopyHotkeyDisplayName => new HotkeyBinding(OverlayCopyHotkey, OverlayCopyHotkeyModifiers).DisplayName; + public string OverlaySaveAsHotkeyDisplayName => new HotkeyBinding(OverlaySaveAsHotkey, OverlaySaveAsHotkeyModifiers).DisplayName; + public string OverlayUndoHotkeyDisplayName => new HotkeyBinding(OverlayUndoHotkey, OverlayUndoHotkeyModifiers).DisplayName; + public string OverlayRedoHotkeyDisplayName => new HotkeyBinding(OverlayRedoHotkey, OverlayRedoHotkeyModifiers).DisplayName; + public string OverlayToggleShortcutsHotkeyDisplayName => new HotkeyBinding(OverlayToggleShortcutsHotkey, OverlayToggleShortcutsHotkeyModifiers).DisplayName; + public string OverlayCloseHotkeyDisplayName => new HotkeyBinding(OverlayCloseHotkey, OverlayCloseHotkeyModifiers).DisplayName; public bool HasOverlayShortcutConflict => !string.IsNullOrWhiteSpace(OverlayShortcutConflictMessage); public string SelectedSectionDisplayName => SelectedSectionItem.DisplayName; public string SelectedSectionDescription => SelectedSectionItem.Description; @@ -529,120 +566,31 @@ private void CancelCapturingOverlayShortcut() private void ResetOverlayShortcut(string shortcutKey) { OverlayShortcutConflictMessage = string.Empty; - var defaults = new UserSettings(); - switch (shortcutKey) - { - case "OverlayCopy": - OverlayCopyHotkey = defaults.OverlayCopyHotkey; - OverlayCopyHotkeyModifiers = defaults.OverlayCopyHotkeyModifiers; - break; - case "OverlaySaveAs": - OverlaySaveAsHotkey = defaults.OverlaySaveAsHotkey; - OverlaySaveAsHotkeyModifiers = defaults.OverlaySaveAsHotkeyModifiers; - break; - case "OverlayUndo": - OverlayUndoHotkey = defaults.OverlayUndoHotkey; - OverlayUndoHotkeyModifiers = defaults.OverlayUndoHotkeyModifiers; - break; - case "OverlayRedo": - OverlayRedoHotkey = defaults.OverlayRedoHotkey; - OverlayRedoHotkeyModifiers = defaults.OverlayRedoHotkeyModifiers; - break; - case "OverlayToggleShortcuts": - OverlayToggleShortcutsHotkey = defaults.OverlayToggleShortcutsHotkey; - OverlayToggleShortcutsHotkeyModifiers = defaults.OverlayToggleShortcutsHotkeyModifiers; - break; - case "OverlayClose": - OverlayCloseHotkey = defaults.OverlayCloseHotkey; - OverlayCloseHotkeyModifiers = defaults.OverlayCloseHotkeyModifiers; - break; - } + var descriptor = FindOverlayShortcut(shortcutKey); + descriptor?.Set(this, descriptor.SettingOf(new UserSettings())); } internal void ApplyOverlayShortcutCapture(uint vk, HotkeyModifiers modifiers) { - if (TryFindOverlayShortcutOwner(vk, modifiers, out var owner) && owner != OverlayShortcutCaptureTarget) + var binding = new HotkeyBinding(vk, modifiers); + var owner = Array.Find(OverlayShortcutDescriptors, descriptor => descriptor.Get(this) == binding); + if (owner is not null && owner.Key != OverlayShortcutCaptureTarget) { - OverlayShortcutConflictMessage = $"{BuildHotkeyDisplayName(vk, modifiers)} is already assigned to {OverlayShortcutLabel(owner)}."; + OverlayShortcutConflictMessage = $"{binding.DisplayName} is already assigned to {owner.Label}."; return; } OverlayShortcutConflictMessage = string.Empty; - switch (OverlayShortcutCaptureTarget) + var target = FindOverlayShortcut(OverlayShortcutCaptureTarget); + if (target is null) { - case "OverlayCopy": - OverlayCopyHotkey = vk; - OverlayCopyHotkeyModifiers = modifiers; - break; - case "OverlaySaveAs": - OverlaySaveAsHotkey = vk; - OverlaySaveAsHotkeyModifiers = modifiers; - break; - case "OverlayUndo": - OverlayUndoHotkey = vk; - OverlayUndoHotkeyModifiers = modifiers; - break; - case "OverlayRedo": - OverlayRedoHotkey = vk; - OverlayRedoHotkeyModifiers = modifiers; - break; - case "OverlayToggleShortcuts": - OverlayToggleShortcutsHotkey = vk; - OverlayToggleShortcutsHotkeyModifiers = modifiers; - break; - case "OverlayClose": - OverlayCloseHotkey = vk; - OverlayCloseHotkeyModifiers = modifiers; - break; - default: - return; + return; } + target.Set(this, binding); CancelCapturingOverlayShortcut(); } - private bool TryFindOverlayShortcutOwner(uint vk, HotkeyModifiers modifiers, out string owner) - { - if (OverlayCopyHotkey == vk && OverlayCopyHotkeyModifiers == modifiers) - { - owner = "OverlayCopy"; - return true; - } - - if (OverlaySaveAsHotkey == vk && OverlaySaveAsHotkeyModifiers == modifiers) - { - owner = "OverlaySaveAs"; - return true; - } - - if (OverlayUndoHotkey == vk && OverlayUndoHotkeyModifiers == modifiers) - { - owner = "OverlayUndo"; - return true; - } - - if (OverlayRedoHotkey == vk && OverlayRedoHotkeyModifiers == modifiers) - { - owner = "OverlayRedo"; - return true; - } - - if (OverlayToggleShortcutsHotkey == vk && OverlayToggleShortcutsHotkeyModifiers == modifiers) - { - owner = "OverlayToggleShortcuts"; - return true; - } - - if (OverlayCloseHotkey == vk && OverlayCloseHotkeyModifiers == modifiers) - { - owner = "OverlayClose"; - return true; - } - - owner = string.Empty; - return false; - } - [RelayCommand] private void ResetCurrentSection() { @@ -688,18 +636,7 @@ private void ResetCurrentSection() AppTheme = defaults.Theme; break; case SettingsSection.Shortcuts: - OverlayCopyHotkey = defaults.OverlayCopyHotkey; - OverlayCopyHotkeyModifiers = defaults.OverlayCopyHotkeyModifiers; - OverlaySaveAsHotkey = defaults.OverlaySaveAsHotkey; - OverlaySaveAsHotkeyModifiers = defaults.OverlaySaveAsHotkeyModifiers; - OverlayUndoHotkey = defaults.OverlayUndoHotkey; - OverlayUndoHotkeyModifiers = defaults.OverlayUndoHotkeyModifiers; - OverlayRedoHotkey = defaults.OverlayRedoHotkey; - OverlayRedoHotkeyModifiers = defaults.OverlayRedoHotkeyModifiers; - OverlayToggleShortcutsHotkey = defaults.OverlayToggleShortcutsHotkey; - OverlayToggleShortcutsHotkeyModifiers = defaults.OverlayToggleShortcutsHotkeyModifiers; - OverlayCloseHotkey = defaults.OverlayCloseHotkey; - OverlayCloseHotkeyModifiers = defaults.OverlayCloseHotkeyModifiers; + ResetOverlayShortcutsTo(defaults); IsCapturingOverlayShortcut = false; OverlayShortcutCaptureTarget = string.Empty; OverlayShortcutCaptureDisplayName = string.Empty; @@ -743,18 +680,7 @@ private void RestoreDefaults() CleanWindowCaptureHotkey = defaults.CleanWindowCaptureHotkey; CleanWindowCaptureHotkeyModifiers = defaults.CleanWindowCaptureHotkeyModifiers; IsCapturingCleanWindowCaptureHotkey = false; - OverlayCopyHotkey = defaults.OverlayCopyHotkey; - OverlayCopyHotkeyModifiers = defaults.OverlayCopyHotkeyModifiers; - OverlaySaveAsHotkey = defaults.OverlaySaveAsHotkey; - OverlaySaveAsHotkeyModifiers = defaults.OverlaySaveAsHotkeyModifiers; - OverlayUndoHotkey = defaults.OverlayUndoHotkey; - OverlayUndoHotkeyModifiers = defaults.OverlayUndoHotkeyModifiers; - OverlayRedoHotkey = defaults.OverlayRedoHotkey; - OverlayRedoHotkeyModifiers = defaults.OverlayRedoHotkeyModifiers; - OverlayToggleShortcutsHotkey = defaults.OverlayToggleShortcutsHotkey; - OverlayToggleShortcutsHotkeyModifiers = defaults.OverlayToggleShortcutsHotkeyModifiers; - OverlayCloseHotkey = defaults.OverlayCloseHotkey; - OverlayCloseHotkeyModifiers = defaults.OverlayCloseHotkeyModifiers; + ResetOverlayShortcutsTo(defaults); IsCapturingOverlayShortcut = false; OverlayShortcutCaptureTarget = string.Empty; OverlayShortcutCaptureDisplayName = string.Empty; @@ -784,50 +710,17 @@ private void ResetStylePresets(List presets) OnPropertyChanged(nameof(CanAddPreset)); } - private static string VkToKeyName(uint vk) => - vk == 0x2C ? "Print Screen" : KeyInterop.KeyFromVirtualKey((int)vk).ToString(); - - private static string OverlayShortcutLabel(string shortcutKey) + private void ResetOverlayShortcutsTo(UserSettings settings) { - return shortcutKey switch + foreach (var descriptor in OverlayShortcutDescriptors) { - "OverlayCopy" => "Copy snip", - "OverlaySaveAs" => "Save As", - "OverlayUndo" => "Undo", - "OverlayRedo" => "Redo", - "OverlayToggleShortcuts" => "Show/hide overlay shortcuts", - "OverlayClose" => "Close overlay", - _ => "Shortcut", - }; - } - - private static string BuildHotkeyDisplayName(uint vk, HotkeyModifiers modifiers) - { - if (vk == 0) - { - return "Not set"; - } - - var parts = new List(); - if (modifiers.HasFlag(HotkeyModifiers.Ctrl)) - { - parts.Add("Ctrl"); - } - - if (modifiers.HasFlag(HotkeyModifiers.Shift)) - { - parts.Add("Shift"); + descriptor.Set(this, descriptor.SettingOf(settings)); } - - if (modifiers.HasFlag(HotkeyModifiers.Alt)) - { - parts.Add("Alt"); - } - - parts.Add(VkToKeyName(vk)); - return string.Join("+", parts); } + private static string OverlayShortcutLabel(string shortcutKey) => + FindOverlayShortcut(shortcutKey)?.Label ?? "Shortcut"; + private static double ClampRecordingCursorHighlightSize(double size) { return Math.Clamp(size, MinRecordingCursorHighlightSize, MaxRecordingCursorHighlightSize); diff --git a/Pointframe/Views/SettingsWindow.xaml.cs b/Pointframe/Views/SettingsWindow.xaml.cs index ad75611..a0ff4eb 100644 --- a/Pointframe/Views/SettingsWindow.xaml.cs +++ b/Pointframe/Views/SettingsWindow.xaml.cs @@ -74,16 +74,16 @@ private void HotkeyCapture_PreviewKeyDown(object sender, System.Windows.Input.Ke // Non-modifier keys are intercepted by the hook in capture mode. // This handler only fires for modifier keys — update the live display. e.Handled = true; - UpdateCaptureHotkeyCurrentInput(e.KeyboardDevice.Modifiers); + SetHotkeyCaptureInput(CaptureHotkeyCurrentInput, e.KeyboardDevice.Modifiers); } private void HotkeyCapture_PreviewKeyUp(object sender, System.Windows.Input.KeyEventArgs e) { e.Handled = true; - UpdateCaptureHotkeyCurrentInput(e.KeyboardDevice.Modifiers); + SetHotkeyCaptureInput(CaptureHotkeyCurrentInput, e.KeyboardDevice.Modifiers); } - private void UpdateCaptureHotkeyCurrentInput(ModifierKeys modifiers) + private static void SetHotkeyCaptureInput(TextBlock target, ModifierKeys modifiers) { var parts = new System.Collections.Generic.List(); if ((modifiers & ModifierKeys.Control) != 0) @@ -101,7 +101,7 @@ private void UpdateCaptureHotkeyCurrentInput(ModifierKeys modifiers) parts.Add("Alt"); } - CaptureHotkeyCurrentInput.Text = parts.Count > 0 ? string.Join(" + ", parts) + " + ?" : "—"; + target.Text = parts.Count > 0 ? string.Join(" + ", parts) + " + ?" : "—"; } private void HotkeyRecordingPanel_IsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e) @@ -138,7 +138,7 @@ private void RecordHotkeyCapture_PreviewKeyDown(object sender, System.Windows.In // Non-modifier keys are intercepted by the hook in capture mode. // This handler only fires for modifier keys — update the live display. e.Handled = true; - UpdateRecordHotkeyCurrentInput(e.KeyboardDevice.Modifiers); + SetHotkeyCaptureInput(RecordHotkeyCurrentInput, e.KeyboardDevice.Modifiers); } private void WholeScreenRecordHotkeyRecordingPanel_IsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e) @@ -173,13 +173,13 @@ private void OnRecordHotkeyKeyPressed(uint vk, HotkeyModifiers modifiers) private void CleanWindowHotkeyCapture_PreviewKeyDown(object sender, System.Windows.Input.KeyEventArgs e) { e.Handled = true; - UpdateCleanWindowHotkeyCurrentInput(e.KeyboardDevice.Modifiers); + SetHotkeyCaptureInput(CleanWindowHotkeyCurrentInput, e.KeyboardDevice.Modifiers); } private void CleanWindowHotkeyCapture_PreviewKeyUp(object sender, System.Windows.Input.KeyEventArgs e) { e.Handled = true; - UpdateCleanWindowHotkeyCurrentInput(e.KeyboardDevice.Modifiers); + SetHotkeyCaptureInput(CleanWindowHotkeyCurrentInput, e.KeyboardDevice.Modifiers); } private void CleanWindowHotkeyRecordingPanel_IsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e) @@ -214,40 +214,19 @@ private void OnCleanWindowHotkeyKeyPressed(uint vk, HotkeyModifiers modifiers) private void RecordHotkeyCapture_PreviewKeyUp(object sender, System.Windows.Input.KeyEventArgs e) { e.Handled = true; - UpdateRecordHotkeyCurrentInput(e.KeyboardDevice.Modifiers); - } - - private void UpdateRecordHotkeyCurrentInput(ModifierKeys modifiers) - { - var parts = new System.Collections.Generic.List(); - if ((modifiers & ModifierKeys.Control) != 0) - { - parts.Add("Ctrl"); - } - - if ((modifiers & ModifierKeys.Shift) != 0) - { - parts.Add("Shift"); - } - - if ((modifiers & ModifierKeys.Alt) != 0) - { - parts.Add("Alt"); - } - - RecordHotkeyCurrentInput.Text = parts.Count > 0 ? string.Join(" + ", parts) + " + ?" : "—"; + SetHotkeyCaptureInput(RecordHotkeyCurrentInput, e.KeyboardDevice.Modifiers); } private void ShortcutsCleanWindowHotkeyCapture_PreviewKeyDown(object sender, KeyEventArgs e) { e.Handled = true; - UpdateShortcutsCleanWindowHotkeyCurrentInput(e.KeyboardDevice.Modifiers); + SetHotkeyCaptureInput(ShortcutsCleanWindowHotkeyCurrentInput, e.KeyboardDevice.Modifiers); } private void ShortcutsCleanWindowHotkeyCapture_PreviewKeyUp(object sender, KeyEventArgs e) { e.Handled = true; - UpdateShortcutsCleanWindowHotkeyCurrentInput(e.KeyboardDevice.Modifiers); + SetHotkeyCaptureInput(ShortcutsCleanWindowHotkeyCurrentInput, e.KeyboardDevice.Modifiers); } private void ShortcutsCleanWindowHotkeyRecordingPanel_IsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e) @@ -266,58 +245,16 @@ private void ShortcutsCleanWindowHotkeyRecordingPanel_IsVisibleChanged(object se } } - private void UpdateShortcutsCleanWindowHotkeyCurrentInput(ModifierKeys modifiers) - { - var parts = new System.Collections.Generic.List(); - if ((modifiers & ModifierKeys.Control) != 0) - { - parts.Add("Ctrl"); - } - - if ((modifiers & ModifierKeys.Shift) != 0) - { - parts.Add("Shift"); - } - - if ((modifiers & ModifierKeys.Alt) != 0) - { - parts.Add("Alt"); - } - - ShortcutsCleanWindowHotkeyCurrentInput.Text = parts.Count > 0 ? string.Join(" + ", parts) + " + ?" : "—"; - } - - private void UpdateCleanWindowHotkeyCurrentInput(ModifierKeys modifiers) - { - var parts = new System.Collections.Generic.List(); - if ((modifiers & ModifierKeys.Control) != 0) - { - parts.Add("Ctrl"); - } - - if ((modifiers & ModifierKeys.Shift) != 0) - { - parts.Add("Shift"); - } - - if ((modifiers & ModifierKeys.Alt) != 0) - { - parts.Add("Alt"); - } - - CleanWindowHotkeyCurrentInput.Text = parts.Count > 0 ? string.Join(" + ", parts) + " + ?" : "—"; - } - private void ShortcutsRegionHotkeyCapture_PreviewKeyDown(object sender, KeyEventArgs e) { e.Handled = true; - UpdateShortcutsRegionHotkeyCurrentInput(e.KeyboardDevice.Modifiers); + SetHotkeyCaptureInput(ShortcutsCaptureHotkeyCurrentInput, e.KeyboardDevice.Modifiers); } private void ShortcutsRegionHotkeyCapture_PreviewKeyUp(object sender, KeyEventArgs e) { e.Handled = true; - UpdateShortcutsRegionHotkeyCurrentInput(e.KeyboardDevice.Modifiers); + SetHotkeyCaptureInput(ShortcutsCaptureHotkeyCurrentInput, e.KeyboardDevice.Modifiers); } private void ShortcutsRegionHotkeyRecordingPanel_IsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e) @@ -336,37 +273,16 @@ private void ShortcutsRegionHotkeyRecordingPanel_IsVisibleChanged(object sender, } } - private void UpdateShortcutsRegionHotkeyCurrentInput(ModifierKeys modifiers) - { - var parts = new System.Collections.Generic.List(); - if ((modifiers & ModifierKeys.Control) != 0) - { - parts.Add("Ctrl"); - } - - if ((modifiers & ModifierKeys.Shift) != 0) - { - parts.Add("Shift"); - } - - if ((modifiers & ModifierKeys.Alt) != 0) - { - parts.Add("Alt"); - } - - ShortcutsCaptureHotkeyCurrentInput.Text = parts.Count > 0 ? string.Join(" + ", parts) + " + ?" : "—"; - } - private void ShortcutsRecordHotkeyCapture_PreviewKeyDown(object sender, KeyEventArgs e) { e.Handled = true; - UpdateShortcutsRecordHotkeyCurrentInput(e.KeyboardDevice.Modifiers); + SetHotkeyCaptureInput(ShortcutsRecordHotkeyCurrentInput, e.KeyboardDevice.Modifiers); } private void ShortcutsRecordHotkeyCapture_PreviewKeyUp(object sender, KeyEventArgs e) { e.Handled = true; - UpdateShortcutsRecordHotkeyCurrentInput(e.KeyboardDevice.Modifiers); + SetHotkeyCaptureInput(ShortcutsRecordHotkeyCurrentInput, e.KeyboardDevice.Modifiers); } private void ShortcutsRecordHotkeyRecordingPanel_IsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e) @@ -385,37 +301,16 @@ private void ShortcutsRecordHotkeyRecordingPanel_IsVisibleChanged(object sender, } } - private void UpdateShortcutsRecordHotkeyCurrentInput(ModifierKeys modifiers) - { - var parts = new System.Collections.Generic.List(); - if ((modifiers & ModifierKeys.Control) != 0) - { - parts.Add("Ctrl"); - } - - if ((modifiers & ModifierKeys.Shift) != 0) - { - parts.Add("Shift"); - } - - if ((modifiers & ModifierKeys.Alt) != 0) - { - parts.Add("Alt"); - } - - ShortcutsRecordHotkeyCurrentInput.Text = parts.Count > 0 ? string.Join(" + ", parts) + " + ?" : "—"; - } - private void OverlayShortcutCapture_PreviewKeyDown(object sender, KeyEventArgs e) { e.Handled = true; - UpdateOverlayShortcutCurrentInput(e.KeyboardDevice.Modifiers); + SetHotkeyCaptureInput(OverlayShortcutCurrentInput, e.KeyboardDevice.Modifiers); } private void OverlayShortcutCapture_PreviewKeyUp(object sender, KeyEventArgs e) { e.Handled = true; - UpdateOverlayShortcutCurrentInput(e.KeyboardDevice.Modifiers); + SetHotkeyCaptureInput(OverlayShortcutCurrentInput, e.KeyboardDevice.Modifiers); } private void OverlayShortcutRecordingPanel_IsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e) @@ -445,27 +340,6 @@ private void OnOverlayShortcutKeyPressed(uint vk, HotkeyModifiers modifiers) _vm.ApplyOverlayShortcutCapture(vk, modifiers); } - private void UpdateOverlayShortcutCurrentInput(ModifierKeys modifiers) - { - var parts = new System.Collections.Generic.List(); - if ((modifiers & ModifierKeys.Control) != 0) - { - parts.Add("Ctrl"); - } - - if ((modifiers & ModifierKeys.Shift) != 0) - { - parts.Add("Shift"); - } - - if ((modifiers & ModifierKeys.Alt) != 0) - { - parts.Add("Alt"); - } - - OverlayShortcutCurrentInput.Text = parts.Count > 0 ? string.Join(" + ", parts) + " + ?" : "—"; - } - private void SectionNavigation_SelectionChanged(object sender, SelectionChangedEventArgs e) { ContentScrollViewer?.ScrollToHome(); From b5040f8144e6cffedbb4f0ff525d168461c19160 Mon Sep 17 00:00:00 2001 From: Dimitar Radenkov Date: Sun, 19 Jul 2026 09:50:49 +0300 Subject: [PATCH 2/6] Refactor overlay window interaction and hotkey management; implement OcrLassoController and add unit tests for HotkeyBinding --- Pointframe.Tests/Models/HotkeyBindingTests.cs | 61 ++++++ .../OverlayWindowInteractionTests.cs | 26 +-- Pointframe/Models/HotkeyBinding.cs | 32 +++ .../Services/Annotation/OcrLassoController.cs | 156 ++++++++++++++ Pointframe/Views/OverlayWindow.Recording.cs | 30 +-- Pointframe/Views/OverlayWindow.xaml.cs | 194 ++++-------------- 6 files changed, 308 insertions(+), 191 deletions(-) create mode 100644 Pointframe.Tests/Models/HotkeyBindingTests.cs create mode 100644 Pointframe/Services/Annotation/OcrLassoController.cs diff --git a/Pointframe.Tests/Models/HotkeyBindingTests.cs b/Pointframe.Tests/Models/HotkeyBindingTests.cs new file mode 100644 index 0000000..d396230 --- /dev/null +++ b/Pointframe.Tests/Models/HotkeyBindingTests.cs @@ -0,0 +1,61 @@ +using System.Windows.Input; +using Pointframe.Models; +using Xunit; + +namespace Pointframe.Tests.Models; + +public sealed class HotkeyBindingTests +{ + [Fact] + public void DisplayName_WhenKeyNotSet_ReturnsNotSet() + { + Assert.Equal("Not set", new HotkeyBinding(0, HotkeyModifiers.Ctrl).DisplayName); + } + + [Fact] + public void DisplayName_ComposesModifiersInCtrlShiftAltOrder() + { + var binding = new HotkeyBinding(0x53, HotkeyModifiers.Ctrl | HotkeyModifiers.Shift | HotkeyModifiers.Alt); // S + + Assert.Equal("Ctrl+Shift+Alt+S", binding.DisplayName); + } + + [Fact] + public void DisplayName_ForPrintScreen_UsesFriendlyName() + { + Assert.Equal("Print Screen", new HotkeyBinding(0x2C, HotkeyModifiers.None).DisplayName); + } + + [Fact] + public void Matches_WhenKeyAndModifiersMatch_ReturnsTrue() + { + var binding = new HotkeyBinding(0x53, HotkeyModifiers.Ctrl); // S + + Assert.True(binding.Matches(Key.S, ModifierKeys.Control)); + } + + [Fact] + public void Matches_WhenModifiersDiffer_ReturnsFalse() + { + var binding = new HotkeyBinding(0x53, HotkeyModifiers.Ctrl); // S + + Assert.False(binding.Matches(Key.S, ModifierKeys.Control | ModifierKeys.Shift)); + Assert.False(binding.Matches(Key.S, ModifierKeys.None)); + } + + [Fact] + public void Matches_WhenKeyDiffers_ReturnsFalse() + { + var binding = new HotkeyBinding(0x53, HotkeyModifiers.Ctrl); // S + + Assert.False(binding.Matches(Key.A, ModifierKeys.Control)); + } + + [Fact] + public void Matches_WhenKeyNotSet_NeverMatches() + { + var binding = new HotkeyBinding(0, HotkeyModifiers.None); + + Assert.False(binding.Matches(Key.None, ModifierKeys.None)); + } +} diff --git a/Pointframe.Tests/OverlayWindowInteractionTests.cs b/Pointframe.Tests/OverlayWindowInteractionTests.cs index d350195..b1098b8 100644 --- a/Pointframe.Tests/OverlayWindowInteractionTests.cs +++ b/Pointframe.Tests/OverlayWindowInteractionTests.cs @@ -150,17 +150,18 @@ public void WindowKeyDown_Escape_WhenTextLassoActive_ClearsLassoState() try { var lassoRect = Assert.IsType(context.Window.FindName("OcrLassoRect")); - lassoRect.Visibility = Visibility.Visible; context.ViewModel.InitializeAnnotatingSession(new Rect(0d, 0d, 100d, 80d), 1d, 1d); context.ViewModel.IsTextLassoActive = true; - SetPrivateField(context.Window, "_lassoStart", new Point(12d, 14d)); + var lasso = GetPrivateField(context.Window, "_ocrLasso"); + lasso.HandlePointerDown(new Point(12d, 14d)); + Assert.Equal(Visibility.Visible, lassoRect.Visibility); var args = CreateKeyArgs(Key.Escape); InvokePrivate(context.Window, "Window_KeyDown", context.Window, args); Assert.False(context.ViewModel.IsTextLassoActive); Assert.Equal(Visibility.Collapsed, lassoRect.Visibility); - Assert.Null(GetPrivateField(context.Window, "_lassoStart")); + Assert.False(lasso.HasPendingLasso); Assert.True(args.Handled); } finally @@ -344,8 +345,8 @@ public void DoLassoOcr_WhenBackgroundIsMissing_DoesNotInvokeOcrService() var context = CreateContext(); try { - var task = Assert.IsAssignableFrom(InvokePrivate(context.Window, "DoLassoOcr", new Rect(1d, 2d, 30d, 16d))); - task.GetAwaiter().GetResult(); + var lasso = GetPrivateField(context.Window, "_ocrLasso"); + lasso.RecognizeAsync(new Rect(1d, 2d, 30d, 16d)).GetAwaiter().GetResult(); context.OcrServiceMock.Verify(service => service.Recognize(It.IsAny()), Times.Never); } @@ -369,8 +370,8 @@ public void DoLassoOcr_WhenNoTextDetected_TracksAttemptAndNoTextTelemetry() .Setup(service => service.Recognize(It.IsAny())) .ReturnsAsync(" "); - var task = Assert.IsAssignableFrom(InvokePrivate(context.Window, "DoLassoOcr", new Rect(1d, 2d, 10d, 6d))); - task.GetAwaiter().GetResult(); + var lasso = GetPrivateField(context.Window, "_ocrLasso"); + lasso.RecognizeAsync(new Rect(1d, 2d, 10d, 6d)).GetAwaiter().GetResult(); context.TelemetryMock.Verify( telemetry => telemetry.TrackEvent( @@ -412,8 +413,8 @@ public void DoLassoOcr_WhenTextDetected_TracksAttemptAndUsedTelemetry() .Setup(service => service.Recognize(It.IsAny())) .ReturnsAsync("copied text"); - var task = Assert.IsAssignableFrom(InvokePrivate(context.Window, "DoLassoOcr", new Rect(2d, 3d, 8d, 5d))); - task.GetAwaiter().GetResult(); + var lasso = GetPrivateField(context.Window, "_ocrLasso"); + lasso.RecognizeAsync(new Rect(2d, 3d, 8d, 5d)).GetAwaiter().GetResult(); context.TelemetryMock.Verify( telemetry => telemetry.TrackEvent( @@ -562,13 +563,6 @@ private static T GetPrivateField(object target, string fieldName) return (T)field.GetValue(target)!; } - private static void SetPrivateField(object target, string fieldName, T value) - { - var field = target.GetType().GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic); - Assert.NotNull(field); - field.SetValue(target, value); - } - private static KeyEventArgs CreateKeyArgs(Key key) { var source = new HwndSource(new HwndSourceParameters("OverlayWindowInteractionTests") diff --git a/Pointframe/Models/HotkeyBinding.cs b/Pointframe/Models/HotkeyBinding.cs index d4b4396..45b551e 100644 --- a/Pointframe/Models/HotkeyBinding.cs +++ b/Pointframe/Models/HotkeyBinding.cs @@ -35,4 +35,36 @@ public string DisplayName return string.Join("+", parts); } } + + public bool Matches(Key pressedKey, ModifierKeys pressedModifiers) + { + if (Key == 0) + { + return false; + } + + return pressedKey == KeyInterop.KeyFromVirtualKey((int)Key) + && pressedModifiers == ToModifierKeys(Modifiers); + } + + private static ModifierKeys ToModifierKeys(HotkeyModifiers modifiers) + { + var result = ModifierKeys.None; + if (modifiers.HasFlag(HotkeyModifiers.Ctrl)) + { + result |= ModifierKeys.Control; + } + + if (modifiers.HasFlag(HotkeyModifiers.Shift)) + { + result |= ModifierKeys.Shift; + } + + if (modifiers.HasFlag(HotkeyModifiers.Alt)) + { + result |= ModifierKeys.Alt; + } + + return result; + } } diff --git a/Pointframe/Services/Annotation/OcrLassoController.cs b/Pointframe/Services/Annotation/OcrLassoController.cs new file mode 100644 index 0000000..e455eed --- /dev/null +++ b/Pointframe/Services/Annotation/OcrLassoController.cs @@ -0,0 +1,156 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Shapes; +using Pointframe.ViewModels; + +namespace Pointframe.Services; + +internal sealed class OcrLassoController +{ + private readonly Canvas _canvas; + private readonly Shape _lassoRect; + private readonly OverlayViewModel _viewModel; + private readonly IOcrService _ocrService; + private readonly ITelemetryService _telemetry; + private readonly Func _backgroundProvider; + private readonly Action _showToast; + private Point? _lassoStart; + + public OcrLassoController( + Canvas canvas, + Shape lassoRect, + OverlayViewModel viewModel, + IOcrService ocrService, + ITelemetryService telemetry, + Func backgroundProvider, + Action showToast) + { + _canvas = canvas; + _lassoRect = lassoRect; + _viewModel = viewModel; + _ocrService = ocrService; + _telemetry = telemetry; + _backgroundProvider = backgroundProvider; + _showToast = showToast; + } + + public bool HasPendingLasso => _lassoStart.HasValue; + + public bool HandlePointerDown(Point point) + { + if (!_viewModel.IsTextLassoActive) + { + return false; + } + + _lassoStart = point; + var selection = _viewModel.SelectionRect; + Canvas.SetLeft(_lassoRect, selection.X + point.X); + Canvas.SetTop(_lassoRect, selection.Y + point.Y); + _lassoRect.Width = 0; + _lassoRect.Height = 0; + _lassoRect.Visibility = Visibility.Visible; + _canvas.CaptureMouse(); + return true; + } + + public bool HandlePointerMove(Point point) + { + if (!_viewModel.IsTextLassoActive || !_lassoStart.HasValue) + { + return false; + } + + var selection = _viewModel.SelectionRect; + var x = Math.Min(point.X, _lassoStart.Value.X); + var y = Math.Min(point.Y, _lassoStart.Value.Y); + var w = Math.Abs(point.X - _lassoStart.Value.X); + var h = Math.Abs(point.Y - _lassoStart.Value.Y); + Canvas.SetLeft(_lassoRect, selection.X + x); + Canvas.SetTop(_lassoRect, selection.Y + y); + _lassoRect.Width = w; + _lassoRect.Height = h; + return true; + } + + public bool HandlePointerUp(Point point) + { + if (!_viewModel.IsTextLassoActive || !_lassoStart.HasValue) + { + return false; + } + + _canvas.ReleaseMouseCapture(); + var x = Math.Min(point.X, _lassoStart.Value.X); + var y = Math.Min(point.Y, _lassoStart.Value.Y); + var w = Math.Abs(point.X - _lassoStart.Value.X); + var h = Math.Abs(point.Y - _lassoStart.Value.Y); + _lassoRect.Visibility = Visibility.Collapsed; + _lassoStart = null; + + if (w >= 4 && h >= 4) + { + _ = RecognizeAsync(new Rect(x, y, w, h)); + } + + return true; + } + + public bool Cancel() + { + if (!_viewModel.IsTextLassoActive) + { + return false; + } + + _viewModel.IsTextLassoActive = false; + _lassoRect.Visibility = Visibility.Collapsed; + _lassoStart = null; + return true; + } + + internal async Task RecognizeAsync(Rect lassoRect) + { + var background = _backgroundProvider(); + if (background is null) + { + return; + } + + var pixelX = (int)(lassoRect.X * _viewModel.DpiX); + var pixelY = (int)(lassoRect.Y * _viewModel.DpiY); + var pixelW = (int)(lassoRect.Width * _viewModel.DpiX); + var pixelH = (int)(lassoRect.Height * _viewModel.DpiY); + + pixelX = Math.Max(0, Math.Min(pixelX, background.PixelWidth - 1)); + pixelY = Math.Max(0, Math.Min(pixelY, background.PixelHeight - 1)); + pixelW = Math.Min(pixelW, background.PixelWidth - pixelX); + pixelH = Math.Min(pixelH, background.PixelHeight - pixelY); + + if (pixelW < 1 || pixelH < 1) + { + return; + } + + var cropped = new CroppedBitmap(background, new Int32Rect(pixelX, pixelY, pixelW, pixelH)); + var ocrProps = new Dictionary + { + ["selection_width_px"] = pixelW.ToString(), + ["selection_height_px"] = pixelH.ToString(), + }; + + _telemetry.TrackEvent("ocr_attempted", ocrProps); + var text = await _ocrService.Recognize(cropped); + + if (string.IsNullOrWhiteSpace(text)) + { + _telemetry.TrackEvent("ocr_no_text", ocrProps); + _showToast("No text detected — try a larger area"); + return; + } + + System.Windows.Clipboard.SetText(text); + _telemetry.TrackEvent("ocr_used", ocrProps); + _showToast("✓ Text copied to clipboard"); + } +} diff --git a/Pointframe/Views/OverlayWindow.Recording.cs b/Pointframe/Views/OverlayWindow.Recording.cs index 52e6ab1..abc4a22 100644 --- a/Pointframe/Views/OverlayWindow.Recording.cs +++ b/Pointframe/Views/OverlayWindow.Recording.cs @@ -119,11 +119,13 @@ internal static RecordingSessionGeometry CreateRecordingSessionGeometry( Int32Rect captureBoundsPixels, string monitorName) { - var monitorBounds = Forms.Screen.FromRectangle(new System.Drawing.Rectangle( + var screen = Forms.Screen.FromRectangle(new System.Drawing.Rectangle( captureBoundsPixels.X, captureBoundsPixels.Y, captureBoundsPixels.Width, - captureBoundsPixels.Height)).Bounds; + captureBoundsPixels.Height)); + var monitorBounds = screen.Bounds; + var workArea = screen.WorkingArea; return CreateRecordingSessionGeometry( selectionRect, @@ -135,26 +137,10 @@ internal static RecordingSessionGeometry CreateRecordingSessionGeometry( monitorBounds.Width, monitorBounds.Height), new Int32Rect( - Forms.Screen.FromRectangle(new System.Drawing.Rectangle( - captureBoundsPixels.X, - captureBoundsPixels.Y, - captureBoundsPixels.Width, - captureBoundsPixels.Height)).WorkingArea.X, - Forms.Screen.FromRectangle(new System.Drawing.Rectangle( - captureBoundsPixels.X, - captureBoundsPixels.Y, - captureBoundsPixels.Width, - captureBoundsPixels.Height)).WorkingArea.Y, - Forms.Screen.FromRectangle(new System.Drawing.Rectangle( - captureBoundsPixels.X, - captureBoundsPixels.Y, - captureBoundsPixels.Width, - captureBoundsPixels.Height)).WorkingArea.Width, - Forms.Screen.FromRectangle(new System.Drawing.Rectangle( - captureBoundsPixels.X, - captureBoundsPixels.Y, - captureBoundsPixels.Width, - captureBoundsPixels.Height)).WorkingArea.Height)); + workArea.X, + workArea.Y, + workArea.Width, + workArea.Height)); } internal static RecordingSessionGeometry CreateRecordingSessionGeometry( diff --git a/Pointframe/Views/OverlayWindow.xaml.cs b/Pointframe/Views/OverlayWindow.xaml.cs index 0d2a842..fd4406b 100644 --- a/Pointframe/Views/OverlayWindow.xaml.cs +++ b/Pointframe/Views/OverlayWindow.xaml.cs @@ -32,7 +32,7 @@ public partial class OverlayWindow : Window private readonly Func _beautifierWindowFactory; private AnnotationCanvasRenderer _renderer = null!; private AnnotationCanvasInteractionController _annotationInteractionController = null!; - private Point? _lassoStart; + private readonly OcrLassoController _ocrLasso; private RecordingSessionGeometry _recordingSessionGeometry = RecordingSessionGeometry.Empty; private BitmapSource? _openedImage; private string? _openedImagePath; @@ -101,6 +101,14 @@ internal OverlayWindow( ShowOcrToast($"Copied {hex}"); }, onLoupePositionChanged: pt => UpdateLoupe(pt)); + _ocrLasso = new OcrLassoController( + AnnotationCanvas, + OcrLassoRect, + _vm, + _ocrService, + _telemetry, + () => _renderer.BackgroundCapture, + ShowOcrToast); _undoSubscription = _eventAggregator.Subscribe(HandleUndoGroup); _redoSubscription = _eventAggregator.Subscribe(HandleRedoGroup); _vm.CloseRequested += Close; @@ -223,60 +231,31 @@ private void InitializeFromSelectionSessionCore(SelectionSessionResult selection private void Annot_Down(object sender, MouseButtonEventArgs e) { - if (_vm.IsTextLassoActive) - { - _lassoStart = e.GetPosition(AnnotationCanvas); - var sel = _vm.SelectionRect; - Canvas.SetLeft(OcrLassoRect, sel.X + _lassoStart.Value.X); - Canvas.SetTop(OcrLassoRect, sel.Y + _lassoStart.Value.Y); - OcrLassoRect.Width = 0; - OcrLassoRect.Height = 0; - OcrLassoRect.Visibility = Visibility.Visible; - AnnotationCanvas.CaptureMouse(); + var point = e.GetPosition(AnnotationCanvas); + if (_ocrLasso.HandlePointerDown(point)) + { return; } - _annotationInteractionController.HandlePointerDown(e.GetPosition(AnnotationCanvas)); + _annotationInteractionController.HandlePointerDown(point); } private void Annot_Move(object sender, MouseEventArgs e) { - if (_vm.IsTextLassoActive && _lassoStart.HasValue) - { - var cur = e.GetPosition(AnnotationCanvas); - var sel = _vm.SelectionRect; - var x = Math.Min(cur.X, _lassoStart.Value.X); - var y = Math.Min(cur.Y, _lassoStart.Value.Y); - var w = Math.Abs(cur.X - _lassoStart.Value.X); - var h = Math.Abs(cur.Y - _lassoStart.Value.Y); - Canvas.SetLeft(OcrLassoRect, sel.X + x); - Canvas.SetTop(OcrLassoRect, sel.Y + y); - OcrLassoRect.Width = w; - OcrLassoRect.Height = h; + var point = e.GetPosition(AnnotationCanvas); + if (_ocrLasso.HandlePointerMove(point)) + { return; } - _annotationInteractionController.HandlePointerMove(e.GetPosition(AnnotationCanvas)); + _annotationInteractionController.HandlePointerMove(point); } private void Annot_Up(object sender, MouseButtonEventArgs e) { - if (_vm.IsTextLassoActive && _lassoStart.HasValue) - { - var cur = e.GetPosition(AnnotationCanvas); - AnnotationCanvas.ReleaseMouseCapture(); - var x = Math.Min(cur.X, _lassoStart.Value.X); - var y = Math.Min(cur.Y, _lassoStart.Value.Y); - var w = Math.Abs(cur.X - _lassoStart.Value.X); - var h = Math.Abs(cur.Y - _lassoStart.Value.Y); - OcrLassoRect.Visibility = Visibility.Collapsed; - _lassoStart = null; - - if (w >= 4 && h >= 4) - { - _ = DoLassoOcr(new Rect(x, y, w, h)); - } - + var point = e.GetPosition(AnnotationCanvas); + if (_ocrLasso.HandlePointerUp(point)) + { return; } @@ -285,7 +264,7 @@ private void Annot_Up(object sender, MouseButtonEventArgs e) return; } - _annotationInteractionController.HandlePointerUp(e.GetPosition(AnnotationCanvas)); + _annotationInteractionController.HandlePointerUp(point); } private void Tool_Click(object sender, RoutedEventArgs e) @@ -438,51 +417,6 @@ private void DoBeautify(BitmapSource bitmap) Dispatcher.BeginInvoke(DispatcherPriority.ApplicationIdle, new Action(Close)); } - private async Task DoLassoOcr(Rect lassoRect) - { - var background = _renderer.BackgroundCapture; - if (background is null) - { - return; - } - - var pixelX = (int)(lassoRect.X * _vm.DpiX); - var pixelY = (int)(lassoRect.Y * _vm.DpiY); - var pixelW = (int)(lassoRect.Width * _vm.DpiX); - var pixelH = (int)(lassoRect.Height * _vm.DpiY); - - pixelX = Math.Max(0, Math.Min(pixelX, background.PixelWidth - 1)); - pixelY = Math.Max(0, Math.Min(pixelY, background.PixelHeight - 1)); - pixelW = Math.Min(pixelW, background.PixelWidth - pixelX); - pixelH = Math.Min(pixelH, background.PixelHeight - pixelY); - - if (pixelW < 1 || pixelH < 1) - { - return; - } - - var cropped = new CroppedBitmap(background, new Int32Rect(pixelX, pixelY, pixelW, pixelH)); - var ocrProps = new Dictionary - { - ["selection_width_px"] = pixelW.ToString(), - ["selection_height_px"] = pixelH.ToString(), - }; - - _telemetry.TrackEvent("ocr_attempted", ocrProps); - var text = await _ocrService.Recognize(cropped); - - if (string.IsNullOrWhiteSpace(text)) - { - _telemetry.TrackEvent("ocr_no_text", ocrProps); - ShowOcrToast("No text detected \u2014 try a larger area"); - return; - } - - System.Windows.Clipboard.SetText(text); - _telemetry.TrackEvent("ocr_used", ocrProps); - ShowOcrToast("\u2713 Text copied to clipboard"); - } - private async void ShowOcrToast(string message) { if (Dispatcher.HasShutdownStarted || Dispatcher.HasShutdownFinished) @@ -578,30 +512,9 @@ private bool HandleOverlayShortcut(Key key, ModifierKeys modifiers) var shortcuts = _userSettings.Current; if (MatchesShortcut(key, modifiers, shortcuts.OverlayCloseHotkey, shortcuts.OverlayCloseHotkeyModifiers)) { - if (_vm.CurrentPhase == OverlayViewModel.Phase.Annotating) + if (_vm.CurrentPhase == OverlayViewModel.Phase.Annotating && TryDismissAnnotatingUiLayer()) { - if (ShortcutsPopup.Visibility == Visibility.Visible) - { - ShortcutsPopup.Visibility = Visibility.Collapsed; - return true; - } - - if (_vm.IsTextLassoActive) - { - _vm.IsTextLassoActive = false; - OcrLassoRect.Visibility = Visibility.Collapsed; - _lassoStart = null; - return true; - } - - if (_vm.SelectedTool == AnnotationTool.ColorPicker) - { - _vm.RevertToPreviousTool(); - SyncToolbarToSelectedTool(); - UpdateLoupe(null); - AnnotationCanvas.Cursor = _vm.SelectedTool == AnnotationTool.Text ? Cursors.IBeam : Cursors.Cross; - return true; - } + return true; } Close(); @@ -613,30 +526,9 @@ private bool HandleOverlayShortcut(Key key, ModifierKeys modifiers) return false; } - if (key == Key.Escape) + if (key == Key.Escape && TryDismissAnnotatingUiLayer()) { - if (ShortcutsPopup.Visibility == Visibility.Visible) - { - ShortcutsPopup.Visibility = Visibility.Collapsed; - return true; - } - - if (_vm.IsTextLassoActive) - { - _vm.IsTextLassoActive = false; - OcrLassoRect.Visibility = Visibility.Collapsed; - _lassoStart = null; - return true; - } - - if (_vm.SelectedTool == AnnotationTool.ColorPicker) - { - _vm.RevertToPreviousTool(); - SyncToolbarToSelectedTool(); - UpdateLoupe(null); - AnnotationCanvas.Cursor = _vm.SelectedTool == AnnotationTool.Text ? Cursors.IBeam : Cursors.Cross; - return true; - } + return true; } if (MatchesShortcut(key, modifiers, shortcuts.OverlayToggleShortcutsHotkey, shortcuts.OverlayToggleShortcutsHotkeyModifiers)) @@ -695,38 +587,34 @@ private bool HandleOverlayShortcut(Key key, ModifierKeys modifiers) return false; } - private static bool MatchesShortcut(Key key, ModifierKeys pressedModifiers, uint configuredKey, HotkeyModifiers configuredModifiers) - { - if (configuredKey == 0) - { - return false; - } - - return key == KeyInterop.KeyFromVirtualKey((int)configuredKey) - && pressedModifiers == ToModifierKeys(configuredModifiers); - } - - private static ModifierKeys ToModifierKeys(HotkeyModifiers modifiers) + private bool TryDismissAnnotatingUiLayer() { - var result = ModifierKeys.None; - if (modifiers.HasFlag(HotkeyModifiers.Ctrl)) + if (ShortcutsPopup.Visibility == Visibility.Visible) { - result |= ModifierKeys.Control; + ShortcutsPopup.Visibility = Visibility.Collapsed; + return true; } - if (modifiers.HasFlag(HotkeyModifiers.Shift)) + if (_ocrLasso.Cancel()) { - result |= ModifierKeys.Shift; + return true; } - if (modifiers.HasFlag(HotkeyModifiers.Alt)) + if (_vm.SelectedTool == AnnotationTool.ColorPicker) { - result |= ModifierKeys.Alt; + _vm.RevertToPreviousTool(); + SyncToolbarToSelectedTool(); + UpdateLoupe(null); + AnnotationCanvas.Cursor = _vm.SelectedTool == AnnotationTool.Text ? Cursors.IBeam : Cursors.Cross; + return true; } - return result; + return false; } + private static bool MatchesShortcut(Key key, ModifierKeys pressedModifiers, uint configuredKey, HotkeyModifiers configuredModifiers) => + new HotkeyBinding(configuredKey, configuredModifiers).Matches(key, pressedModifiers); + private void ToggleShortcutsPopup() { if (ShortcutsPopup.Visibility == Visibility.Visible) From 8c9da6215cad6d1d7be157f0d9410ff743ff6d78 Mon Sep 17 00:00:00 2001 From: Dimitar Radenkov Date: Sun, 19 Jul 2026 11:49:31 +0300 Subject: [PATCH 3/6] Refactor service registration; consolidate service configuration into AddPointframeAppServices method --- Pointframe.Tests/AppTests.cs | 6 +- Pointframe/App.xaml.cs | 151 +++------------------------ Pointframe/AppServiceRegistration.cs | 111 ++++++++++++++++++++ 3 files changed, 130 insertions(+), 138 deletions(-) create mode 100644 Pointframe/AppServiceRegistration.cs diff --git a/Pointframe.Tests/AppTests.cs b/Pointframe.Tests/AppTests.cs index 1915365..3f5f5b8 100644 --- a/Pointframe.Tests/AppTests.cs +++ b/Pointframe.Tests/AppTests.cs @@ -14,13 +14,11 @@ namespace Pointframe.Tests; public sealed class AppTests { [Fact] - public void ConfigureServices_RegistersCoreServicesAndFactories() + public void AddPointframeAppServices_RegistersCoreServicesAndFactories() { var services = new ServiceCollection(); - typeof(App) - .GetMethod("ConfigureServices", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)! - .Invoke(null, [services]); + services.AddPointframeAppServices(); using var provider = services.BuildServiceProvider(); diff --git a/Pointframe/App.xaml.cs b/Pointframe/App.xaml.cs index a3ea22c..75b1b4d 100644 --- a/Pointframe/App.xaml.cs +++ b/Pointframe/App.xaml.cs @@ -4,11 +4,9 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Pointframe.Automation; -using Pointframe.Data; using Pointframe.Data.Abstractions; using Pointframe.Services; using Pointframe.Services.Messaging; -using Pointframe.Services.Recording; using Pointframe.ViewModels; using Serilog; using Application = System.Windows.Application; @@ -77,7 +75,7 @@ protected override void OnStartup(StartupEventArgs e) logging.ClearProviders(); logging.AddSerilog(dispose: false); }) - .ConfigureServices((_, services) => ConfigureServices(services)) + .ConfigureServices((_, services) => services.AddPointframeAppServices()) .Build(); _logger = _host.Services.GetRequiredService>(); @@ -184,104 +182,6 @@ private void ApplyDataMigrations() migrationService.ApplyMigrations().GetAwaiter().GetResult(); } - private static void ConfigureServices(IServiceCollection services) - { - var dataSourceDirectory = Path.GetDirectoryName(AppPaths.PointframeDatabasePath); - if (!string.IsNullOrWhiteSpace(dataSourceDirectory)) - { - Directory.CreateDirectory(dataSourceDirectory); - } - - services.AddPointframeDataServices($"Data Source={AppPaths.PointframeDatabasePath}"); - - 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(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddSingleton(); - services.AddSingleton(); - services.AddTransient>(sp => inputPath => new TrimViewModel( - inputPath, - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService>())); - services.AddSingleton(); - services.AddSingleton(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(CreateOverlayWindow); - services.AddTransient(); - services.AddSingleton(); - services.AddSingleton(); - services.AddTransient>(sp => bitmap => - { - var window = new BeautifierWindow(sp.GetRequiredService()); - window.Initialize(bitmap); - return window; - }); - services.AddTransient(); - services.AddTransient(); - services.AddTransient>(sp => - (screenRecordingService, outputPath) => new RecordingHudViewModel( - screenRecordingService, - outputPath, - sp.GetRequiredService(), - sp.GetRequiredService>())); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(sp => - new UpdateDownloadViewModel( - UpdateDownloadViewModel.SharedHttp, - sp.GetRequiredService(), - sp.GetService>())); - services.AddTransient>(sp => () => sp.GetRequiredService()); - services.AddTransient>(_ => vm => new UpdateDownloadWindow(vm)); - services.AddTransient(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(sp => sp.GetRequiredService()); - services.AddHostedService(sp => sp.GetRequiredService()); - services.AddHostedService(); - } - - private static OverlayWindow CreateOverlayWindow(IServiceProvider sp) => new( - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService>(), - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService>()); - protected override void OnExit(ExitEventArgs e) { _logger?.LogInformation("Pointframe shutting down"); @@ -394,47 +294,30 @@ private void ShowTrimWindow(string inputPath) window.Show(); } - private void ShowSettingsWindow() - { - if (_settingsWindow is not null) - { - _settingsWindow.Activate(); - return; - } - - _settingsWindow = _host.Services.GetRequiredService(); - RegisterAutomationWindow(_settingsWindow); - _settingsWindow.Closed += (_, _) => _settingsWindow = null; - _settingsWindow.Show(); - } + private void ShowSettingsWindow() => ShowOrActivateWindow(_settingsWindow, window => _settingsWindow = window); - private void ShowAboutWindow() - { - if (_aboutWindow is not null) - { - _aboutWindow.Activate(); - return; - } + private void ShowAboutWindow() => ShowOrActivateWindow(_aboutWindow, window => _aboutWindow = window); - _aboutWindow = _host.Services.GetRequiredService(); - RegisterAutomationWindow(_aboutWindow); - _aboutWindow.Closed += (_, _) => _aboutWindow = null; - _aboutWindow.Show(); - } + private void ShowLibraryWindow() => ShowOrActivateWindow( + _libraryWindow, + window => _libraryWindow = window, + window => window.ViewModel.RequestOpen += OpenCaptureFromLibrary); - private void ShowLibraryWindow() + private void ShowOrActivateWindow(TWindow? current, Action store, Action? initialize = null) + where TWindow : Window { - if (_libraryWindow is not null) + if (current is not null) { - _libraryWindow.Activate(); + current.Activate(); return; } - _libraryWindow = _host.Services.GetRequiredService(); - _libraryWindow.ViewModel.RequestOpen += OpenCaptureFromLibrary; - RegisterAutomationWindow(_libraryWindow); - _libraryWindow.Closed += (_, _) => _libraryWindow = null; - _libraryWindow.Show(); + var window = _host.Services.GetRequiredService(); + initialize?.Invoke(window); + RegisterAutomationWindow(window); + window.Closed += (_, _) => store(null); + store(window); + window.Show(); } private void OpenCaptureFromLibrary(CaptureItem item) diff --git a/Pointframe/AppServiceRegistration.cs b/Pointframe/AppServiceRegistration.cs new file mode 100644 index 0000000..0dfecc5 --- /dev/null +++ b/Pointframe/AppServiceRegistration.cs @@ -0,0 +1,111 @@ +using Microsoft.Extensions.DependencyInjection; +using Pointframe.Data; +using Pointframe.Services; +using Pointframe.Services.Messaging; +using Pointframe.Services.Recording; +using Pointframe.ViewModels; + +namespace Pointframe; + +internal static class AppServiceRegistration +{ + internal static IServiceCollection AddPointframeAppServices(this IServiceCollection services) + { + var dataSourceDirectory = Path.GetDirectoryName(AppPaths.PointframeDatabasePath); + if (!string.IsNullOrWhiteSpace(dataSourceDirectory)) + { + Directory.CreateDirectory(dataSourceDirectory); + } + + services.AddPointframeDataServices($"Data Source={AppPaths.PointframeDatabasePath}"); + + 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(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddSingleton(); + services.AddSingleton(); + services.AddTransient>(sp => inputPath => new TrimViewModel( + inputPath, + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>())); + services.AddSingleton(); + services.AddSingleton(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(CreateOverlayWindow); + services.AddTransient(); + services.AddSingleton(); + services.AddSingleton(); + services.AddTransient>(sp => bitmap => + { + var window = new BeautifierWindow(sp.GetRequiredService()); + window.Initialize(bitmap); + return window; + }); + services.AddTransient(); + services.AddTransient(); + services.AddTransient>(sp => + (screenRecordingService, outputPath) => new RecordingHudViewModel( + screenRecordingService, + outputPath, + sp.GetRequiredService(), + sp.GetRequiredService>())); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(sp => + new UpdateDownloadViewModel( + UpdateDownloadViewModel.SharedHttp, + sp.GetRequiredService(), + sp.GetService>())); + services.AddTransient>(sp => () => sp.GetRequiredService()); + services.AddTransient>(_ => vm => new UpdateDownloadWindow(vm)); + services.AddTransient(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddHostedService(sp => sp.GetRequiredService()); + services.AddHostedService(); + + return services; + } + + private static OverlayWindow CreateOverlayWindow(IServiceProvider sp) => new( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>()); +} From b8579ab5c288a3c993b05624ce51ea2fa2776f22 Mon Sep 17 00:00:00 2001 From: Dimitar Radenkov Date: Sun, 19 Jul 2026 11:59:41 +0300 Subject: [PATCH 4/6] Refactor TrayIconManager and event handling; implement messaging for window requests and recording trim --- Pointframe.Tests/AppTests.cs | 5 ++ .../Services/TrayIconManagerTests.cs | 70 +++++++++++++---- Pointframe/App.xaml.cs | 76 ++++++++++++------- Pointframe/AppServiceRegistration.cs | 1 + .../Infrastructure/TrayIconManager.cs | 51 +++++-------- .../Messaging/OpenImageRequestedMessage.cs | 3 + .../ShowAboutWindowRequestedMessage.cs | 3 + .../ShowLibraryWindowRequestedMessage.cs | 3 + .../ShowSettingsWindowRequestedMessage.cs | 3 + .../TrimRecordingRequestedMessage.cs | 3 + 10 files changed, 139 insertions(+), 79 deletions(-) create mode 100644 Pointframe/Services/Messaging/OpenImageRequestedMessage.cs create mode 100644 Pointframe/Services/Messaging/ShowAboutWindowRequestedMessage.cs create mode 100644 Pointframe/Services/Messaging/ShowLibraryWindowRequestedMessage.cs create mode 100644 Pointframe/Services/Messaging/ShowSettingsWindowRequestedMessage.cs create mode 100644 Pointframe/Services/Messaging/TrimRecordingRequestedMessage.cs diff --git a/Pointframe.Tests/AppTests.cs b/Pointframe.Tests/AppTests.cs index 3f5f5b8..846107e 100644 --- a/Pointframe.Tests/AppTests.cs +++ b/Pointframe.Tests/AppTests.cs @@ -17,6 +17,10 @@ public sealed class AppTests public void AddPointframeAppServices_RegistersCoreServicesAndFactories() { var services = new ServiceCollection(); + // The production host registers logging and configuration; mirror that here. + services.AddLogging(); + services.AddSingleton( + new Microsoft.Extensions.Configuration.ConfigurationBuilder().Build()); services.AddPointframeAppServices(); @@ -24,6 +28,7 @@ public void AddPointframeAppServices_RegistersCoreServicesAndFactories() Assert.IsType(provider.GetRequiredService()); Assert.IsType(provider.GetRequiredService()); + Assert.IsType(provider.GetRequiredService()); Assert.NotNull(provider.GetRequiredService>()); } diff --git a/Pointframe.Tests/Services/TrayIconManagerTests.cs b/Pointframe.Tests/Services/TrayIconManagerTests.cs index 24a71d3..0ad2bcc 100644 --- a/Pointframe.Tests/Services/TrayIconManagerTests.cs +++ b/Pointframe.Tests/Services/TrayIconManagerTests.cs @@ -5,6 +5,7 @@ using Moq; using Pointframe.Models; using Pointframe.Services; +using Pointframe.Services.Messaging; using Pointframe.Tests.Services.Handlers; using Xunit; @@ -282,10 +283,10 @@ public void TrimRecentRecording_Click_WhenRecordingMissing_ShowsWarning() StaTestHelper.Run(() => { var messageBoxMock = new Mock(); - var trimRequested = false; + var eventAggregatorMock = new Mock(); var manager = CreateManager( messageBox: messageBoxMock.Object, - onTrimRecording: _ => trimRequested = true); + eventAggregator: eventAggregatorMock.Object); var missingPath = Path.Combine(Path.GetTempPath(), $"missing-{Guid.NewGuid()}.mp4"); var recentRecording = CreateRecentRecordingItem(missingPath, "00:07"); @@ -296,17 +297,19 @@ public void TrimRecentRecording_Click_WhenRecordingMissing_ShowsWarning() messageBoxMock.Verify(service => service.ShowWarning( "The recording file could not be found.", "Trim Recording"), Times.Once); - Assert.False(trimRequested); + eventAggregatorMock.Verify( + aggregator => aggregator.Publish(It.IsAny()), + Times.Never); }); } [Fact] - public void TrimRecentRecording_Click_WhenRecordingExists_InvokesTrimCallback() + public void TrimRecentRecording_Click_WhenRecordingExists_PublishesTrimRequest() { StaTestHelper.Run(() => { - string? trimmedPath = null; - var manager = CreateManager(onTrimRecording: path => trimmedPath = path); + var eventAggregatorMock = new Mock(); + var manager = CreateManager(eventAggregator: eventAggregatorMock.Object); var tempMp4 = Path.GetTempFileName(); var recentRecording = CreateRecentRecordingItem(tempMp4, "00:09"); @@ -316,7 +319,9 @@ public void TrimRecentRecording_Click_WhenRecordingExists_InvokesTrimCallback() { InvokePrivate(manager, "TrimRecentRecording_Click", menuItem, new RoutedEventArgs()); - Assert.Equal(tempMp4, trimmedPath); + eventAggregatorMock.Verify( + aggregator => aggregator.Publish(It.Is(message => message.RecordingPath == tempMp4)), + Times.Once); } finally { @@ -325,6 +330,44 @@ public void TrimRecentRecording_Click_WhenRecordingExists_InvokesTrimCallback() }); } + [Fact] + public void SnipMenuClicks_ForwardToCaptureLaunchService() + { + StaTestHelper.Run(() => + { + var captureLaunchMock = new Mock(); + var manager = CreateManager(captureLaunch: captureLaunchMock.Object); + + InvokePrivate(manager, "NewSnip_Click", new object(), new RoutedEventArgs()); + InvokePrivate(manager, "WholeScreenSnip_Click", new object(), new RoutedEventArgs()); + InvokePrivate(manager, "CleanWindowSnip_Click", new object(), new RoutedEventArgs()); + + captureLaunchMock.Verify(service => service.StartRegionSnip("tray"), Times.Once); + captureLaunchMock.Verify(service => service.StartWholeScreenSnip("tray"), Times.Once); + captureLaunchMock.Verify(service => service.StartCleanWindowSnip("tray"), Times.Once); + }); + } + + [Fact] + public void ShellMenuClicks_PublishWindowRequests() + { + StaTestHelper.Run(() => + { + var eventAggregatorMock = new Mock(); + var manager = CreateManager(eventAggregator: eventAggregatorMock.Object); + + InvokePrivate(manager, "Settings_Click", new object(), new RoutedEventArgs()); + InvokePrivate(manager, "About_Click", new object(), new RoutedEventArgs()); + InvokePrivate(manager, "Library_Click", new object(), new RoutedEventArgs()); + InvokePrivate(manager, "OpenImage_Click", new object(), new RoutedEventArgs()); + + eventAggregatorMock.Verify(aggregator => aggregator.Publish(It.IsAny()), Times.Once); + eventAggregatorMock.Verify(aggregator => aggregator.Publish(It.IsAny()), Times.Once); + eventAggregatorMock.Verify(aggregator => aggregator.Publish(It.IsAny()), Times.Once); + eventAggregatorMock.Verify(aggregator => aggregator.Publish(It.IsAny()), Times.Once); + }); + } + [Fact] public void ExportRecentRecordingGif_Click_WhenRecordingMissing_ShowsWarning() { @@ -656,7 +699,8 @@ private static TrayIconManager CreateManager( IAutoUpdateService? autoUpdate = null, IUserSettingsService? userSettings = null, IGifExportService? gifExportService = null, - Action? onTrimRecording = null) + ICaptureLaunchService? captureLaunch = null, + IEventAggregator? eventAggregator = null) { return new TrayIconManager( NullLogger.Instance, @@ -668,14 +712,8 @@ private static TrayIconManager CreateManager( userSettings ?? Mock.Of(), gifExportService ?? Mock.Of(), Mock.Of(), - onNewSnip: static () => { }, - onWholeScreenSnip: static () => { }, - onCleanWindowSnip: static () => { }, - onOpenImage: static () => { }, - onTrimRecording: onTrimRecording ?? (static _ => { }), - onShowSettings: static () => { }, - onShowAbout: static () => { }, - onShowLibrary: static () => { }); + captureLaunch ?? Mock.Of(), + eventAggregator ?? Mock.Of()); } private static void InvokePrivate(object target, string methodName, params object[] args) diff --git a/Pointframe/App.xaml.cs b/Pointframe/App.xaml.cs index 75b1b4d..fb660ac 100644 --- a/Pointframe/App.xaml.cs +++ b/Pointframe/App.xaml.cs @@ -21,7 +21,6 @@ public partial class App : Application private IMessageBoxService _messageBox = null!; private IUserSettingsService _userSettings = null!; private IThemeService _themeService = null!; - private IAutoUpdateService _autoUpdate = null!; private IDialogService _dialogService = null!; private IImageFileService _imageFileService = null!; private IGlobalHotkeyService _globalHotkey = null!; @@ -29,9 +28,7 @@ public partial class App : Application private ITrayIconManager _trayIconManager = null!; private ICaptureLaunchService _captureLaunch = null!; private IActivationTelemetryService _activationTelemetry = null!; - private IEventSubscription? _updateAvailableSubscription; - private IEventSubscription? _recordingCompletedSubscription; - private IEventSubscription? _captureCompletedSubscription; + private readonly List _eventSubscriptions = []; private ITelemetryService _telemetry = null!; private DateTime _sessionStartTime; private SettingsWindow? _settingsWindow; @@ -108,10 +105,14 @@ protected override void OnStartup(StartupEventArgs e) if (!automationLaunchOptions.IsAutomationMode) { var eventAggregator = _host.Services.GetRequiredService(); - _updateAvailableSubscription = eventAggregator.Subscribe(HandleUpdateAvailable); - _recordingCompletedSubscription = eventAggregator.Subscribe(HandleRecordingCompleted); - _captureCompletedSubscription = eventAggregator.Subscribe(HandleCaptureCompleted); - _autoUpdate = _host.Services.GetRequiredService(); + _eventSubscriptions.Add(eventAggregator.Subscribe(HandleUpdateAvailable)); + _eventSubscriptions.Add(eventAggregator.Subscribe(HandleRecordingCompleted)); + _eventSubscriptions.Add(eventAggregator.Subscribe(HandleCaptureCompleted)); + _eventSubscriptions.Add(eventAggregator.Subscribe(HandleOpenImageRequested)); + _eventSubscriptions.Add(eventAggregator.Subscribe(HandleTrimRecordingRequested)); + _eventSubscriptions.Add(eventAggregator.Subscribe(HandleShowSettingsWindowRequested)); + _eventSubscriptions.Add(eventAggregator.Subscribe(HandleShowAboutWindowRequested)); + _eventSubscriptions.Add(eventAggregator.Subscribe(HandleShowLibraryWindowRequested)); } _logger.LogInformation("Pointframe starting up"); @@ -139,24 +140,7 @@ protected override void OnStartup(StartupEventArgs e) return; } - _trayIconManager = new TrayIconManager( - _host.Services.GetRequiredService>(), - _messageBox, - _host.Services.GetRequiredService(), - _host.Services.GetRequiredService(), - _host.Services.GetRequiredService(), - _autoUpdate, - _userSettings, - _host.Services.GetRequiredService(), - _telemetry, - onNewSnip: () => _captureLaunch.StartRegionSnip("tray"), - onWholeScreenSnip: () => _captureLaunch.StartWholeScreenSnip("tray"), - onCleanWindowSnip: () => _captureLaunch.StartCleanWindowSnip("tray"), - onOpenImage: () => Dispatcher.InvokeAsync(OpenImage, System.Windows.Threading.DispatcherPriority.ApplicationIdle), - onTrimRecording: ShowTrimWindow, - onShowSettings: ShowSettingsWindow, - onShowAbout: ShowAboutWindow, - onShowLibrary: ShowLibraryWindow); + _trayIconManager = _host.Services.GetRequiredService(); _trayIconManager.Initialize(); startupTimer.Stop(); _telemetry.TrackEvent("startup_completed", new Dictionary @@ -193,9 +177,12 @@ protected override void OnExit(ExitEventArgs e) }); } - _updateAvailableSubscription?.Dispose(); - _recordingCompletedSubscription?.Dispose(); - _captureCompletedSubscription?.Dispose(); + foreach (var subscription in _eventSubscriptions) + { + subscription.Dispose(); + } + + _eventSubscriptions.Clear(); _globalHotkey.Dispose(); _trayIconManager?.Dispose(); _host.StopAsync().GetAwaiter().GetResult(); @@ -414,6 +401,37 @@ private async ValueTask HandleUpdateAvailable(UpdateAvailableMessage message) _telemetry.TrackEvent("update_available", new Dictionary { ["version"] = $"{v.Major}.{v.Minor}.{v.Build}" }); } + private ValueTask HandleOpenImageRequested(OpenImageRequestedMessage message) + { + // Defer until the tray menu has fully unwound so the file dialog keeps focus (see lessons.md). + Dispatcher.InvokeAsync(OpenImage, DispatcherPriority.ApplicationIdle); + return ValueTask.CompletedTask; + } + + private ValueTask HandleTrimRecordingRequested(TrimRecordingRequestedMessage message) + { + ShowTrimWindow(message.RecordingPath); + return ValueTask.CompletedTask; + } + + private ValueTask HandleShowSettingsWindowRequested(ShowSettingsWindowRequestedMessage message) + { + ShowSettingsWindow(); + return ValueTask.CompletedTask; + } + + private ValueTask HandleShowAboutWindowRequested(ShowAboutWindowRequestedMessage message) + { + ShowAboutWindow(); + return ValueTask.CompletedTask; + } + + private ValueTask HandleShowLibraryWindowRequested(ShowLibraryWindowRequestedMessage message) + { + ShowLibraryWindow(); + return ValueTask.CompletedTask; + } + private ValueTask HandleRecordingCompleted(RecordingCompletedMessage message) { _trayIconManager.HandleRecordingCompleted(message.OutputPath, message.ElapsedText); diff --git a/Pointframe/AppServiceRegistration.cs b/Pointframe/AppServiceRegistration.cs index 0dfecc5..a158065 100644 --- a/Pointframe/AppServiceRegistration.cs +++ b/Pointframe/AppServiceRegistration.cs @@ -36,6 +36,7 @@ internal static IServiceCollection AddPointframeAppServices(this IServiceCollect services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/Pointframe/Services/Infrastructure/TrayIconManager.cs b/Pointframe/Services/Infrastructure/TrayIconManager.cs index 5ef96ec..d99d29d 100644 --- a/Pointframe/Services/Infrastructure/TrayIconManager.cs +++ b/Pointframe/Services/Infrastructure/TrayIconManager.cs @@ -1,6 +1,7 @@ using System.Diagnostics; using System.Windows; using Hardcodet.Wpf.TaskbarNotification; +using Pointframe.Services.Messaging; using WpfApplication = System.Windows.Application; using WpfContextMenu = System.Windows.Controls.ContextMenu; using WpfMenuItem = System.Windows.Controls.MenuItem; @@ -19,14 +20,8 @@ internal sealed class TrayIconManager : ITrayIconManager private readonly IUserSettingsService _userSettings; private readonly IGifExportService _gifExportService; private readonly ITelemetryService _telemetry; - private readonly Action _onNewSnip; - private readonly Action _onWholeScreenSnip; - private readonly Action _onCleanWindowSnip; - private readonly Action _onOpenImage; - private readonly Action _onTrimRecording; - private readonly Action _onShowSettings; - private readonly Action _onShowAbout; - private readonly Action _onShowLibrary; + private readonly ICaptureLaunchService _captureLaunch; + private readonly IEventAggregator _eventAggregator; private const int MaxRecentItems = 5; @@ -49,16 +44,9 @@ public TrayIconManager( IUserSettingsService userSettings, IGifExportService gifExportService, ITelemetryService telemetry, - Action onNewSnip, - Action onWholeScreenSnip, - Action onCleanWindowSnip, - Action onOpenImage, - Action onTrimRecording, - Action onShowSettings, - Action onShowAbout, - Action onShowLibrary) - { - _onShowLibrary = onShowLibrary; + ICaptureLaunchService captureLaunch, + IEventAggregator eventAggregator) + { _logger = logger; _messageBox = messageBox; _processService = processService; @@ -68,13 +56,8 @@ public TrayIconManager( _userSettings = userSettings; _gifExportService = gifExportService; _telemetry = telemetry; - _onNewSnip = onNewSnip; - _onWholeScreenSnip = onWholeScreenSnip; - _onCleanWindowSnip = onCleanWindowSnip; - _onOpenImage = onOpenImage; - _onTrimRecording = onTrimRecording; - _onShowSettings = onShowSettings; - _onShowAbout = onShowAbout; + _captureLaunch = captureLaunch; + _eventAggregator = eventAggregator; } public void Initialize() @@ -233,14 +216,14 @@ internal static WpfMenuItem CreateTrayMenuItem(string header, RoutedEventHandler return menuItem; } - private void TrayIcon_LeftClick(object sender, RoutedEventArgs e) => _onNewSnip(); - private void NewSnip_Click(object sender, RoutedEventArgs e) => _onNewSnip(); - private void WholeScreenSnip_Click(object sender, RoutedEventArgs e) => _onWholeScreenSnip(); - private void CleanWindowSnip_Click(object sender, RoutedEventArgs e) => _onCleanWindowSnip(); - private void Settings_Click(object sender, RoutedEventArgs e) => _onShowSettings(); - private void About_Click(object sender, RoutedEventArgs e) => _onShowAbout(); - private void Library_Click(object sender, RoutedEventArgs e) => _onShowLibrary(); - private void OpenImage_Click(object sender, RoutedEventArgs e) => _onOpenImage(); + private void TrayIcon_LeftClick(object sender, RoutedEventArgs e) => _captureLaunch.StartRegionSnip("tray"); + private void NewSnip_Click(object sender, RoutedEventArgs e) => _captureLaunch.StartRegionSnip("tray"); + private void WholeScreenSnip_Click(object sender, RoutedEventArgs e) => _captureLaunch.StartWholeScreenSnip("tray"); + private void CleanWindowSnip_Click(object sender, RoutedEventArgs e) => _captureLaunch.StartCleanWindowSnip("tray"); + private void Settings_Click(object sender, RoutedEventArgs e) => _ = _eventAggregator.Publish(new ShowSettingsWindowRequestedMessage()); + private void About_Click(object sender, RoutedEventArgs e) => _ = _eventAggregator.Publish(new ShowAboutWindowRequestedMessage()); + private void Library_Click(object sender, RoutedEventArgs e) => _ = _eventAggregator.Publish(new ShowLibraryWindowRequestedMessage()); + private void OpenImage_Click(object sender, RoutedEventArgs e) => _ = _eventAggregator.Publish(new OpenImageRequestedMessage()); private void Exit_Click(object sender, RoutedEventArgs e) => WpfApplication.Current.Shutdown(); private void OpenSnipsFolder_Click(object sender, RoutedEventArgs e) @@ -544,7 +527,7 @@ private void TrimRecentRecording_Click(object sender, RoutedEventArgs e) _telemetry.TrackEvent("video_trim_opened"); DismissTransientUi(); - _onTrimRecording(recentRecording.OutputPath); + _ = _eventAggregator.Publish(new TrimRecordingRequestedMessage(recentRecording.OutputPath)); } private async void ExportRecentRecordingGif_Click(object sender, RoutedEventArgs e) diff --git a/Pointframe/Services/Messaging/OpenImageRequestedMessage.cs b/Pointframe/Services/Messaging/OpenImageRequestedMessage.cs new file mode 100644 index 0000000..774cf77 --- /dev/null +++ b/Pointframe/Services/Messaging/OpenImageRequestedMessage.cs @@ -0,0 +1,3 @@ +namespace Pointframe.Services.Messaging; + +public sealed record OpenImageRequestedMessage; diff --git a/Pointframe/Services/Messaging/ShowAboutWindowRequestedMessage.cs b/Pointframe/Services/Messaging/ShowAboutWindowRequestedMessage.cs new file mode 100644 index 0000000..80d9ce0 --- /dev/null +++ b/Pointframe/Services/Messaging/ShowAboutWindowRequestedMessage.cs @@ -0,0 +1,3 @@ +namespace Pointframe.Services.Messaging; + +public sealed record ShowAboutWindowRequestedMessage; diff --git a/Pointframe/Services/Messaging/ShowLibraryWindowRequestedMessage.cs b/Pointframe/Services/Messaging/ShowLibraryWindowRequestedMessage.cs new file mode 100644 index 0000000..af0de10 --- /dev/null +++ b/Pointframe/Services/Messaging/ShowLibraryWindowRequestedMessage.cs @@ -0,0 +1,3 @@ +namespace Pointframe.Services.Messaging; + +public sealed record ShowLibraryWindowRequestedMessage; diff --git a/Pointframe/Services/Messaging/ShowSettingsWindowRequestedMessage.cs b/Pointframe/Services/Messaging/ShowSettingsWindowRequestedMessage.cs new file mode 100644 index 0000000..3040b85 --- /dev/null +++ b/Pointframe/Services/Messaging/ShowSettingsWindowRequestedMessage.cs @@ -0,0 +1,3 @@ +namespace Pointframe.Services.Messaging; + +public sealed record ShowSettingsWindowRequestedMessage; diff --git a/Pointframe/Services/Messaging/TrimRecordingRequestedMessage.cs b/Pointframe/Services/Messaging/TrimRecordingRequestedMessage.cs new file mode 100644 index 0000000..ae5587e --- /dev/null +++ b/Pointframe/Services/Messaging/TrimRecordingRequestedMessage.cs @@ -0,0 +1,3 @@ +namespace Pointframe.Services.Messaging; + +public sealed record TrimRecordingRequestedMessage(string RecordingPath); From d0a801a69003dbd978eb9f8fb391bb99a4923be5 Mon Sep 17 00:00:00 2001 From: Dimitar Radenkov Date: Sun, 19 Jul 2026 14:11:42 +0300 Subject: [PATCH 5/6] Refactor ScreenRecordingService to use RecordingMicrophoneSession for microphone management; add unit tests for RecordingMicrophoneSession --- .../RecordingMicrophoneSessionTests.cs | 84 +++++++++++++ .../Recording/RecordingMicrophoneSession.cs | 60 +++++++++ .../Recording/ScreenRecordingService.cs | 119 ++++++++---------- 3 files changed, 193 insertions(+), 70 deletions(-) create mode 100644 Pointframe.Tests/Services/RecordingMicrophoneSessionTests.cs create mode 100644 Pointframe/Services/Recording/RecordingMicrophoneSession.cs diff --git a/Pointframe.Tests/Services/RecordingMicrophoneSessionTests.cs b/Pointframe.Tests/Services/RecordingMicrophoneSessionTests.cs new file mode 100644 index 0000000..9084034 --- /dev/null +++ b/Pointframe.Tests/Services/RecordingMicrophoneSessionTests.cs @@ -0,0 +1,84 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Pointframe.Services; +using Xunit; + +namespace Pointframe.Tests.Services; + +public sealed class RecordingMicrophoneSessionTests +{ + [Fact] + public void Constructor_WithoutDevice_IsDisabledAndNotToggleable() + { + var session = new RecordingMicrophoneSession( + Mock.Of(), + NullLogger.Instance, + deviceName: null); + + Assert.False(session.IsEnabled); + Assert.False(session.CanToggleMute); + Assert.False(session.InitialMutedState); + } + + [Fact] + public void Constructor_WithDevice_CapturesInitialMuteState() + { + var microphoneService = Mock.Of(service => + service.TryGetCaptureDeviceMuted("Studio Mic") == true); + + var session = new RecordingMicrophoneSession(microphoneService, NullLogger.Instance, "Studio Mic"); + + Assert.True(session.IsEnabled); + Assert.True(session.CanToggleMute); + Assert.True(session.InitialMutedState); + } + + [Fact] + public void TrySetMuted_WithoutDevice_ReturnsFalse() + { + var session = new RecordingMicrophoneSession( + Mock.Of(), + NullLogger.Instance, + deviceName: null); + + Assert.False(session.TrySetMuted(true)); + } + + [Fact] + public void TrySetMuted_WhenDeviceCallFails_ReturnsFalse() + { + var microphoneService = new Mock(); + microphoneService.Setup(service => service.TryGetCaptureDeviceMuted("Studio Mic")).Returns(false); + microphoneService.Setup(service => service.TrySetCaptureDeviceMuted("Studio Mic", true)).Returns(false); + + var session = new RecordingMicrophoneSession(microphoneService.Object, NullLogger.Instance, "Studio Mic"); + + Assert.False(session.TrySetMuted(true)); + } + + [Fact] + public void RestoreInitialMuteState_RestoresCapturedState() + { + var microphoneService = new Mock(); + microphoneService.Setup(service => service.TryGetCaptureDeviceMuted("Studio Mic")).Returns(false); + microphoneService.Setup(service => service.TrySetCaptureDeviceMuted("Studio Mic", It.IsAny())).Returns(true); + + var session = new RecordingMicrophoneSession(microphoneService.Object, NullLogger.Instance, "Studio Mic"); + session.TrySetMuted(true); + session.RestoreInitialMuteState(); + + microphoneService.Verify(service => service.TrySetCaptureDeviceMuted("Studio Mic", false), Times.Once); + } + + [Fact] + public void RestoreInitialMuteState_WhenInitialStateUnknown_DoesNothing() + { + var microphoneService = new Mock(); + microphoneService.Setup(service => service.TryGetCaptureDeviceMuted("Studio Mic")).Returns((bool?)null); + + var session = new RecordingMicrophoneSession(microphoneService.Object, NullLogger.Instance, "Studio Mic"); + session.RestoreInitialMuteState(); + + microphoneService.Verify(service => service.TrySetCaptureDeviceMuted(It.IsAny(), It.IsAny()), Times.Never); + } +} diff --git a/Pointframe/Services/Recording/RecordingMicrophoneSession.cs b/Pointframe/Services/Recording/RecordingMicrophoneSession.cs new file mode 100644 index 0000000..ed6a5d1 --- /dev/null +++ b/Pointframe/Services/Recording/RecordingMicrophoneSession.cs @@ -0,0 +1,60 @@ +namespace Pointframe.Services; + +// Owns the mute state of the active recording microphone for one session. +// The initial mute state is captured at creation so the recording-time toggle +// never turns into a persistent system-wide mute (see lessons.md). +internal sealed class RecordingMicrophoneSession +{ + private readonly IMicrophoneDeviceService _microphoneDeviceService; + private readonly ILogger _logger; + private readonly string? _deviceName; + private readonly bool? _initialMutedState; + + public RecordingMicrophoneSession( + IMicrophoneDeviceService microphoneDeviceService, + ILogger logger, + string? deviceName) + { + _microphoneDeviceService = microphoneDeviceService; + _logger = logger; + _deviceName = deviceName; + _initialMutedState = deviceName is null + ? null + : microphoneDeviceService.TryGetCaptureDeviceMuted(deviceName); + } + + public bool IsEnabled => _deviceName is not null; + + public bool CanToggleMute => _initialMutedState.HasValue; + + public bool InitialMutedState => _initialMutedState ?? false; + + public bool TrySetMuted(bool isMuted) + { + if (string.IsNullOrWhiteSpace(_deviceName)) + { + return false; + } + + if (!_microphoneDeviceService.TrySetCaptureDeviceMuted(_deviceName, isMuted)) + { + _logger.LogWarning("Failed to set microphone mute state to {IsMuted} for active recording device '{DeviceName}'", isMuted, _deviceName); + return false; + } + + return true; + } + + public void RestoreInitialMuteState() + { + if (string.IsNullOrWhiteSpace(_deviceName) || !_initialMutedState.HasValue) + { + return; + } + + if (!_microphoneDeviceService.TrySetCaptureDeviceMuted(_deviceName, _initialMutedState.Value)) + { + _logger.LogWarning("Failed to restore microphone mute state for recording device '{DeviceName}'", _deviceName); + } + } +} diff --git a/Pointframe/Services/Recording/ScreenRecordingService.cs b/Pointframe/Services/Recording/ScreenRecordingService.cs index 1c9af24..cbcf6b6 100644 --- a/Pointframe/Services/Recording/ScreenRecordingService.cs +++ b/Pointframe/Services/Recording/ScreenRecordingService.cs @@ -36,8 +36,7 @@ private static extern bool BitBlt( private int _captureWidth; private int _captureHeight; private int _fps; - private string? _activeMicrophoneDeviceName; - private bool? _restoreMicrophoneMutedState; + private RecordingMicrophoneSession? _microphoneSession; private byte[]? _latestFrameBytes; private Stopwatch? _sessionStopwatch; private int _attemptedFrameCount; @@ -121,14 +120,10 @@ public void Start( _writer = _writerFactory.Create(width, height, fps, outputPath, microphoneDeviceName); _screenDc = new ScreenDc(); - IsRecordingMicrophoneEnabled = microphoneDeviceName is not null; - _activeMicrophoneDeviceName = microphoneDeviceName; - var initialMicrophoneMutedState = microphoneDeviceName is null - ? null - : _microphoneDeviceService.TryGetCaptureDeviceMuted(microphoneDeviceName); - _restoreMicrophoneMutedState = initialMicrophoneMutedState; - CanToggleMicrophone = initialMicrophoneMutedState.HasValue; - IsMicrophoneMuted = initialMicrophoneMutedState ?? false; + _microphoneSession = new RecordingMicrophoneSession(_microphoneDeviceService, _logger, microphoneDeviceName); + IsRecordingMicrophoneEnabled = _microphoneSession.IsEnabled; + CanToggleMicrophone = _microphoneSession.CanToggleMute; + IsMicrophoneMuted = _microphoneSession.InitialMutedState; // Bounded channel: if the encode loop falls behind, CaptureFrameToChannel will // skip the newest frame (TryWrite returns false) rather than stalling the capture thread. @@ -152,31 +147,16 @@ public void Start( { IsRecording = false; IsPaused = false; - IsRecordingMicrophoneEnabled = false; - CanToggleMicrophone = false; - IsMicrophoneMuted = false; - _activeMicrophoneDeviceName = null; - _restoreMicrophoneMutedState = null; - _latestFrameBytes = null; + ResetMicrophoneFlags(); + // Nothing was muted yet on the failure path — discard without restoring. + _microphoneSession = null; _cts?.Cancel(); WaitForStartFailureTaskShutdown(_captureLoop); WaitForStartFailureTaskShutdown(_encodeLoop); - _screenDc?.Dispose(); - _screenDc = null; - _captureGraphics?.Dispose(); - _captureGraphics = null; - _captureBitmap?.Dispose(); - _captureBitmap = null; + DisposeCaptureResources(); _writer?.Dispose(); _writer = null; - _captureLoop = null; - _encodeLoop = null; - _encodeChannel = null; - _cts?.Dispose(); - _cts = null; - ClearBufferPool(); - - _sessionStopwatch = null; + ReleaseSessionReferences(); throw; } } @@ -232,18 +212,8 @@ public void Stop() finally { LogSessionSummary(stopRequestedElapsed); - _captureGraphics?.Dispose(); - _captureGraphics = null; - _captureBitmap?.Dispose(); - _captureBitmap = null; - var screenDc = _screenDc; - _screenDc = null; - screenDc?.Dispose(); - _latestFrameBytes = null; - IsRecordingMicrophoneEnabled = false; - CanToggleMicrophone = false; - IsMicrophoneMuted = false; - ClearBufferPool(); + DisposeCaptureResources(); + ResetMicrophoneFlags(); try { @@ -252,18 +222,46 @@ public void Stop() } finally { - RestoreMicrophoneMuteState(); + // Restore only after the writer has fully closed — ffmpeg still owns the device until then. + _microphoneSession?.RestoreInitialMuteState(); + _microphoneSession = null; _writer = null; } - _cts?.Dispose(); - _cts = null; - _captureLoop = null; - _encodeLoop = null; - _encodeChannel = null; - _sessionStopwatch = null; + + ReleaseSessionReferences(); } } + private void ResetMicrophoneFlags() + { + IsRecordingMicrophoneEnabled = false; + CanToggleMicrophone = false; + IsMicrophoneMuted = false; + } + + private void DisposeCaptureResources() + { + _captureGraphics?.Dispose(); + _captureGraphics = null; + _captureBitmap?.Dispose(); + _captureBitmap = null; + var screenDc = _screenDc; + _screenDc = null; + screenDc?.Dispose(); + } + + private void ReleaseSessionReferences() + { + _latestFrameBytes = null; + ClearBufferPool(); + _cts?.Dispose(); + _cts = null; + _captureLoop = null; + _encodeLoop = null; + _encodeChannel = null; + _sessionStopwatch = null; + } + private async Task CaptureLoop(CancellationToken ct) { _logger.LogDebug("Capture loop started"); @@ -448,14 +446,13 @@ public void Resume() public bool TrySetMicrophoneMuted(bool isMuted) { - if (!CanToggleMicrophone || string.IsNullOrWhiteSpace(_activeMicrophoneDeviceName)) + if (!CanToggleMicrophone || _microphoneSession is null) { return false; } - if (!_microphoneDeviceService.TrySetCaptureDeviceMuted(_activeMicrophoneDeviceName, isMuted)) + if (!_microphoneSession.TrySetMuted(isMuted)) { - _logger.LogWarning("Failed to set microphone mute state to {IsMuted} for active recording device '{DeviceName}'", isMuted, _activeMicrophoneDeviceName); return false; } @@ -586,24 +583,6 @@ private static void WaitForStartFailureTaskShutdown(Task? task) } } - private void RestoreMicrophoneMuteState() - { - if (string.IsNullOrWhiteSpace(_activeMicrophoneDeviceName) || !_restoreMicrophoneMutedState.HasValue) - { - _activeMicrophoneDeviceName = null; - _restoreMicrophoneMutedState = null; - return; - } - - if (!_microphoneDeviceService.TrySetCaptureDeviceMuted(_activeMicrophoneDeviceName, _restoreMicrophoneMutedState.Value)) - { - _logger.LogWarning("Failed to restore microphone mute state for recording device '{DeviceName}'", _activeMicrophoneDeviceName); - } - - _activeMicrophoneDeviceName = null; - _restoreMicrophoneMutedState = null; - } - // Lightweight RAII wrapper around the screen device context. private sealed class ScreenDc : IDisposable { From a113c1254c6976e0c32525af9c7e96327d5727a0 Mon Sep 17 00:00:00 2001 From: Dimitar Radenkov Date: Sun, 19 Jul 2026 14:22:02 +0300 Subject: [PATCH 6/6] Refactor OcrLassoController to release mouse capture on lasso deactivation; update OverlayWindowInteractionTests to verify annotation canvas behavior --- Pointframe.Tests/OverlayWindowInteractionTests.cs | 1 + Pointframe/Services/Annotation/OcrLassoController.cs | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/Pointframe.Tests/OverlayWindowInteractionTests.cs b/Pointframe.Tests/OverlayWindowInteractionTests.cs index b1098b8..c395c74 100644 --- a/Pointframe.Tests/OverlayWindowInteractionTests.cs +++ b/Pointframe.Tests/OverlayWindowInteractionTests.cs @@ -155,6 +155,7 @@ public void WindowKeyDown_Escape_WhenTextLassoActive_ClearsLassoState() var lasso = GetPrivateField(context.Window, "_ocrLasso"); lasso.HandlePointerDown(new Point(12d, 14d)); Assert.Equal(Visibility.Visible, lassoRect.Visibility); + Assert.True(lasso.HasPendingLasso); var args = CreateKeyArgs(Key.Escape); InvokePrivate(context.Window, "Window_KeyDown", context.Window, args); diff --git a/Pointframe/Services/Annotation/OcrLassoController.cs b/Pointframe/Services/Annotation/OcrLassoController.cs index e455eed..1ac946b 100644 --- a/Pointframe/Services/Annotation/OcrLassoController.cs +++ b/Pointframe/Services/Annotation/OcrLassoController.cs @@ -103,6 +103,11 @@ public bool Cancel() return false; } + if (_canvas.IsMouseCaptured) + { + _canvas.ReleaseMouseCapture(); + } + _viewModel.IsTextLassoActive = false; _lassoRect.Visibility = Visibility.Collapsed; _lassoStart = null;