From 56a398f0556cbe82d1486deb8b90a7fdd9cb37bd Mon Sep 17 00:00:00 2001 From: Aleksandr Neychev Date: Wed, 12 Aug 2026 21:31:42 +0500 Subject: [PATCH] changed settings saving --- CursorLang.Agent/Windows/NativePopupWindow.cs | 38 ++- .../Models/AppSettingsTests.cs | 182 ++++++++++- .../Models/PopupModeSettingsTests.cs | 289 ++++++++++++++++++ .../Resources/StringsTests.cs | 5 + .../CapsLockSwitchCoordinatorTests.cs | 2 +- .../Services/SettingsServiceTests.cs | 161 ++++++++-- CursorLang.Core/Models/AppSettings.cs | 131 ++++---- CursorLang.Core/Models/PopupModeSettings.cs | 141 +++++++++ CursorLang.Core/Models/PopupPlacement.cs | 17 +- CursorLang.Core/Resources/Strings.resx | 9 + CursorLang.Core/Resources/Strings.ru.resx | 9 + .../Services/ThemeServiceTests.cs | 2 +- .../ViewModels/SettingsViewModelTests.cs | 19 +- .../Views/MainWindowTests.cs | 184 ++++++++++- CursorLang.Settings/Themes/Controls.xaml | 5 + .../ViewModels/SettingsViewModel.cs | 5 + CursorLang.Settings/Views/MainWindow.xaml | 76 +++-- README.RU.md | 22 ++ README.md | 22 ++ 19 files changed, 1169 insertions(+), 150 deletions(-) create mode 100644 CursorLang.Core.Tests/Models/PopupModeSettingsTests.cs create mode 100644 CursorLang.Core/Models/PopupModeSettings.cs diff --git a/CursorLang.Agent/Windows/NativePopupWindow.cs b/CursorLang.Agent/Windows/NativePopupWindow.cs index 1a7d1ce..7018f43 100644 --- a/CursorLang.Agent/Windows/NativePopupWindow.cs +++ b/CursorLang.Agent/Windows/NativePopupWindow.cs @@ -91,8 +91,17 @@ internal sealed class NativePopupWindow : NativeWindow, ILayoutPopupWindow PopupWindowNative.Point position = atFixedPoint ? PopupLayout.OnScreen( - work, _settings.ScreenPosition, PopupLayout.ToPixels(_settings.ScreenMargin, scale), width, height) - : PopupLayout.NearAnchor(anchor, AnchorSideForMode(), OffsetForMode(scale), width, height); + work, + _settings.FixedPoint.Position, + PopupLayout.ToPixels(_settings.FixedPoint.Offset, scale), + width, + height) + : PopupLayout.NearAnchor( + anchor, + SideForMode(), + PopupLayout.ToPixels(_settings.Current.Offset, scale), + width, + height); if (!Draw(text, position, width, height, PopupLayout.ToPixels(CornerRadius, scale))) { @@ -169,7 +178,7 @@ internal sealed class NativePopupWindow : NativeWindow, ILayoutPopupWindow RoundTheCorners(bits, width, height, radius); var size = new WindowNative.Size { Width = width, Height = height }; - var alpha = (byte)Math.Clamp(Math.Round(_settings.Opacity * 255), 0, 255); + var alpha = (byte)Math.Clamp(Math.Round(_settings.Current.Opacity * 255), 0, 255); return WindowNative.SetContent(Handle, at, size, memory, alpha); } @@ -193,7 +202,7 @@ internal sealed class NativePopupWindow : NativeWindow, ILayoutPopupWindow private void Fill(IntPtr bits, int width, int height) { - Color background = _settings.BackgroundColor; + Color background = _settings.Current.BackgroundColor; // Straight into the bitmap rather than through a brush: the pixels have to be // written anyway to carry an alpha channel GDI would not touch @@ -218,7 +227,7 @@ internal sealed class NativePopupWindow : NativeWindow, ILayoutPopupWindow IntPtr previousFont = GdiNative.SelectObject(deviceContext, _font); GdiNative.SetBkMode(deviceContext, GdiNative.TRANSPARENT); - GdiNative.SetTextColor(deviceContext, GdiNative.ToColorRef(_settings.ForegroundColor)); + GdiNative.SetTextColor(deviceContext, GdiNative.ToColorRef(_settings.Current.ForegroundColor)); GdiNative.DrawText(deviceContext, text, text.Length, ref bounds, GdiNative.DT_SINGLELINE | GdiNative.DT_CENTER | GdiNative.DT_VCENTER | @@ -333,13 +342,11 @@ internal sealed class NativePopupWindow : NativeWindow, ILayoutPopupWindow return PopupLayout.AsAnchor(PopupWindowNative.GetCursorPosition()); } - // Every anchor mode has a side and an offset of its own - private AnchorSide AnchorSideForMode() => - _settings.PlacementMode == PopupPlacementMode.AtCaret ? _settings.CaretSide : _settings.CursorSide; - - private int OffsetForMode(double scale) => PopupLayout.ToPixels( - _settings.PlacementMode == PopupPlacementMode.AtCaret ? _settings.CaretOffset : _settings.CursorOffset, - scale); + // The caret has two sides to choose from and the cursor has six, so each mode names + // its own side in its own terms + private AnchorSide SideForMode() => _settings.PlacementMode == PopupPlacementMode.AtCaret + ? _settings.AtCaret.Anchor + : _settings.AtCursor.Side; private Size MeasureText(string text) { @@ -364,11 +371,12 @@ internal sealed class NativePopupWindow : NativeWindow, ILayoutPopupWindow } // The font is rebuilt only when the size in the settings or the monitor scale - // changes: it is the one expensive thing a show does + // changes: it is the one expensive thing a show does. A switch of the placement + // mode counts as a change of the size, since the size belongs to the mode private void EnsureFont(double scale) { if (_font != IntPtr.Zero && - Math.Abs(_fontSize - _settings.FontSize) < 0.01 && + Math.Abs(_fontSize - _settings.Current.FontSize) < 0.01 && Math.Abs(_fontScale - scale) < 0.01) { return; @@ -376,7 +384,7 @@ internal sealed class NativePopupWindow : NativeWindow, ILayoutPopupWindow ReleaseFont(); - _fontSize = _settings.FontSize; + _fontSize = _settings.Current.FontSize; _fontScale = scale; _font = GdiNative.CreateFont(_fontSize, scale); } diff --git a/CursorLang.Core.Tests/Models/AppSettingsTests.cs b/CursorLang.Core.Tests/Models/AppSettingsTests.cs index e8a3422..1201022 100644 --- a/CursorLang.Core.Tests/Models/AppSettingsTests.cs +++ b/CursorLang.Core.Tests/Models/AppSettingsTests.cs @@ -16,18 +16,115 @@ public sealed class AppSettingsTests Assert.Equal("en", settings.Language); Assert.Equal(AppTheme.System, settings.Theme); Assert.Equal(PopupPlacementMode.AtCursor, settings.PlacementMode); - Assert.Equal(AnchorSide.BottomRight, settings.CursorSide); - Assert.Equal(16, settings.CursorOffset); - Assert.Equal(AnchorSide.BottomRight, settings.CaretSide); - Assert.Equal(16, settings.CaretOffset); - Assert.Equal(ScreenPosition.BottomRight, settings.ScreenPosition); - Assert.Equal(24, settings.ScreenMargin); - Assert.Equal(20, settings.FontSize); - Assert.Equal(0.9, settings.Opacity); Assert.Equal(500, settings.DurationMilliseconds); Assert.Equal(300, settings.CapsLockHoldMilliseconds); - Assert.Equal(Color.FromArgb(0x20, 0x20, 0x20), settings.BackgroundColor); - Assert.Equal(Color.FromArgb(0xFF, 0xFF, 0xFF), settings.ForegroundColor); + } + + [Fact] + public void Every_mode_starts_out_looking_the_same() + { + var settings = new AppSettings(); + + Assert.Equal(AnchorSide.BottomRight, settings.AtCursor.Side); + Assert.Equal(16, settings.AtCursor.Offset); + Assert.Equal(CaretSide.Right, settings.AtCaret.Side); + Assert.Equal(16, settings.AtCaret.Offset); + + // The middle of the monitor, where there is no edge to stand off from + Assert.Equal(ScreenPosition.Center, settings.FixedPoint.Position); + Assert.Equal(0, settings.FixedPoint.Offset); + + PopupModeSettings[] modes = [settings.AtCursor, settings.AtCaret, settings.FixedPoint]; + + Assert.All(modes, mode => + { + Assert.Equal(20, mode.FontSize); + Assert.Equal(0.9, mode.Opacity); + Assert.Equal(Color.FromArgb(0x20, 0x20, 0x20), mode.BackgroundColor); + Assert.Equal(Color.FromArgb(0xFF, 0xFF, 0xFF), mode.ForegroundColor); + }); + + // Three modes and three sets of settings: none of them is shared + Assert.Equal(3, modes.Distinct().Count()); + } + + [Theory] + [InlineData(PopupPlacementMode.AtCursor)] + [InlineData(PopupPlacementMode.AtCaret)] + [InlineData(PopupPlacementMode.FixedPoint)] + public void The_chosen_mode_is_the_one_handed_out(PopupPlacementMode mode) + { + var settings = new AppSettings { PlacementMode = mode }; + + PopupModeSettings expected = mode switch + { + PopupPlacementMode.AtCaret => settings.AtCaret, + PopupPlacementMode.FixedPoint => settings.FixedPoint, + _ => settings.AtCursor, + }; + + Assert.Same(expected, settings.Current); + } + + /// + /// The settings of a mode are its own: setting one up leaves the others alone. + /// + /// + /// This is the whole point of keeping them per mode. A look shared by the modes + /// meant setting it up again after every switch, and switching modes to see what + /// they do undid what had just been set up. + /// + [Fact] + public void Setting_one_mode_up_leaves_the_others_where_they_were() + { + var settings = new AppSettings(); + + settings.AtCaret.FontSize = 12; + settings.AtCaret.Side = CaretSide.Left; + settings.AtCaret.BackgroundColor = Color.FromArgb(0x11, 0x22, 0x33); + + Assert.Equal(20, settings.AtCursor.FontSize); + Assert.Equal(AnchorSide.BottomRight, settings.AtCursor.Side); + Assert.Equal(20, settings.FixedPoint.FontSize); + Assert.Equal(Color.FromArgb(0x20, 0x20, 0x20), settings.FixedPoint.BackgroundColor); + } + + // The mode in force decides what the look means, so a switch of it is a change of + // everything the window shows through the current mode + [Fact] + public void A_switch_of_the_mode_is_announced_as_a_change_of_the_current_one() + { + var settings = new AppSettings(); + List changed = []; + settings.PropertyChanged += (_, e) => changed.Add(e.PropertyName); + + settings.PlacementMode = PopupPlacementMode.FixedPoint; + + Assert.Contains(nameof(AppSettings.PlacementMode), changed); + Assert.Contains(nameof(AppSettings.Current), changed); + } + + /// + /// A change inside a mode is passed on as a change of the settings. + /// + /// + /// The service that writes the file listens to the settings alone. Without this, a + /// font size dragged in the window would never reach the disk. + /// + [Fact] + public void A_change_inside_a_mode_is_announced_by_the_settings() + { + var settings = new AppSettings(); + List changed = []; + settings.PropertyChanged += (_, e) => changed.Add(e.PropertyName); + + settings.AtCaret.FontSize = 42; + settings.FixedPoint.Position = ScreenPosition.Bottom; + settings.AtCursor.Offset = 8; + + Assert.Contains("AtCaret.FontSize", changed); + Assert.Contains("FixedPoint.Position", changed); + Assert.Contains("AtCursor.Offset", changed); } // The app must not change how the system behaves until it is asked to @@ -82,8 +179,60 @@ public sealed class AppSettingsTests Assert.Empty(changed); } - // Duration and CapsLockHoldDelay are derived from other settings and have - // no business being in the file + /// + /// Pouring another instance in fills the modes in place rather than replacing them. + /// + /// + /// This is the agent's side of the connection: the fresh values arrive as a freshly + /// parsed instance. Replacing the mode objects would leave the popup bound to the + /// old ones. + /// + [Fact] + public void Taking_other_settings_over_fills_the_modes_that_are_already_there() + { + var settings = new AppSettings(); + CursorModeSettings cursor = settings.AtCursor; + + var other = new AppSettings + { + Language = "ru", + Theme = AppTheme.Dark, + PlacementMode = PopupPlacementMode.FixedPoint, + DurationMilliseconds = 900, + UseCapsLockHotkey = true, + CapsLockHoldMilliseconds = 450, + }; + + other.AtCursor.Side = AnchorSide.TopLeft; + other.AtCursor.Offset = 5; + other.AtCursor.FontSize = 11; + other.AtCaret.Side = CaretSide.Left; + other.AtCaret.ForegroundColor = Color.FromArgb(0x0A, 0x0B, 0x0C); + other.FixedPoint.Position = ScreenPosition.Top; + other.FixedPoint.Offset = 64; + + settings.CopyFrom(other); + + Assert.Same(cursor, settings.AtCursor); + + Assert.Equal("ru", settings.Language); + Assert.Equal(AppTheme.Dark, settings.Theme); + Assert.Equal(PopupPlacementMode.FixedPoint, settings.PlacementMode); + Assert.Equal(900, settings.DurationMilliseconds); + Assert.True(settings.UseCapsLockHotkey); + Assert.Equal(450, settings.CapsLockHoldMilliseconds); + + Assert.Equal(AnchorSide.TopLeft, settings.AtCursor.Side); + Assert.Equal(5, settings.AtCursor.Offset); + Assert.Equal(11, settings.AtCursor.FontSize); + Assert.Equal(CaretSide.Left, settings.AtCaret.Side); + Assert.Equal(Color.FromArgb(0x0A, 0x0B, 0x0C), settings.AtCaret.ForegroundColor); + Assert.Equal(ScreenPosition.Top, settings.FixedPoint.Position); + Assert.Equal(64, settings.FixedPoint.Offset); + } + + // Derived values and the modes handed out for convenience are computed from what + // is stored and have no business being in the file themselves [Fact] public void Derived_values_stay_out_of_the_file() { @@ -93,10 +242,14 @@ public sealed class AppSettingsTests Assert.DoesNotContain(nameof(AppSettings.Duration), names); Assert.DoesNotContain(nameof(AppSettings.CapsLockHoldDelay), names); + Assert.DoesNotContain(nameof(AppSettings.Current), names); // What they are derived from, on the other hand, has to be stored Assert.Contains(nameof(AppSettings.DurationMilliseconds), names); Assert.Contains(nameof(AppSettings.CapsLockHoldMilliseconds), names); + Assert.Contains(nameof(AppSettings.AtCursor), names); + Assert.Contains(nameof(AppSettings.AtCaret), names); + Assert.Contains(nameof(AppSettings.FixedPoint), names); } [Fact] @@ -118,6 +271,11 @@ public sealed class AppSettingsTests } /// Names of the settings the user is able to change. + /// + /// The modes are handed out rather than assigned — they are filled in place — so + /// they are not among these. What is inside them is checked by + /// . + /// internal static IEnumerable WritablePropertyNames() => typeof(AppSettings).GetProperties() .Where(property => property.CanWrite) diff --git a/CursorLang.Core.Tests/Models/PopupModeSettingsTests.cs b/CursorLang.Core.Tests/Models/PopupModeSettingsTests.cs new file mode 100644 index 0000000..044fcb9 --- /dev/null +++ b/CursorLang.Core.Tests/Models/PopupModeSettingsTests.cs @@ -0,0 +1,289 @@ +using System.ComponentModel; +using System.Drawing; +using System.Reflection; +using System.Text.Json; +using CursorLang.Core.Models; + +namespace CursorLang.Core.Tests.Models; + +/// +/// The settings of a single placement mode: what the popup looks like there and how +/// far from its anchor it sits. +/// +public sealed class PopupModeSettingsTests +{ + [Fact] + public void The_cursor_mode_starts_out_below_and_right_of_the_pointer() + { + var mode = new CursorModeSettings(); + + Assert.Equal(AnchorSide.BottomRight, mode.Side); + Assert.Equal(16, mode.Offset); + } + + /// + /// Next to the caret the popup goes beside it, and to the right by default. + /// + /// + /// Above or below the caret is where the next line of the text is, so those sides + /// are not on offer at all — the type has the two of them and no more. + /// + [Fact] + public void The_caret_mode_starts_out_to_the_right_of_the_caret() + { + var mode = new CaretModeSettings(); + + Assert.Equal(CaretSide.Right, mode.Side); + Assert.Equal(16, mode.Offset); + Assert.Equal([CaretSide.Left, CaretSide.Right], Enum.GetValues()); + } + + [Theory] + [InlineData(CaretSide.Left, AnchorSide.Left)] + [InlineData(CaretSide.Right, AnchorSide.Right)] + public void The_side_of_the_caret_lines_the_popup_up_with_it(CaretSide side, AnchorSide expected) + { + var mode = new CaretModeSettings { Side = side }; + + Assert.Equal(expected, mode.Anchor); + } + + [Fact] + public void A_changed_side_of_the_caret_is_a_change_of_what_it_lines_up_with() + { + var mode = new CaretModeSettings(); + List changed = []; + mode.PropertyChanged += (_, e) => changed.Add(e.PropertyName); + + mode.Side = CaretSide.Left; + + Assert.Contains(nameof(CaretModeSettings.Anchor), changed); + } + + /// + /// The fixed point starts out in the middle of the monitor, where there is no edge + /// to stand off from. + /// + [Fact] + public void The_fixed_point_starts_out_in_the_middle_with_no_offset() + { + var mode = new FixedPointModeSettings(); + + Assert.Equal(ScreenPosition.Center, mode.Position); + Assert.Equal(0, mode.Offset); + Assert.False(mode.IsAtAnEdge); + } + + // A setting that is not shown must not be one that still applies + [Fact] + public void Moving_the_fixed_point_to_the_middle_drops_the_offset() + { + var mode = new FixedPointModeSettings { Position = ScreenPosition.TopLeft, Offset = 40 }; + + Assert.True(mode.IsAtAnEdge); + + mode.Position = ScreenPosition.Center; + + Assert.Equal(0, mode.Offset); + Assert.False(mode.IsAtAnEdge); + } + + [Theory] + [InlineData(ScreenPosition.TopLeft)] + [InlineData(ScreenPosition.Top)] + [InlineData(ScreenPosition.TopRight)] + [InlineData(ScreenPosition.BottomLeft)] + [InlineData(ScreenPosition.Bottom)] + [InlineData(ScreenPosition.BottomRight)] + public void Away_from_the_middle_the_offset_is_kept(ScreenPosition position) + { + var mode = new FixedPointModeSettings { Position = position, Offset = 40 }; + + Assert.Equal(40, mode.Offset); + Assert.True(mode.IsAtAnEdge); + } + + [Fact] + public void A_changed_place_of_the_fixed_point_is_a_change_of_having_an_edge() + { + var mode = new FixedPointModeSettings(); + List changed = []; + mode.PropertyChanged += (_, e) => changed.Add(e.PropertyName); + + mode.Position = ScreenPosition.Bottom; + + Assert.Contains(nameof(FixedPointModeSettings.IsAtAnEdge), changed); + } + + [Theory] + [MemberData(nameof(WritableProperties), typeof(CursorModeSettings))] + [MemberData(nameof(WritableProperties), typeof(CaretModeSettings))] + [MemberData(nameof(WritableProperties), typeof(FixedPointModeSettings))] + public void A_changed_setting_is_announced_to_subscribers(Type type, string propertyName) + { + PopupModeSettings mode = Create(type); + List changed = []; + mode.PropertyChanged += (_, e) => changed.Add(e.PropertyName); + + SetDifferentValue(mode, propertyName); + + Assert.Contains(propertyName, changed); + } + + [Theory] + [MemberData(nameof(WritableProperties), typeof(CursorModeSettings))] + [MemberData(nameof(WritableProperties), typeof(CaretModeSettings))] + [MemberData(nameof(WritableProperties), typeof(FixedPointModeSettings))] + public void Writing_the_same_value_leaves_subscribers_alone(Type type, string propertyName) + { + PopupModeSettings mode = Create(type); + PropertyInfo property = type.GetProperty(propertyName)!; + + List changed = []; + mode.PropertyChanged += (_, e) => changed.Add(e.PropertyName); + + property.SetValue(mode, property.GetValue(mode)); + + Assert.Empty(changed); + } + + [Fact] + public void The_cursor_mode_takes_another_one_over_whole() + { + var mode = new CursorModeSettings(); + var other = new CursorModeSettings + { + Side = AnchorSide.Left, + Offset = 3, + FontSize = 41, + Opacity = 0.25, + BackgroundColor = Color.FromArgb(0x01, 0x02, 0x03), + ForegroundColor = Color.FromArgb(0x04, 0x05, 0x06), + }; + + mode.CopyFrom(other); + + Assert.Equal(AnchorSide.Left, mode.Side); + Assert.Equal(3, mode.Offset); + Assert.Equal(41, mode.FontSize); + Assert.Equal(0.25, mode.Opacity); + Assert.Equal(Color.FromArgb(0x01, 0x02, 0x03), mode.BackgroundColor); + Assert.Equal(Color.FromArgb(0x04, 0x05, 0x06), mode.ForegroundColor); + } + + [Fact] + public void The_caret_mode_takes_another_one_over_whole() + { + var mode = new CaretModeSettings(); + var other = new CaretModeSettings { Side = CaretSide.Left, Offset = 2, FontSize = 15 }; + + mode.CopyFrom(other); + + Assert.Equal(CaretSide.Left, mode.Side); + Assert.Equal(2, mode.Offset); + Assert.Equal(15, mode.FontSize); + } + + [Fact] + public void The_fixed_point_takes_another_one_over_whole() + { + var mode = new FixedPointModeSettings(); + var other = new FixedPointModeSettings + { + Position = ScreenPosition.Bottom, + Offset = 120, + FontSize = 60, + Opacity = 0.5, + BackgroundColor = Color.FromArgb(0x07, 0x08, 0x09), + ForegroundColor = Color.FromArgb(0x0A, 0x0B, 0x0C), + }; + + mode.CopyFrom(other); + + Assert.Equal(ScreenPosition.Bottom, mode.Position); + Assert.Equal(120, mode.Offset); + Assert.Equal(60, mode.FontSize); + Assert.Equal(0.5, mode.Opacity); + Assert.Equal(Color.FromArgb(0x07, 0x08, 0x09), mode.BackgroundColor); + Assert.Equal(Color.FromArgb(0x0A, 0x0B, 0x0C), mode.ForegroundColor); + } + + // Taking over a mode that sits in the middle takes its lack of an offset over too + [Fact] + public void Taking_over_a_fixed_point_in_the_middle_leaves_no_offset() + { + var mode = new FixedPointModeSettings { Position = ScreenPosition.Top, Offset = 32 }; + + mode.CopyFrom(new FixedPointModeSettings()); + + Assert.Equal(ScreenPosition.Center, mode.Position); + Assert.Equal(0, mode.Offset); + } + + // What a mode works out from what it stores is not stored itself + [Fact] + public void Derived_values_of_a_mode_stay_out_of_the_file() + { + List caret = Stored(new CaretModeSettings()); + List fixedPoint = Stored(new FixedPointModeSettings()); + + Assert.DoesNotContain(nameof(CaretModeSettings.Anchor), caret); + Assert.DoesNotContain(nameof(FixedPointModeSettings.IsAtAnEdge), fixedPoint); + + Assert.Contains(nameof(CaretModeSettings.Side), caret); + Assert.Contains(nameof(FixedPointModeSettings.Position), fixedPoint); + } + + [Fact] + public void A_mode_reports_changes_as_INotifyPropertyChanged() + { + Assert.IsAssignableFrom(new CursorModeSettings()); + Assert.IsAssignableFrom(new CaretModeSettings()); + Assert.IsAssignableFrom(new FixedPointModeSettings()); + } + + public static TheoryData WritableProperties(Type type) + { + var data = new TheoryData(); + + foreach (string name in WritablePropertyNames(type)) + { + data.Add(type, name); + } + + return data; + } + + /// Names of the settings of a mode the user is able to change. + internal static IEnumerable WritablePropertyNames(Type type) => + type.GetProperties().Where(property => property.CanWrite).Select(property => property.Name); + + private static PopupModeSettings Create(Type type) => (PopupModeSettings)Activator.CreateInstance(type)!; + + private static List Stored(TMode mode) where TMode : PopupModeSettings + { + using JsonDocument document = JsonSerializer.SerializeToDocument(mode); + + return [.. document.RootElement.EnumerateObject().Select(property => property.Name)]; + } + + // A value guaranteed to differ from the current one: each kind of setting has its + // own way of differing + private static void SetDifferentValue(PopupModeSettings mode, string propertyName) + { + PropertyInfo property = mode.GetType().GetProperty(propertyName)!; + + object next = property.GetValue(mode) switch + { + double number => number + 1, + Color color => Color.FromArgb((byte)(color.R + 1), color.G, color.B), + Enum value => Enum.GetValues(value.GetType()) + .Cast() + .First(other => !Equals(other, value)), + + var other => throw new NotSupportedException($"Unknown kind of setting: {other?.GetType()}"), + }; + + property.SetValue(mode, next); + } +} diff --git a/CursorLang.Core.Tests/Resources/StringsTests.cs b/CursorLang.Core.Tests/Resources/StringsTests.cs index 0fe2475..6acca5a 100644 --- a/CursorLang.Core.Tests/Resources/StringsTests.cs +++ b/CursorLang.Core.Tests/Resources/StringsTests.cs @@ -108,6 +108,11 @@ public sealed class StringsTests data.Add(key); } + foreach (string key in EnumKeysOf()) + { + data.Add(key); + } + foreach (string key in EnumKeysOf()) { data.Add(key); diff --git a/CursorLang.Core.Tests/Services/CapsLockSwitchCoordinatorTests.cs b/CursorLang.Core.Tests/Services/CapsLockSwitchCoordinatorTests.cs index 9961671..c4f64a8 100644 --- a/CursorLang.Core.Tests/Services/CapsLockSwitchCoordinatorTests.cs +++ b/CursorLang.Core.Tests/Services/CapsLockSwitchCoordinatorTests.cs @@ -71,7 +71,7 @@ public sealed class CapsLockSwitchCoordinatorTests int startsBefore = hotkey.StartCalls; int stopsBefore = hotkey.StopCalls; - settings.FontSize = 44; + settings.Current.FontSize = 44; settings.CapsLockHoldMilliseconds = 700; Assert.Equal(startsBefore, hotkey.StartCalls); diff --git a/CursorLang.Core.Tests/Services/SettingsServiceTests.cs b/CursorLang.Core.Tests/Services/SettingsServiceTests.cs index 5b80847..373f12c 100644 --- a/CursorLang.Core.Tests/Services/SettingsServiceTests.cs +++ b/CursorLang.Core.Tests/Services/SettingsServiceTests.cs @@ -30,7 +30,7 @@ public sealed class SettingsServiceTests }); Assert.Equal(AppTheme.System, settings.Theme); - Assert.Equal(20, settings.FontSize); + Assert.Equal(20, settings.Current.FontSize); } [Theory] @@ -72,10 +72,10 @@ public sealed class SettingsServiceTests using SettingsService service = Create(folder); AppSettings settings = service.Load(); - settings.FontSize = 42; settings.Theme = AppTheme.Dark; settings.PlacementMode = PopupPlacementMode.AtCaret; - settings.BackgroundColor = Color.FromArgb(0x11, 0x22, 0x33); + settings.Current.FontSize = 42; + settings.Current.BackgroundColor = Color.FromArgb(0x11, 0x22, 0x33); settings.UseCapsLockHotkey = true; service.Save(); @@ -87,10 +87,10 @@ public sealed class SettingsServiceTests return service.Load(); }); - Assert.Equal(42, restored.FontSize); + Assert.Equal(42, restored.Current.FontSize); Assert.Equal(AppTheme.Dark, restored.Theme); Assert.Equal(PopupPlacementMode.AtCaret, restored.PlacementMode); - Assert.Equal(Color.FromArgb(0x11, 0x22, 0x33), restored.BackgroundColor); + Assert.Equal(Color.FromArgb(0x11, 0x22, 0x33), restored.Current.BackgroundColor); Assert.True(restored.UseCapsLockHotkey); } @@ -104,7 +104,7 @@ public sealed class SettingsServiceTests using SettingsService service = Create(folder); AppSettings settings = service.Load(); settings.Theme = AppTheme.Dark; - settings.BackgroundColor = Color.FromArgb(0x20, 0x20, 0x20); + settings.Current.BackgroundColor = Color.FromArgb(0x20, 0x20, 0x20); service.Save(); }); @@ -130,7 +130,7 @@ public sealed class SettingsServiceTests AppSettings settings = service.Load(); service.TrackChanges(); - settings.FontSize = 33; + settings.Current.FontSize = 33; // Right after the edit there is nothing on disk yet: the write is deferred Assert.False(File.Exists(path)); @@ -156,7 +156,7 @@ public sealed class SettingsServiceTests for (int i = 0; i < 10; i++) { - settings.Opacity = 0.5 + (i * 0.01); + settings.Current.Opacity = 0.5 + (i * 0.01); Assert.False(File.Exists(path)); Pump.Pause(TimeSpan.FromMilliseconds(10)); } @@ -188,7 +188,7 @@ public sealed class SettingsServiceTests service.TrackChanges(); AppSettings settings = service.Load(); - settings.FontSize = 29; + settings.Current.FontSize = 29; Pump.WaitFor(() => File.Exists(path), "the settings were written by the timer"); }); @@ -230,11 +230,11 @@ public sealed class SettingsServiceTests using SettingsService service = Create(folder); AppSettings settings = service.Load(); - File.WriteAllText(path, """{"FontSize": 31, "BackgroundColor": "#FF102030"}"""); + File.WriteAllText(path, """{"AtCursor": {"FontSize": 31, "BackgroundColor": "#FF102030"}}"""); service.Reload(); - Assert.Equal(31, settings.FontSize); - Assert.Equal(Color.FromArgb(0x10, 0x20, 0x30), settings.BackgroundColor); + Assert.Equal(31, settings.Current.FontSize); + Assert.Equal(Color.FromArgb(0x10, 0x20, 0x30), settings.Current.BackgroundColor); }); } @@ -250,12 +250,12 @@ public sealed class SettingsServiceTests { using SettingsService service = Create(folder); AppSettings settings = service.Load(); - settings.FontSize = 44; + settings.Current.FontSize = 44; File.WriteAllText(path, "not json at all"); service.Reload(); - Assert.Equal(44, settings.FontSize); + Assert.Equal(44, settings.Current.FontSize); }); } @@ -269,7 +269,7 @@ public sealed class SettingsServiceTests SettingsService service = Create(folder); AppSettings settings = service.Load(); service.TrackChanges(); - settings.FontSize = 27; + settings.Current.FontSize = 27; service.Dispose(); }); @@ -295,7 +295,7 @@ public sealed class SettingsServiceTests string afterDispose = File.ReadAllText(path); - settings.FontSize = 99; + settings.Current.FontSize = 99; Pump.Pause(TimeSpan.FromMilliseconds(60)); Assert.Equal(afterDispose, File.ReadAllText(path)); @@ -309,7 +309,7 @@ public sealed class SettingsServiceTests string inherited = folder.File("inherited.json"); string own = folder.File("settings.json"); - File.WriteAllText(inherited, """{"FontSize": 31, "Language": "ru"}"""); + File.WriteAllText(inherited, """{"AtCursor": {"FontSize": 31}, "Language": "ru"}"""); AppSettings settings = Pump.Run(() => { @@ -317,7 +317,7 @@ public sealed class SettingsServiceTests return service.Load(); }); - Assert.Equal(31, settings.FontSize); + Assert.Equal(31, settings.Current.FontSize); Assert.Equal("ru", settings.Language); // What was taken over is pinned to its new place at once rather than on the first edit @@ -331,7 +331,7 @@ public sealed class SettingsServiceTests { using var folder = new TempFolder(); string inherited = folder.File("inherited.json"); - string original = """{"FontSize": 31}"""; + string original = """{"AtCursor": {"FontSize": 31}}"""; File.WriteAllText(inherited, original); @@ -351,8 +351,8 @@ public sealed class SettingsServiceTests string own = folder.File("settings.json"); string inherited = folder.File("inherited.json"); - File.WriteAllText(own, """{"FontSize": 12}"""); - File.WriteAllText(inherited, """{"FontSize": 31}"""); + File.WriteAllText(own, """{"AtCursor": {"FontSize": 12}}"""); + File.WriteAllText(inherited, """{"AtCursor": {"FontSize": 31}}"""); AppSettings settings = Pump.Run(() => { @@ -360,7 +360,7 @@ public sealed class SettingsServiceTests return service.Load(); }); - Assert.Equal(12, settings.FontSize); + Assert.Equal(12, settings.Current.FontSize); } // Outside a package both paths are the same, so there is nothing to take over @@ -392,14 +392,14 @@ public sealed class SettingsServiceTests return service.Load(); }); - Assert.Equal(20, settings.FontSize); + Assert.Equal(20, settings.Current.FontSize); } [Fact] public void Settings_with_unknown_fields_are_still_read() { using var folder = new TempFolder(); - File.WriteAllText(folder.File("settings.json"), """{"FontSize": 15, "SomethingNew": true}"""); + File.WriteAllText(folder.File("settings.json"), """{"AtCursor": {"FontSize": 15}, "SomethingNew": true}"""); AppSettings settings = Pump.Run(() => { @@ -407,7 +407,7 @@ public sealed class SettingsServiceTests return service.Load(); }); - Assert.Equal(15, settings.FontSize); + Assert.Equal(15, settings.Current.FontSize); } [Theory] @@ -417,7 +417,7 @@ public sealed class SettingsServiceTests public void A_colour_is_read_from_its_usual_notation(string stored, byte r, byte g, byte b) { using var folder = new TempFolder(); - File.WriteAllText(folder.File("settings.json"), $$"""{"BackgroundColor": {{stored}}}"""); + File.WriteAllText(folder.File("settings.json"), $$"""{"AtCursor": {"BackgroundColor": {{stored}} } }"""); AppSettings settings = Pump.Run(() => { @@ -425,7 +425,7 @@ public sealed class SettingsServiceTests return service.Load(); }); - Assert.Equal(Color.FromArgb(r, g, b), settings.BackgroundColor); + Assert.Equal(Color.FromArgb(r, g, b), settings.Current.BackgroundColor); } [Theory] @@ -435,7 +435,7 @@ public sealed class SettingsServiceTests public void An_unintelligible_colour_becomes_black(string stored) { using var folder = new TempFolder(); - File.WriteAllText(folder.File("settings.json"), $$"""{"BackgroundColor": {{stored}}}"""); + File.WriteAllText(folder.File("settings.json"), $$"""{"AtCursor": {"BackgroundColor": {{stored}} } }"""); AppSettings settings = Pump.Run(() => { @@ -443,7 +443,7 @@ public sealed class SettingsServiceTests return service.Load(); }); - Assert.Equal(Color.Black, settings.BackgroundColor); + Assert.Equal(Color.Black, settings.Current.BackgroundColor); } // The service creates the settings folder itself @@ -478,7 +478,7 @@ public sealed class SettingsServiceTests using SettingsService service = new(path, folder.File("inherited.json"), SaveDelay); AppSettings settings = service.Load(); - settings.FontSize = 18; + settings.Current.FontSize = 18; service.Save(); }); @@ -534,6 +534,107 @@ public sealed class SettingsServiceTests } } + // The modes are sections of their own, and every setting of a mode has to reach the + // file inside its own section + [Theory] + [InlineData(nameof(AppSettings.AtCursor), typeof(CursorModeSettings))] + [InlineData(nameof(AppSettings.AtCaret), typeof(CaretModeSettings))] + [InlineData(nameof(AppSettings.FixedPoint), typeof(FixedPointModeSettings))] + public void Every_setting_of_a_mode_reaches_its_section_of_the_file(string section, Type mode) + { + using var folder = new TempFolder(); + + Pump.Run(() => + { + using SettingsService service = Create(folder); + _ = service.Load(); + service.Save(); + }); + + using JsonDocument document = JsonDocument.Parse(File.ReadAllText(folder.File("settings.json"))); + + Assert.True(document.RootElement.TryGetProperty(section, out JsonElement stored)); + + foreach (string name in PopupModeSettingsTests.WritablePropertyNames(mode)) + { + Assert.True(stored.TryGetProperty(name, out _), $"{section}.{name}"); + } + } + + /// + /// The modes are stored apart: what is set up in one is still there after a trip + /// through the file and the other two. + /// + [Fact] + public void Each_mode_keeps_its_own_settings() + { + using var folder = new TempFolder(); + + Pump.Run(() => + { + using SettingsService service = Create(folder); + AppSettings settings = service.Load(); + + settings.AtCursor.Side = AnchorSide.TopLeft; + settings.AtCursor.FontSize = 14; + settings.AtCaret.Side = CaretSide.Left; + settings.AtCaret.FontSize = 28; + settings.AtCaret.ForegroundColor = Color.FromArgb(0x0A, 0x0B, 0x0C); + settings.FixedPoint.Position = ScreenPosition.Top; + settings.FixedPoint.Offset = 96; + settings.FixedPoint.Opacity = 0.4; + + service.Save(); + }); + + AppSettings restored = Pump.Run(() => + { + using SettingsService service = Create(folder); + return service.Load(); + }); + + Assert.Equal(AnchorSide.TopLeft, restored.AtCursor.Side); + Assert.Equal(14, restored.AtCursor.FontSize); + Assert.Equal(CaretSide.Left, restored.AtCaret.Side); + Assert.Equal(28, restored.AtCaret.FontSize); + Assert.Equal(Color.FromArgb(0x0A, 0x0B, 0x0C), restored.AtCaret.ForegroundColor); + Assert.Equal(ScreenPosition.Top, restored.FixedPoint.Position); + Assert.Equal(96, restored.FixedPoint.Offset); + Assert.Equal(0.4, restored.FixedPoint.Opacity); + + // What was left alone stays at its default rather than following a neighbour + Assert.Equal(0.9, restored.AtCursor.Opacity); + } + + // A change inside a mode is a change of the settings: the file has to follow the + // sliders of the look as well as the ones above them + [Fact] + public void A_change_inside_a_mode_saves_itself() + { + using var folder = new TempFolder(); + string path = folder.File("settings.json"); + + Pump.Run(() => + { + using SettingsService service = Create(folder); + AppSettings settings = service.Load(); + service.TrackChanges(); + + settings.AtCaret.FontSize = 37; + + Pump.WaitFor(() => File.Exists(path), "the settings were written by the timer"); + }); + + using JsonDocument document = JsonDocument.Parse(File.ReadAllText(path)); + + Assert.Equal( + 37, + document.RootElement + .GetProperty(nameof(AppSettings.AtCaret)) + .GetProperty(nameof(PopupModeSettings.FontSize)) + .GetDouble()); + } + // An ordinary run picks the storage place itself: a package keeps settings // of its own, a separate install keeps them in the user profile [Fact] diff --git a/CursorLang.Core/Models/AppSettings.cs b/CursorLang.Core/Models/AppSettings.cs index 64fb55d..0d3c462 100644 --- a/CursorLang.Core/Models/AppSettings.cs +++ b/CursorLang.Core/Models/AppSettings.cs @@ -1,4 +1,4 @@ -using System.Drawing; +using System.ComponentModel; using System.Text.Json.Serialization; using CommunityToolkit.Mvvm.ComponentModel; @@ -8,11 +8,10 @@ namespace CursorLang.Core.Models; /// The application settings, as the settings window writes them to settings.json. /// /// -/// The colours are rather than -/// System.Windows.Media.Color. The former lives in System.Drawing.Primitives, -/// which is part of the base runtime and brings neither WPF nor GDI+ along; the latter -/// is WindowsBase, and this type is read by the agent, which must stay clear of it. -/// The settings window turns them into brushes in its converters. +/// Everything about the popup — the side, the offset, the font size, the opacity and +/// both colours — belongs to a placement mode rather than to the application, and is +/// kept in a of its own for each of them. So the +/// settings of a mode are still there after a trip through the other two. /// /// Both processes hold an instance of this, but only the settings window writes: the /// agent re-reads the file and pours the fresh values into the instance it already has, @@ -32,43 +31,6 @@ public sealed partial class AppSettings : ObservableObject [ObservableProperty] private PopupPlacementMode _placementMode = PopupPlacementMode.AtCursor; - // The side and the offset are stored per mode: the cursor and the caret call for - // different settings, and switching the mode does not reset them - - /// The side of the cursor the popup is put on. - [ObservableProperty] - private AnchorSide _cursorSide = AnchorSide.BottomRight; - - /// The offset from the cursor in WPF units. - [ObservableProperty] - private double _cursorOffset = 16; - - /// The side of the caret the popup is put on. - [ObservableProperty] - private AnchorSide _caretSide = AnchorSide.BottomRight; - - /// The offset from the caret in WPF units. - [ObservableProperty] - private double _caretOffset = 16; - - /// - /// The place on the monitor for the mode. - /// - [ObservableProperty] - private ScreenPosition _screenPosition = ScreenPosition.BottomRight; - - /// The offset from the monitor edge in WPF units. - [ObservableProperty] - private double _screenMargin = 24; - - /// The size of the layout name in the popup, in WPF units. - [ObservableProperty] - private double _fontSize = 20; - - /// The popup opacity: 1.0 is fully opaque. - [ObservableProperty] - private double _opacity = 0.9; - /// How long the popup stays on screen, in milliseconds. [ObservableProperty] private double _durationMilliseconds = 500; @@ -87,13 +49,44 @@ public sealed partial class AppSettings : ObservableObject [ObservableProperty] private double _capsLockHoldMilliseconds = 300; - /// The fill of the popup. The opacity is set by . - [ObservableProperty] - private Color _backgroundColor = Color.FromArgb(0xFF, 0x20, 0x20, 0x20); + public AppSettings() + { + AtCursor.PropertyChanged += OnModeChanged; + AtCaret.PropertyChanged += OnModeChanged; + FixedPoint.PropertyChanged += OnModeChanged; + } - /// The colour of the layout name in the popup. - [ObservableProperty] - private Color _foregroundColor = Color.FromArgb(0xFF, 0xFF, 0xFF, 0xFF); + /// The mode. + /// + /// The modes are handed out rather than replaced — the instance is the same for the + /// life of the settings, so a binding and a subscription to it hold. That is what + /// the creation handling is for: without it the deserializer would want a setter, + /// and reading the file would swap the object everything is bound to. + /// + [JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)] + public CursorModeSettings AtCursor { get; } = new(); + + /// The mode. + [JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)] + public CaretModeSettings AtCaret { get; } = new(); + + /// The mode. + [JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)] + public FixedPointModeSettings FixedPoint { get; } = new(); + + /// The settings of the mode currently chosen. + /// + /// The settings window binds the look through here, and the popup asks for it here + /// too: what is on screen is always the settings of the mode in force. A change of + /// announces this as changed, so the bindings follow. + /// + [JsonIgnore] + public PopupModeSettings Current => PlacementMode switch + { + PopupPlacementMode.AtCaret => AtCaret, + PopupPlacementMode.FixedPoint => FixedPoint, + _ => AtCursor, + }; /// as a . [JsonIgnore] @@ -110,25 +103,43 @@ public sealed partial class AppSettings : ObservableObject /// /// This is how the agent learns about an edit: the settings window is a separate /// process, so the fresh values arrive as a freshly parsed instance and are poured - /// into the one everything is already bound to, rather than replacing it. + /// into the one everything is already bound to, rather than replacing it. The modes + /// are filled the same way, and for the same reason. /// public void CopyFrom(AppSettings other) { Language = other.Language; Theme = other.Theme; PlacementMode = other.PlacementMode; - CursorSide = other.CursorSide; - CursorOffset = other.CursorOffset; - CaretSide = other.CaretSide; - CaretOffset = other.CaretOffset; - ScreenPosition = other.ScreenPosition; - ScreenMargin = other.ScreenMargin; - FontSize = other.FontSize; - Opacity = other.Opacity; DurationMilliseconds = other.DurationMilliseconds; UseCapsLockHotkey = other.UseCapsLockHotkey; CapsLockHoldMilliseconds = other.CapsLockHoldMilliseconds; - BackgroundColor = other.BackgroundColor; - ForegroundColor = other.ForegroundColor; + + AtCursor.CopyFrom(other.AtCursor); + AtCaret.CopyFrom(other.AtCaret); + FixedPoint.CopyFrom(other.FixedPoint); } + + // The mode in force decides what the look means, so a switch of the mode is a + // change of everything bound through Current + partial void OnPlacementModeChanged(PopupPlacementMode value) => OnPropertyChanged(nameof(Current)); + + /// + /// Passes a change made inside a mode on as a change of the settings. + /// + /// + /// A mode is an object of its own, so an edit in it is not an edit of this one as + /// far as goes. The settings window binds + /// through the path and hears the mode itself, but the service that writes the file + /// listens to the settings alone — and without this a font size dragged in the + /// window would never reach the disk. The name says where the change happened: + /// "AtCaret.FontSize". + /// + private void OnModeChanged(object? sender, PropertyChangedEventArgs e) => + OnPropertyChanged($"{NameOfMode(sender)}.{e.PropertyName}"); + + private string NameOfMode(object? mode) => + ReferenceEquals(mode, AtCaret) ? nameof(AtCaret) + : ReferenceEquals(mode, FixedPoint) ? nameof(FixedPoint) + : nameof(AtCursor); } diff --git a/CursorLang.Core/Models/PopupModeSettings.cs b/CursorLang.Core/Models/PopupModeSettings.cs new file mode 100644 index 0000000..6953cfa --- /dev/null +++ b/CursorLang.Core/Models/PopupModeSettings.cs @@ -0,0 +1,141 @@ +using System.Drawing; +using System.Text.Json.Serialization; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace CursorLang.Core.Models; + +/// +/// The settings of one placement mode: where the popup goes and how it looks there. +/// +/// +/// Every mode keeps a set of its own. The popup next to the caret sits inside a text +/// being read and is wanted small and quiet; the one in the corner of the monitor is +/// looked for on purpose and is wanted large. A look shared by the modes meant setting +/// it up again after every switch — and switching modes to see what they do undid what +/// had just been set up. +/// +/// The colours are for the same reason as in +/// : the agent reads them and must stay clear of WPF. +/// +public abstract partial class PopupModeSettings : ObservableObject +{ + /// The distance from whatever the popup is placed by, in WPF units. + [ObservableProperty] + private double _offset = 16; + + /// The size of the layout name in the popup, in WPF units. + [ObservableProperty] + private double _fontSize = 20; + + /// The popup opacity: 1.0 is fully opaque. + [ObservableProperty] + private double _opacity = 0.9; + + /// The fill of the popup. The opacity is set by . + [ObservableProperty] + private Color _backgroundColor = Color.FromArgb(0xFF, 0x20, 0x20, 0x20); + + /// The colour of the layout name in the popup. + [ObservableProperty] + private Color _foregroundColor = Color.FromArgb(0xFF, 0xFF, 0xFF, 0xFF); + + /// + /// Takes the offset and the look of another mode over. What tells the modes apart — + /// the side, the place on the monitor — is copied by the mode itself. + /// + protected void CopyLookFrom(PopupModeSettings other) + { + Offset = other.Offset; + FontSize = other.FontSize; + Opacity = other.Opacity; + BackgroundColor = other.BackgroundColor; + ForegroundColor = other.ForegroundColor; + } +} + +/// The mode that puts the popup next to the mouse cursor. +public sealed partial class CursorModeSettings : PopupModeSettings +{ + /// The corner or the side of the cursor the popup is put on. + [ObservableProperty] + private AnchorSide _side = AnchorSide.BottomRight; + + /// Takes the values of another mode of the same kind over. + public void CopyFrom(CursorModeSettings other) + { + CopyLookFrom(other); + Side = other.Side; + } +} + +/// +/// The mode that puts the popup next to the caret of the active input field. +/// +public sealed partial class CaretModeSettings : PopupModeSettings +{ + /// The side of the caret the popup is put on: only left or right. + [ObservableProperty] + private CaretSide _side = CaretSide.Right; + + /// + /// The side as the layout arithmetic wants it. Both of them line the popup up with + /// the caret rather than putting it above or below. + /// + [JsonIgnore] + public AnchorSide Anchor => Side == CaretSide.Left ? AnchorSide.Left : AnchorSide.Right; + + /// Takes the values of another mode of the same kind over. + public void CopyFrom(CaretModeSettings other) + { + CopyLookFrom(other); + Side = other.Side; + } + + // The side decides what the popup is lined up against, so it is a change of that too + partial void OnSideChanged(CaretSide value) => OnPropertyChanged(nameof(Anchor)); +} + +/// +/// The mode that puts the popup at a fixed place of the monitor holding the active +/// window. Here is the distance from the edge +/// of the monitor. +/// +/// +/// In the middle of the monitor there is no edge to keep a distance from, so the offset +/// is zero there and the settings window does not offer it. Choosing the middle puts it +/// back to zero rather than remembering it for later: a setting that is not shown must +/// not be one that still applies. +/// +public sealed partial class FixedPointModeSettings : PopupModeSettings +{ + /// The place on the monitor the popup is put at. + [ObservableProperty] + private ScreenPosition _position = ScreenPosition.Center; + + public FixedPointModeSettings() + { + // The middle is the default, and it has no edge to stand off from + Offset = 0; + } + + /// Whether the popup is put at an edge of the monitor rather than in its middle. + [JsonIgnore] + public bool IsAtAnEdge => Position != ScreenPosition.Center; + + /// Takes the values of another mode of the same kind over. + public void CopyFrom(FixedPointModeSettings other) + { + CopyLookFrom(other); + Position = other.Position; + } + + partial void OnPositionChanged(ScreenPosition value) + { + OnPropertyChanged(nameof(IsAtAnEdge)); + + if (value == ScreenPosition.Center) + { + Offset = 0; + } + } +} diff --git a/CursorLang.Core/Models/PopupPlacement.cs b/CursorLang.Core/Models/PopupPlacement.cs index 179082f..d58a961 100644 --- a/CursorLang.Core/Models/PopupPlacement.cs +++ b/CursorLang.Core/Models/PopupPlacement.cs @@ -19,7 +19,7 @@ public enum PopupPlacementMode } /// -/// Which side of the cursor or the caret to show the popup on. +/// Which side of the cursor to show the popup on. /// public enum AnchorSide { @@ -31,6 +31,21 @@ public enum AnchorSide BottomRight, } +/// +/// Which side of the caret to show the popup on. +/// +/// +/// Only the two sides, unlike . The caret stands in a line of +/// text being written, and above or below it is exactly where the next line is: the +/// popup would cover what is being read. To the side it covers nothing, and the line +/// it lines up with is the one the caret is in. +/// +public enum CaretSide +{ + Left, + Right, +} + /// /// The place on the monitor for the mode. /// diff --git a/CursorLang.Core/Resources/Strings.resx b/CursorLang.Core/Resources/Strings.resx index c2a22be..1e72d35 100644 --- a/CursorLang.Core/Resources/Strings.resx +++ b/CursorLang.Core/Resources/Strings.resx @@ -121,6 +121,12 @@ Right + + Left + + + Right + Offset @@ -151,6 +157,9 @@ Margin from screen edge + + The look below is remembered for the chosen placement mode alone. + Font size diff --git a/CursorLang.Core/Resources/Strings.ru.resx b/CursorLang.Core/Resources/Strings.ru.resx index 551da8f..76064c8 100644 --- a/CursorLang.Core/Resources/Strings.ru.resx +++ b/CursorLang.Core/Resources/Strings.ru.resx @@ -121,6 +121,12 @@ Справа + + Слева + + + Справа + Отступ @@ -151,6 +157,9 @@ Отступ от края экрана + + Оформление ниже запоминается только для выбранного режима. + Размер шрифта diff --git a/CursorLang.Settings.Tests/Services/ThemeServiceTests.cs b/CursorLang.Settings.Tests/Services/ThemeServiceTests.cs index a624a34..cd54417 100644 --- a/CursorLang.Settings.Tests/Services/ThemeServiceTests.cs +++ b/CursorLang.Settings.Tests/Services/ThemeServiceTests.cs @@ -114,7 +114,7 @@ public sealed class ThemeServiceTests { using var service = new ThemeService(settings, static () => AppTheme.Dark); - settings.FontSize = 40; + settings.Current.FontSize = 40; settings.Language = "ru"; Assert.Equal(AppTheme.Light, service.CurrentTheme); diff --git a/CursorLang.Settings.Tests/ViewModels/SettingsViewModelTests.cs b/CursorLang.Settings.Tests/ViewModels/SettingsViewModelTests.cs index 5731177..fb0dafe 100644 --- a/CursorLang.Settings.Tests/ViewModels/SettingsViewModelTests.cs +++ b/CursorLang.Settings.Tests/ViewModels/SettingsViewModelTests.cs @@ -43,7 +43,7 @@ public sealed class SettingsViewModelTests var localization = new FakeLocalizationService(); using var viewModel = Create(settings, localization); - settings.FontSize = 30; + settings.Current.FontSize = 30; Assert.Equal("en", localization.CurrentLanguage); } @@ -59,6 +59,18 @@ public sealed class SettingsViewModelTests Assert.Equal(Enum.GetValues(), viewModel.ScreenPositions.Select(option => option.Value)); } + // Next to the caret the popup only goes beside it: above or below is where the next + // line of the text is + [Fact] + public void The_caret_is_offered_two_sides_and_no_more() + { + using SettingsViewModel viewModel = Create(); + + Assert.Equal( + [CaretSide.Left, CaretSide.Right], + viewModel.CaretSides.Select(option => option.Value)); + } + // A caption key is built from the type name and the value [Fact] public void The_option_captions_come_from_the_resources() @@ -85,6 +97,7 @@ public sealed class SettingsViewModelTests Assert.Same(first, viewModel.Themes[0]); Assert.Equal("ru:AppTheme_System", first.Display); Assert.Equal("ru:AnchorSide_TopLeft", viewModel.AnchorSides[0].Display); + Assert.Equal("ru:CaretSide_Left", viewModel.CaretSides[0].Display); Assert.Equal("ru:ScreenPosition_TopLeft", viewModel.ScreenPositions[0].Display); Assert.Equal("ru:PopupPlacementMode_AtCursor", viewModel.PlacementModes[0].Display); } @@ -114,8 +127,8 @@ public sealed class SettingsViewModelTests var settings = new AppSettings(); using var viewModel = Create(settings); - Assert.Contains(settings.BackgroundColor, viewModel.BackgroundPalette); - Assert.Contains(settings.ForegroundColor, viewModel.TextPalette); + Assert.Contains(settings.Current.BackgroundColor, viewModel.BackgroundPalette); + Assert.Contains(settings.Current.ForegroundColor, viewModel.TextPalette); } [Fact] diff --git a/CursorLang.Settings.Tests/Views/MainWindowTests.cs b/CursorLang.Settings.Tests/Views/MainWindowTests.cs index 72eaf29..5d33cec 100644 --- a/CursorLang.Settings.Tests/Views/MainWindowTests.cs +++ b/CursorLang.Settings.Tests/Views/MainWindowTests.cs @@ -124,7 +124,8 @@ public sealed class MainWindowTests { Sta.Run(() => { - var settings = new AppSettings { FontSize = 33 }; + var settings = new AppSettings(); + settings.Current.FontSize = 33; using SettingsViewModel viewModel = CreateViewModel(settings); Open(viewModel, window => @@ -133,6 +134,178 @@ public sealed class MainWindowTests }); } + /// + /// The preview follows the mode: switching it shows the settings of the new one. + /// + /// + /// The look is bound through the current mode, so a switch has to reach the window. + /// Bound to a mode by name, the sliders would keep showing the cursor mode whatever + /// was chosen above them. + /// + [Fact] + public void A_switch_of_the_mode_shows_the_settings_of_that_mode() + { + Sta.Run(() => + { + var settings = new AppSettings(); + settings.AtCursor.FontSize = 24; + settings.FixedPoint.FontSize = 56; + + using SettingsViewModel viewModel = CreateViewModel(settings); + + Open(viewModel, window => + { + Assert.Contains(FindAll(window), text => Math.Abs(text.FontSize - 24) < 0.001); + + settings.PlacementMode = PopupPlacementMode.FixedPoint; + window.UpdateLayout(); + + Assert.Contains(FindAll(window), text => Math.Abs(text.FontSize - 56) < 0.001); + }); + }); + } + + /// + /// In the middle of the monitor the offset from the edge cannot be set, but it stays + /// in place. + /// + /// + /// There is no edge to stand off from there, so the setting goes grey. Taking the row + /// out of the section instead would move everything below it on every switch of the + /// place — the section is not to jump about. + /// + [Fact] + public void In_the_middle_of_the_monitor_the_offset_from_the_edge_goes_grey() + { + Sta.Run(() => + { + var settings = new AppSettings { PlacementMode = PopupPlacementMode.FixedPoint }; + using SettingsViewModel viewModel = CreateViewModel(settings); + + Open(viewModel, window => + { + Slider offset = Assert.Single(FindSlidersBoundTo(window, "Settings.FixedPoint.Offset")); + + Assert.True(offset.IsVisible); + Assert.False(offset.IsEnabled); + + // And it looks the part: the template draws from brushes that know + // nothing of the state, so the style has to fade it + Assert.True(offset.Opacity < 1); + + settings.FixedPoint.Position = ScreenPosition.TopRight; + window.UpdateLayout(); + + Assert.True(offset.IsVisible); + Assert.True(offset.IsEnabled); + Assert.Equal(1, offset.Opacity); + + settings.FixedPoint.Position = ScreenPosition.Center; + window.UpdateLayout(); + + Assert.True(offset.IsVisible); + Assert.False(offset.IsEnabled); + Assert.True(offset.Opacity < 1); + }); + }); + } + + // Another mode has no fixed point at all, and its offset has no place on screen + [Fact] + public void Away_from_the_fixed_point_its_offset_is_not_shown() + { + Sta.Run(() => + { + var settings = new AppSettings { PlacementMode = PopupPlacementMode.AtCursor }; + using SettingsViewModel viewModel = CreateViewModel(settings); + + Open(viewModel, window => + Assert.All(FindSlidersBoundTo(window, "Settings.FixedPoint.Offset"), slider => + Assert.False(slider.IsVisible))); + }); + } + + /// + /// What the look belongs to is explained in a tooltip next to the mode. + /// + /// + /// The sliders of the look show other numbers after a switch of the mode, and that + /// has to be explained. A line of text saying so would sit in the section forever, + /// while the question is asked once. + /// + [Fact] + public void The_window_explains_that_the_look_belongs_to_the_mode() + { + Sta.Run(() => + { + using SettingsViewModel viewModel = CreateViewModel(); + + Open(viewModel, window => + { + // The text of a tooltip is bound but not computed until the tooltip is + // shown, so what it is bound to is what gets checked + List hints = + [ + .. FindAll(window) + .Where(text => text.ToolTip is ToolTip tip && + tip.Content is TextBlock content && + PathOf(content, TextBlock.TextProperty) == "Localization[PopupLookPerModeHint]"), + ]; + + TextBlock link = Assert.Single(hints); + + Assert.Equal("en:MoreInfoLink", link.Text); + Assert.True(link.IsVisible); + }); + }); + } + + // Beside the caret and nowhere else: the list offers the two sides it has + [Fact] + public void The_caret_is_offered_two_sides_in_the_window() + { + Sta.Run(() => + { + var settings = new AppSettings { PlacementMode = PopupPlacementMode.AtCaret }; + using SettingsViewModel viewModel = CreateViewModel(settings); + + Open(viewModel, window => + { + ComboBox sides = Assert.Single( + FindAll(window), + box => BindingOperations.GetBinding(box, Selector.SelectedValueProperty)?.Path.Path + == "Settings.AtCaret.Side"); + + Assert.Equal(2, sides.Items.Count); + + sides.SelectedValue = CaretSide.Left; + + Assert.Equal(CaretSide.Left, settings.AtCaret.Side); + }); + }); + } + + // The sliders of the look write into the mode chosen above them + [Fact] + public void The_look_is_edited_in_the_mode_that_is_chosen() + { + Sta.Run(() => + { + var settings = new AppSettings { PlacementMode = PopupPlacementMode.AtCaret }; + using SettingsViewModel viewModel = CreateViewModel(settings); + + Open(viewModel, window => + { + Slider slider = Assert.Single(FindSlidersBoundTo(window, "Settings.Current.FontSize")); + + slider.Value = 44; + + Assert.Equal(44, settings.AtCaret.FontSize); + Assert.Equal(20, settings.AtCursor.FontSize); + }); + }); + } + [Fact] public void The_tooltip_colours_are_shown_as_swatches() { @@ -144,7 +317,7 @@ public sealed class MainWindowTests // The chosen colour is shown as a swatch with a caption — in the // same notation the settings file uses System.Drawing.Color chosen = viewModel.BackgroundPalette[2]; - settings.BackgroundColor = chosen; + settings.Current.BackgroundColor = chosen; string expected = $"#{chosen.R:X2}{chosen.G:X2}{chosen.B:X2}"; @@ -359,6 +532,13 @@ public sealed class MainWindowTests FindAll