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