changed settings saving (#3)

Reviewed-on: #3
Co-authored-by: Aleksandr Neychev <alexnejchev73@gmail.com>
This commit was merged in pull request #3.
This commit is contained in:
2026-08-12 17:15:12 +00:00
committed by alex
parent 68b3d544d2
commit 5e11b16758
19 changed files with 1169 additions and 150 deletions
+23 -15
View File
@@ -91,8 +91,17 @@ internal sealed class NativePopupWindow : NativeWindow, ILayoutPopupWindow
PopupWindowNative.Point position = atFixedPoint PopupWindowNative.Point position = atFixedPoint
? PopupLayout.OnScreen( ? PopupLayout.OnScreen(
work, _settings.ScreenPosition, PopupLayout.ToPixels(_settings.ScreenMargin, scale), width, height) work,
: PopupLayout.NearAnchor(anchor, AnchorSideForMode(), OffsetForMode(scale), width, height); _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))) 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); RoundTheCorners(bits, width, height, radius);
var size = new WindowNative.Size { Width = width, Height = height }; 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); 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) 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 // 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 // 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); IntPtr previousFont = GdiNative.SelectObject(deviceContext, _font);
GdiNative.SetBkMode(deviceContext, GdiNative.TRANSPARENT); 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.DrawText(deviceContext, text, text.Length, ref bounds,
GdiNative.DT_SINGLELINE | GdiNative.DT_CENTER | GdiNative.DT_VCENTER | GdiNative.DT_SINGLELINE | GdiNative.DT_CENTER | GdiNative.DT_VCENTER |
@@ -333,13 +342,11 @@ internal sealed class NativePopupWindow : NativeWindow, ILayoutPopupWindow
return PopupLayout.AsAnchor(PopupWindowNative.GetCursorPosition()); return PopupLayout.AsAnchor(PopupWindowNative.GetCursorPosition());
} }
// Every anchor mode has a side and an offset of its own // The caret has two sides to choose from and the cursor has six, so each mode names
private AnchorSide AnchorSideForMode() => // its own side in its own terms
_settings.PlacementMode == PopupPlacementMode.AtCaret ? _settings.CaretSide : _settings.CursorSide; private AnchorSide SideForMode() => _settings.PlacementMode == PopupPlacementMode.AtCaret
? _settings.AtCaret.Anchor
private int OffsetForMode(double scale) => PopupLayout.ToPixels( : _settings.AtCursor.Side;
_settings.PlacementMode == PopupPlacementMode.AtCaret ? _settings.CaretOffset : _settings.CursorOffset,
scale);
private Size MeasureText(string text) 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 // 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) private void EnsureFont(double scale)
{ {
if (_font != IntPtr.Zero && 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) Math.Abs(_fontScale - scale) < 0.01)
{ {
return; return;
@@ -376,7 +384,7 @@ internal sealed class NativePopupWindow : NativeWindow, ILayoutPopupWindow
ReleaseFont(); ReleaseFont();
_fontSize = _settings.FontSize; _fontSize = _settings.Current.FontSize;
_fontScale = scale; _fontScale = scale;
_font = GdiNative.CreateFont(_fontSize, scale); _font = GdiNative.CreateFont(_fontSize, scale);
} }
+170 -12
View File
@@ -16,18 +16,115 @@ public sealed class AppSettingsTests
Assert.Equal("en", settings.Language); Assert.Equal("en", settings.Language);
Assert.Equal(AppTheme.System, settings.Theme); Assert.Equal(AppTheme.System, settings.Theme);
Assert.Equal(PopupPlacementMode.AtCursor, settings.PlacementMode); 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(500, settings.DurationMilliseconds);
Assert.Equal(300, settings.CapsLockHoldMilliseconds); 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);
}
/// <summary>
/// The settings of a mode are its own: setting one up leaves the others alone.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[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<string?> changed = [];
settings.PropertyChanged += (_, e) => changed.Add(e.PropertyName);
settings.PlacementMode = PopupPlacementMode.FixedPoint;
Assert.Contains(nameof(AppSettings.PlacementMode), changed);
Assert.Contains(nameof(AppSettings.Current), changed);
}
/// <summary>
/// A change inside a mode is passed on as a change of the settings.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[Fact]
public void A_change_inside_a_mode_is_announced_by_the_settings()
{
var settings = new AppSettings();
List<string?> 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 // 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); Assert.Empty(changed);
} }
// Duration and CapsLockHoldDelay are derived from other settings and have /// <summary>
// no business being in the file /// Pouring another instance in fills the modes in place rather than replacing them.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[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] [Fact]
public void Derived_values_stay_out_of_the_file() 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.Duration), names);
Assert.DoesNotContain(nameof(AppSettings.CapsLockHoldDelay), 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 // What they are derived from, on the other hand, has to be stored
Assert.Contains(nameof(AppSettings.DurationMilliseconds), names); Assert.Contains(nameof(AppSettings.DurationMilliseconds), names);
Assert.Contains(nameof(AppSettings.CapsLockHoldMilliseconds), 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] [Fact]
@@ -118,6 +271,11 @@ public sealed class AppSettingsTests
} }
/// <summary>Names of the settings the user is able to change.</summary> /// <summary>Names of the settings the user is able to change.</summary>
/// <remarks>
/// 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
/// <see cref="PopupModeSettingsTests"/>.
/// </remarks>
internal static IEnumerable<string> WritablePropertyNames() => internal static IEnumerable<string> WritablePropertyNames() =>
typeof(AppSettings).GetProperties() typeof(AppSettings).GetProperties()
.Where(property => property.CanWrite) .Where(property => property.CanWrite)
@@ -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;
/// <summary>
/// The settings of a single placement mode: what the popup looks like there and how
/// far from its anchor it sits.
/// </summary>
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);
}
/// <summary>
/// Next to the caret the popup goes beside it, and to the right by default.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[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<CaretSide>());
}
[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<string?> changed = [];
mode.PropertyChanged += (_, e) => changed.Add(e.PropertyName);
mode.Side = CaretSide.Left;
Assert.Contains(nameof(CaretModeSettings.Anchor), changed);
}
/// <summary>
/// The fixed point starts out in the middle of the monitor, where there is no edge
/// to stand off from.
/// </summary>
[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<string?> 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<string?> 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<string?> 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<string> caret = Stored(new CaretModeSettings());
List<string> 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<INotifyPropertyChanged>(new CursorModeSettings());
Assert.IsAssignableFrom<INotifyPropertyChanged>(new CaretModeSettings());
Assert.IsAssignableFrom<INotifyPropertyChanged>(new FixedPointModeSettings());
}
public static TheoryData<Type, string> WritableProperties(Type type)
{
var data = new TheoryData<Type, string>();
foreach (string name in WritablePropertyNames(type))
{
data.Add(type, name);
}
return data;
}
/// <summary>Names of the settings of a mode the user is able to change.</summary>
internal static IEnumerable<string> 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<string> Stored<TMode>(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<object>()
.First(other => !Equals(other, value)),
var other => throw new NotSupportedException($"Unknown kind of setting: {other?.GetType()}"),
};
property.SetValue(mode, next);
}
}
@@ -108,6 +108,11 @@ public sealed class StringsTests
data.Add(key); data.Add(key);
} }
foreach (string key in EnumKeysOf<CaretSide>())
{
data.Add(key);
}
foreach (string key in EnumKeysOf<ScreenPosition>()) foreach (string key in EnumKeysOf<ScreenPosition>())
{ {
data.Add(key); data.Add(key);
@@ -71,7 +71,7 @@ public sealed class CapsLockSwitchCoordinatorTests
int startsBefore = hotkey.StartCalls; int startsBefore = hotkey.StartCalls;
int stopsBefore = hotkey.StopCalls; int stopsBefore = hotkey.StopCalls;
settings.FontSize = 44; settings.Current.FontSize = 44;
settings.CapsLockHoldMilliseconds = 700; settings.CapsLockHoldMilliseconds = 700;
Assert.Equal(startsBefore, hotkey.StartCalls); Assert.Equal(startsBefore, hotkey.StartCalls);
@@ -30,7 +30,7 @@ public sealed class SettingsServiceTests
}); });
Assert.Equal(AppTheme.System, settings.Theme); Assert.Equal(AppTheme.System, settings.Theme);
Assert.Equal(20, settings.FontSize); Assert.Equal(20, settings.Current.FontSize);
} }
[Theory] [Theory]
@@ -72,10 +72,10 @@ public sealed class SettingsServiceTests
using SettingsService service = Create(folder); using SettingsService service = Create(folder);
AppSettings settings = service.Load(); AppSettings settings = service.Load();
settings.FontSize = 42;
settings.Theme = AppTheme.Dark; settings.Theme = AppTheme.Dark;
settings.PlacementMode = PopupPlacementMode.AtCaret; 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; settings.UseCapsLockHotkey = true;
service.Save(); service.Save();
@@ -87,10 +87,10 @@ public sealed class SettingsServiceTests
return service.Load(); return service.Load();
}); });
Assert.Equal(42, restored.FontSize); Assert.Equal(42, restored.Current.FontSize);
Assert.Equal(AppTheme.Dark, restored.Theme); Assert.Equal(AppTheme.Dark, restored.Theme);
Assert.Equal(PopupPlacementMode.AtCaret, restored.PlacementMode); 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); Assert.True(restored.UseCapsLockHotkey);
} }
@@ -104,7 +104,7 @@ public sealed class SettingsServiceTests
using SettingsService service = Create(folder); using SettingsService service = Create(folder);
AppSettings settings = service.Load(); AppSettings settings = service.Load();
settings.Theme = AppTheme.Dark; settings.Theme = AppTheme.Dark;
settings.BackgroundColor = Color.FromArgb(0x20, 0x20, 0x20); settings.Current.BackgroundColor = Color.FromArgb(0x20, 0x20, 0x20);
service.Save(); service.Save();
}); });
@@ -130,7 +130,7 @@ public sealed class SettingsServiceTests
AppSettings settings = service.Load(); AppSettings settings = service.Load();
service.TrackChanges(); service.TrackChanges();
settings.FontSize = 33; settings.Current.FontSize = 33;
// Right after the edit there is nothing on disk yet: the write is deferred // Right after the edit there is nothing on disk yet: the write is deferred
Assert.False(File.Exists(path)); Assert.False(File.Exists(path));
@@ -156,7 +156,7 @@ public sealed class SettingsServiceTests
for (int i = 0; i < 10; i++) 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)); Assert.False(File.Exists(path));
Pump.Pause(TimeSpan.FromMilliseconds(10)); Pump.Pause(TimeSpan.FromMilliseconds(10));
} }
@@ -188,7 +188,7 @@ public sealed class SettingsServiceTests
service.TrackChanges(); service.TrackChanges();
AppSettings settings = service.Load(); AppSettings settings = service.Load();
settings.FontSize = 29; settings.Current.FontSize = 29;
Pump.WaitFor(() => File.Exists(path), "the settings were written by the timer"); 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); using SettingsService service = Create(folder);
AppSettings settings = service.Load(); AppSettings settings = service.Load();
File.WriteAllText(path, """{"FontSize": 31, "BackgroundColor": "#FF102030"}"""); File.WriteAllText(path, """{"AtCursor": {"FontSize": 31, "BackgroundColor": "#FF102030"}}""");
service.Reload(); service.Reload();
Assert.Equal(31, settings.FontSize); Assert.Equal(31, settings.Current.FontSize);
Assert.Equal(Color.FromArgb(0x10, 0x20, 0x30), settings.BackgroundColor); Assert.Equal(Color.FromArgb(0x10, 0x20, 0x30), settings.Current.BackgroundColor);
}); });
} }
@@ -250,12 +250,12 @@ public sealed class SettingsServiceTests
{ {
using SettingsService service = Create(folder); using SettingsService service = Create(folder);
AppSettings settings = service.Load(); AppSettings settings = service.Load();
settings.FontSize = 44; settings.Current.FontSize = 44;
File.WriteAllText(path, "not json at all"); File.WriteAllText(path, "not json at all");
service.Reload(); 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); SettingsService service = Create(folder);
AppSettings settings = service.Load(); AppSettings settings = service.Load();
service.TrackChanges(); service.TrackChanges();
settings.FontSize = 27; settings.Current.FontSize = 27;
service.Dispose(); service.Dispose();
}); });
@@ -295,7 +295,7 @@ public sealed class SettingsServiceTests
string afterDispose = File.ReadAllText(path); string afterDispose = File.ReadAllText(path);
settings.FontSize = 99; settings.Current.FontSize = 99;
Pump.Pause(TimeSpan.FromMilliseconds(60)); Pump.Pause(TimeSpan.FromMilliseconds(60));
Assert.Equal(afterDispose, File.ReadAllText(path)); Assert.Equal(afterDispose, File.ReadAllText(path));
@@ -309,7 +309,7 @@ public sealed class SettingsServiceTests
string inherited = folder.File("inherited.json"); string inherited = folder.File("inherited.json");
string own = folder.File("settings.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(() => AppSettings settings = Pump.Run(() =>
{ {
@@ -317,7 +317,7 @@ public sealed class SettingsServiceTests
return service.Load(); return service.Load();
}); });
Assert.Equal(31, settings.FontSize); Assert.Equal(31, settings.Current.FontSize);
Assert.Equal("ru", settings.Language); Assert.Equal("ru", settings.Language);
// What was taken over is pinned to its new place at once rather than on the first edit // 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(); using var folder = new TempFolder();
string inherited = folder.File("inherited.json"); string inherited = folder.File("inherited.json");
string original = """{"FontSize": 31}"""; string original = """{"AtCursor": {"FontSize": 31}}""";
File.WriteAllText(inherited, original); File.WriteAllText(inherited, original);
@@ -351,8 +351,8 @@ public sealed class SettingsServiceTests
string own = folder.File("settings.json"); string own = folder.File("settings.json");
string inherited = folder.File("inherited.json"); string inherited = folder.File("inherited.json");
File.WriteAllText(own, """{"FontSize": 12}"""); File.WriteAllText(own, """{"AtCursor": {"FontSize": 12}}""");
File.WriteAllText(inherited, """{"FontSize": 31}"""); File.WriteAllText(inherited, """{"AtCursor": {"FontSize": 31}}""");
AppSettings settings = Pump.Run(() => AppSettings settings = Pump.Run(() =>
{ {
@@ -360,7 +360,7 @@ public sealed class SettingsServiceTests
return service.Load(); 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 // 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(); return service.Load();
}); });
Assert.Equal(20, settings.FontSize); Assert.Equal(20, settings.Current.FontSize);
} }
[Fact] [Fact]
public void Settings_with_unknown_fields_are_still_read() public void Settings_with_unknown_fields_are_still_read()
{ {
using var folder = new TempFolder(); 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(() => AppSettings settings = Pump.Run(() =>
{ {
@@ -407,7 +407,7 @@ public sealed class SettingsServiceTests
return service.Load(); return service.Load();
}); });
Assert.Equal(15, settings.FontSize); Assert.Equal(15, settings.Current.FontSize);
} }
[Theory] [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) public void A_colour_is_read_from_its_usual_notation(string stored, byte r, byte g, byte b)
{ {
using var folder = new TempFolder(); 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(() => AppSettings settings = Pump.Run(() =>
{ {
@@ -425,7 +425,7 @@ public sealed class SettingsServiceTests
return service.Load(); return service.Load();
}); });
Assert.Equal(Color.FromArgb(r, g, b), settings.BackgroundColor); Assert.Equal(Color.FromArgb(r, g, b), settings.Current.BackgroundColor);
} }
[Theory] [Theory]
@@ -435,7 +435,7 @@ public sealed class SettingsServiceTests
public void An_unintelligible_colour_becomes_black(string stored) public void An_unintelligible_colour_becomes_black(string stored)
{ {
using var folder = new TempFolder(); 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(() => AppSettings settings = Pump.Run(() =>
{ {
@@ -443,7 +443,7 @@ public sealed class SettingsServiceTests
return service.Load(); return service.Load();
}); });
Assert.Equal(Color.Black, settings.BackgroundColor); Assert.Equal(Color.Black, settings.Current.BackgroundColor);
} }
// The service creates the settings folder itself // 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); using SettingsService service = new(path, folder.File("inherited.json"), SaveDelay);
AppSettings settings = service.Load(); AppSettings settings = service.Load();
settings.FontSize = 18; settings.Current.FontSize = 18;
service.Save(); 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}");
}
}
/// <summary>
/// The modes are stored apart: what is set up in one is still there after a trip
/// through the file and the other two.
/// </summary>
[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 // An ordinary run picks the storage place itself: a package keeps settings
// of its own, a separate install keeps them in the user profile // of its own, a separate install keeps them in the user profile
[Fact] [Fact]
+71 -60
View File
@@ -1,4 +1,4 @@
using System.Drawing; using System.ComponentModel;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using CommunityToolkit.Mvvm.ComponentModel; 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 application settings, as the settings window writes them to settings.json.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// The colours are <see cref="System.Drawing.Color"/> rather than /// Everything about the popup — the side, the offset, the font size, the opacity and
/// <c>System.Windows.Media.Color</c>. The former lives in System.Drawing.Primitives, /// both colours — belongs to a placement mode rather than to the application, and is
/// which is part of the base runtime and brings neither WPF nor GDI+ along; the latter /// kept in a <see cref="PopupModeSettings"/> of its own for each of them. So the
/// is WindowsBase, and this type is read by the agent, which must stay clear of it. /// settings of a mode are still there after a trip through the other two.
/// The settings window turns them into brushes in its converters.
/// ///
/// Both processes hold an instance of this, but only the settings window writes: the /// 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, /// 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] [ObservableProperty]
private PopupPlacementMode _placementMode = PopupPlacementMode.AtCursor; 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
/// <summary>The side of the cursor the popup is put on.</summary>
[ObservableProperty]
private AnchorSide _cursorSide = AnchorSide.BottomRight;
/// <summary>The offset from the cursor in WPF units.</summary>
[ObservableProperty]
private double _cursorOffset = 16;
/// <summary>The side of the caret the popup is put on.</summary>
[ObservableProperty]
private AnchorSide _caretSide = AnchorSide.BottomRight;
/// <summary>The offset from the caret in WPF units.</summary>
[ObservableProperty]
private double _caretOffset = 16;
/// <summary>
/// The place on the monitor for the <see cref="PopupPlacementMode.FixedPoint"/> mode.
/// </summary>
[ObservableProperty]
private ScreenPosition _screenPosition = ScreenPosition.BottomRight;
/// <summary>The offset from the monitor edge in WPF units.</summary>
[ObservableProperty]
private double _screenMargin = 24;
/// <summary>The size of the layout name in the popup, in WPF units.</summary>
[ObservableProperty]
private double _fontSize = 20;
/// <summary>The popup opacity: 1.0 is fully opaque.</summary>
[ObservableProperty]
private double _opacity = 0.9;
/// <summary>How long the popup stays on screen, in milliseconds.</summary> /// <summary>How long the popup stays on screen, in milliseconds.</summary>
[ObservableProperty] [ObservableProperty]
private double _durationMilliseconds = 500; private double _durationMilliseconds = 500;
@@ -87,13 +49,44 @@ public sealed partial class AppSettings : ObservableObject
[ObservableProperty] [ObservableProperty]
private double _capsLockHoldMilliseconds = 300; private double _capsLockHoldMilliseconds = 300;
/// <summary>The fill of the popup. The opacity is set by <see cref="Opacity"/>.</summary> public AppSettings()
[ObservableProperty] {
private Color _backgroundColor = Color.FromArgb(0xFF, 0x20, 0x20, 0x20); AtCursor.PropertyChanged += OnModeChanged;
AtCaret.PropertyChanged += OnModeChanged;
FixedPoint.PropertyChanged += OnModeChanged;
}
/// <summary>The colour of the layout name in the popup.</summary> /// <summary>The <see cref="PopupPlacementMode.AtCursor"/> mode.</summary>
[ObservableProperty] /// <remarks>
private Color _foregroundColor = Color.FromArgb(0xFF, 0xFF, 0xFF, 0xFF); /// 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.
/// </remarks>
[JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)]
public CursorModeSettings AtCursor { get; } = new();
/// <summary>The <see cref="PopupPlacementMode.AtCaret"/> mode.</summary>
[JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)]
public CaretModeSettings AtCaret { get; } = new();
/// <summary>The <see cref="PopupPlacementMode.FixedPoint"/> mode.</summary>
[JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)]
public FixedPointModeSettings FixedPoint { get; } = new();
/// <summary>The settings of the mode currently chosen.</summary>
/// <remarks>
/// 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
/// <see cref="PlacementMode"/> announces this as changed, so the bindings follow.
/// </remarks>
[JsonIgnore]
public PopupModeSettings Current => PlacementMode switch
{
PopupPlacementMode.AtCaret => AtCaret,
PopupPlacementMode.FixedPoint => FixedPoint,
_ => AtCursor,
};
/// <summary><see cref="DurationMilliseconds"/> as a <see cref="TimeSpan"/>.</summary> /// <summary><see cref="DurationMilliseconds"/> as a <see cref="TimeSpan"/>.</summary>
[JsonIgnore] [JsonIgnore]
@@ -110,25 +103,43 @@ public sealed partial class AppSettings : ObservableObject
/// <remarks> /// <remarks>
/// This is how the agent learns about an edit: the settings window is a separate /// 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 /// 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.
/// </remarks> /// </remarks>
public void CopyFrom(AppSettings other) public void CopyFrom(AppSettings other)
{ {
Language = other.Language; Language = other.Language;
Theme = other.Theme; Theme = other.Theme;
PlacementMode = other.PlacementMode; 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; DurationMilliseconds = other.DurationMilliseconds;
UseCapsLockHotkey = other.UseCapsLockHotkey; UseCapsLockHotkey = other.UseCapsLockHotkey;
CapsLockHoldMilliseconds = other.CapsLockHoldMilliseconds; 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));
/// <summary>
/// Passes a change made inside a mode on as a change of the settings.
/// </summary>
/// <remarks>
/// A mode is an object of its own, so an edit in it is not an edit of this one as
/// far as <see cref="INotifyPropertyChanged"/> 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".
/// </remarks>
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);
} }
+141
View File
@@ -0,0 +1,141 @@
using System.Drawing;
using System.Text.Json.Serialization;
using CommunityToolkit.Mvvm.ComponentModel;
namespace CursorLang.Core.Models;
/// <summary>
/// The settings of one placement mode: where the popup goes and how it looks there.
/// </summary>
/// <remarks>
/// 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 <see cref="System.Drawing.Color"/> for the same reason as in
/// <see cref="AppSettings"/>: the agent reads them and must stay clear of WPF.
/// </remarks>
public abstract partial class PopupModeSettings : ObservableObject
{
/// <summary>The distance from whatever the popup is placed by, in WPF units.</summary>
[ObservableProperty]
private double _offset = 16;
/// <summary>The size of the layout name in the popup, in WPF units.</summary>
[ObservableProperty]
private double _fontSize = 20;
/// <summary>The popup opacity: 1.0 is fully opaque.</summary>
[ObservableProperty]
private double _opacity = 0.9;
/// <summary>The fill of the popup. The opacity is set by <see cref="Opacity"/>.</summary>
[ObservableProperty]
private Color _backgroundColor = Color.FromArgb(0xFF, 0x20, 0x20, 0x20);
/// <summary>The colour of the layout name in the popup.</summary>
[ObservableProperty]
private Color _foregroundColor = Color.FromArgb(0xFF, 0xFF, 0xFF, 0xFF);
/// <summary>
/// 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.
/// </summary>
protected void CopyLookFrom(PopupModeSettings other)
{
Offset = other.Offset;
FontSize = other.FontSize;
Opacity = other.Opacity;
BackgroundColor = other.BackgroundColor;
ForegroundColor = other.ForegroundColor;
}
}
/// <summary>The mode that puts the popup next to the mouse cursor.</summary>
public sealed partial class CursorModeSettings : PopupModeSettings
{
/// <summary>The corner or the side of the cursor the popup is put on.</summary>
[ObservableProperty]
private AnchorSide _side = AnchorSide.BottomRight;
/// <summary>Takes the values of another mode of the same kind over.</summary>
public void CopyFrom(CursorModeSettings other)
{
CopyLookFrom(other);
Side = other.Side;
}
}
/// <summary>
/// The mode that puts the popup next to the caret of the active input field.
/// </summary>
public sealed partial class CaretModeSettings : PopupModeSettings
{
/// <summary>The side of the caret the popup is put on: only left or right.</summary>
[ObservableProperty]
private CaretSide _side = CaretSide.Right;
/// <summary>
/// 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.
/// </summary>
[JsonIgnore]
public AnchorSide Anchor => Side == CaretSide.Left ? AnchorSide.Left : AnchorSide.Right;
/// <summary>Takes the values of another mode of the same kind over.</summary>
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));
}
/// <summary>
/// The mode that puts the popup at a fixed place of the monitor holding the active
/// window. Here <see cref="PopupModeSettings.Offset"/> is the distance from the edge
/// of the monitor.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public sealed partial class FixedPointModeSettings : PopupModeSettings
{
/// <summary>The place on the monitor the popup is put at.</summary>
[ObservableProperty]
private ScreenPosition _position = ScreenPosition.Center;
public FixedPointModeSettings()
{
// The middle is the default, and it has no edge to stand off from
Offset = 0;
}
/// <summary>Whether the popup is put at an edge of the monitor rather than in its middle.</summary>
[JsonIgnore]
public bool IsAtAnEdge => Position != ScreenPosition.Center;
/// <summary>Takes the values of another mode of the same kind over.</summary>
public void CopyFrom(FixedPointModeSettings other)
{
CopyLookFrom(other);
Position = other.Position;
}
partial void OnPositionChanged(ScreenPosition value)
{
OnPropertyChanged(nameof(IsAtAnEdge));
if (value == ScreenPosition.Center)
{
Offset = 0;
}
}
}
+16 -1
View File
@@ -19,7 +19,7 @@ public enum PopupPlacementMode
} }
/// <summary> /// <summary>
/// Which side of the cursor or the caret to show the popup on. /// Which side of the cursor to show the popup on.
/// </summary> /// </summary>
public enum AnchorSide public enum AnchorSide
{ {
@@ -31,6 +31,21 @@ public enum AnchorSide
BottomRight, BottomRight,
} }
/// <summary>
/// Which side of the caret to show the popup on.
/// </summary>
/// <remarks>
/// Only the two sides, unlike <see cref="AnchorSide"/>. 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.
/// </remarks>
public enum CaretSide
{
Left,
Right,
}
/// <summary> /// <summary>
/// The place on the monitor for the <see cref="PopupPlacementMode.FixedPoint"/> mode. /// The place on the monitor for the <see cref="PopupPlacementMode.FixedPoint"/> mode.
/// </summary> /// </summary>
+9
View File
@@ -121,6 +121,12 @@
<data name="AnchorSide_Right" xml:space="preserve"> <data name="AnchorSide_Right" xml:space="preserve">
<value>Right</value> <value>Right</value>
</data> </data>
<data name="CaretSide_Left" xml:space="preserve">
<value>Left</value>
</data>
<data name="CaretSide_Right" xml:space="preserve">
<value>Right</value>
</data>
<data name="CursorOffsetLabel" xml:space="preserve"> <data name="CursorOffsetLabel" xml:space="preserve">
<value>Offset</value> <value>Offset</value>
</data> </data>
@@ -151,6 +157,9 @@
<data name="ScreenMarginLabel" xml:space="preserve"> <data name="ScreenMarginLabel" xml:space="preserve">
<value>Margin from screen edge</value> <value>Margin from screen edge</value>
</data> </data>
<data name="PopupLookPerModeHint" xml:space="preserve">
<value>The look below is remembered for the chosen placement mode alone.</value>
</data>
<data name="FontSizeLabel" xml:space="preserve"> <data name="FontSizeLabel" xml:space="preserve">
<value>Font size</value> <value>Font size</value>
</data> </data>
@@ -121,6 +121,12 @@
<data name="AnchorSide_Right" xml:space="preserve"> <data name="AnchorSide_Right" xml:space="preserve">
<value>Справа</value> <value>Справа</value>
</data> </data>
<data name="CaretSide_Left" xml:space="preserve">
<value>Слева</value>
</data>
<data name="CaretSide_Right" xml:space="preserve">
<value>Справа</value>
</data>
<data name="CursorOffsetLabel" xml:space="preserve"> <data name="CursorOffsetLabel" xml:space="preserve">
<value>Отступ</value> <value>Отступ</value>
</data> </data>
@@ -151,6 +157,9 @@
<data name="ScreenMarginLabel" xml:space="preserve"> <data name="ScreenMarginLabel" xml:space="preserve">
<value>Отступ от края экрана</value> <value>Отступ от края экрана</value>
</data> </data>
<data name="PopupLookPerModeHint" xml:space="preserve">
<value>Оформление ниже запоминается только для выбранного режима.</value>
</data>
<data name="FontSizeLabel" xml:space="preserve"> <data name="FontSizeLabel" xml:space="preserve">
<value>Размер шрифта</value> <value>Размер шрифта</value>
</data> </data>
@@ -114,7 +114,7 @@ public sealed class ThemeServiceTests
{ {
using var service = new ThemeService(settings, static () => AppTheme.Dark); using var service = new ThemeService(settings, static () => AppTheme.Dark);
settings.FontSize = 40; settings.Current.FontSize = 40;
settings.Language = "ru"; settings.Language = "ru";
Assert.Equal(AppTheme.Light, service.CurrentTheme); Assert.Equal(AppTheme.Light, service.CurrentTheme);
@@ -43,7 +43,7 @@ public sealed class SettingsViewModelTests
var localization = new FakeLocalizationService(); var localization = new FakeLocalizationService();
using var viewModel = Create(settings, localization); using var viewModel = Create(settings, localization);
settings.FontSize = 30; settings.Current.FontSize = 30;
Assert.Equal("en", localization.CurrentLanguage); Assert.Equal("en", localization.CurrentLanguage);
} }
@@ -59,6 +59,18 @@ public sealed class SettingsViewModelTests
Assert.Equal(Enum.GetValues<ScreenPosition>(), viewModel.ScreenPositions.Select(option => option.Value)); Assert.Equal(Enum.GetValues<ScreenPosition>(), 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 // A caption key is built from the type name and the value
[Fact] [Fact]
public void The_option_captions_come_from_the_resources() public void The_option_captions_come_from_the_resources()
@@ -85,6 +97,7 @@ public sealed class SettingsViewModelTests
Assert.Same(first, viewModel.Themes[0]); Assert.Same(first, viewModel.Themes[0]);
Assert.Equal("ru:AppTheme_System", first.Display); Assert.Equal("ru:AppTheme_System", first.Display);
Assert.Equal("ru:AnchorSide_TopLeft", viewModel.AnchorSides[0].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:ScreenPosition_TopLeft", viewModel.ScreenPositions[0].Display);
Assert.Equal("ru:PopupPlacementMode_AtCursor", viewModel.PlacementModes[0].Display); Assert.Equal("ru:PopupPlacementMode_AtCursor", viewModel.PlacementModes[0].Display);
} }
@@ -114,8 +127,8 @@ public sealed class SettingsViewModelTests
var settings = new AppSettings(); var settings = new AppSettings();
using var viewModel = Create(settings); using var viewModel = Create(settings);
Assert.Contains(settings.BackgroundColor, viewModel.BackgroundPalette); Assert.Contains(settings.Current.BackgroundColor, viewModel.BackgroundPalette);
Assert.Contains(settings.ForegroundColor, viewModel.TextPalette); Assert.Contains(settings.Current.ForegroundColor, viewModel.TextPalette);
} }
[Fact] [Fact]
@@ -124,7 +124,8 @@ public sealed class MainWindowTests
{ {
Sta.Run(() => Sta.Run(() =>
{ {
var settings = new AppSettings { FontSize = 33 }; var settings = new AppSettings();
settings.Current.FontSize = 33;
using SettingsViewModel viewModel = CreateViewModel(settings); using SettingsViewModel viewModel = CreateViewModel(settings);
Open(viewModel, window => Open(viewModel, window =>
@@ -133,6 +134,178 @@ public sealed class MainWindowTests
}); });
} }
/// <summary>
/// The preview follows the mode: switching it shows the settings of the new one.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[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<TextBlock>(window), text => Math.Abs(text.FontSize - 24) < 0.001);
settings.PlacementMode = PopupPlacementMode.FixedPoint;
window.UpdateLayout();
Assert.Contains(FindAll<TextBlock>(window), text => Math.Abs(text.FontSize - 56) < 0.001);
});
});
}
/// <summary>
/// In the middle of the monitor the offset from the edge cannot be set, but it stays
/// in place.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[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)));
});
}
/// <summary>
/// What the look belongs to is explained in a tooltip next to the mode.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[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<TextBlock> hints =
[
.. FindAll<TextBlock>(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<ComboBox>(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] [Fact]
public void The_tooltip_colours_are_shown_as_swatches() 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 // The chosen colour is shown as a swatch with a caption — in the
// same notation the settings file uses // same notation the settings file uses
System.Drawing.Color chosen = viewModel.BackgroundPalette[2]; 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}"; string expected = $"#{chosen.R:X2}{chosen.G:X2}{chosen.B:X2}";
@@ -359,6 +532,13 @@ public sealed class MainWindowTests
FindAll<Button>(root).Where(button => FindAll<Button>(root).Where(button =>
BindingOperations.GetBinding(button, ButtonBase.CommandProperty)?.Path.Path == path); BindingOperations.GetBinding(button, ButtonBase.CommandProperty)?.Path.Path == path);
private static string? PathOf(DependencyObject element, DependencyProperty property) =>
BindingOperations.GetBinding(element, property)?.Path.Path;
private static IEnumerable<Slider> FindSlidersBoundTo(DependencyObject root, string path) =>
FindAll<Slider>(root).Where(slider =>
BindingOperations.GetBinding(slider, RangeBase.ValueProperty)?.Path.Path == path);
private static IEnumerable<CheckBox> FindStartupCheckBoxes(DependencyObject root) => private static IEnumerable<CheckBox> FindStartupCheckBoxes(DependencyObject root) =>
FindCheckBoxesBoundTo(root, nameof(SettingsViewModel.RunAtStartup)); FindCheckBoxesBoundTo(root, nameof(SettingsViewModel.RunAtStartup));
+5
View File
@@ -337,6 +337,11 @@
</ControlTemplate> </ControlTemplate>
</Setter.Value> </Setter.Value>
</Setter> </Setter>
<Style.Triggers>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.5" />
</Trigger>
</Style.Triggers>
</Style> </Style>
<!-- ======================= ProgressBar ======================= --> <!-- ======================= ProgressBar ======================= -->
@@ -87,6 +87,7 @@ public sealed partial class SettingsViewModel : ObservableObject, IDisposable
Themes = CreateOptions<AppTheme>(); Themes = CreateOptions<AppTheme>();
PlacementModes = CreateOptions<PopupPlacementMode>(); PlacementModes = CreateOptions<PopupPlacementMode>();
AnchorSides = CreateOptions<AnchorSide>(); AnchorSides = CreateOptions<AnchorSide>();
CaretSides = CreateOptions<CaretSide>();
ScreenPositions = CreateOptions<ScreenPosition>(); ScreenPositions = CreateOptions<ScreenPosition>();
} }
@@ -107,6 +108,9 @@ public sealed partial class SettingsViewModel : ObservableObject, IDisposable
public IReadOnlyList<EnumOption<AnchorSide>> AnchorSides { get; } public IReadOnlyList<EnumOption<AnchorSide>> AnchorSides { get; }
/// <summary>The sides on offer next to the caret: there the popup only goes beside it.</summary>
public IReadOnlyList<EnumOption<CaretSide>> CaretSides { get; }
public IReadOnlyList<EnumOption<ScreenPosition>> ScreenPositions { get; } public IReadOnlyList<EnumOption<ScreenPosition>> ScreenPositions { get; }
/// <summary>Whether to show the startup setting.</summary> /// <summary>Whether to show the startup setting.</summary>
@@ -187,6 +191,7 @@ public sealed partial class SettingsViewModel : ObservableObject, IDisposable
Translate(Themes); Translate(Themes);
Translate(PlacementModes); Translate(PlacementModes);
Translate(AnchorSides); Translate(AnchorSides);
Translate(CaretSides);
Translate(ScreenPositions); Translate(ScreenPositions);
} }
+51 -25
View File
@@ -39,6 +39,11 @@
<!-- The caption column is narrow, and a translation may not fit into it: <!-- The caption column is narrow, and a translation may not fit into it:
such a caption is better wrapped onto a second line than cut off --> such a caption is better wrapped onto a second line than cut off -->
<Setter Property="TextWrapping" Value="Wrap" /> <Setter Property="TextWrapping" Value="Wrap" />
<Style.Triggers>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.5" />
</Trigger>
</Style.Triggers>
</Style> </Style>
<Style TargetType="TextBlock" x:Key="FieldValue"> <Style TargetType="TextBlock" x:Key="FieldValue">
<Setter Property="VerticalAlignment" Value="Center" /> <Setter Property="VerticalAlignment" Value="Center" />
@@ -46,6 +51,11 @@
<Setter Property="MinWidth" Value="48" /> <Setter Property="MinWidth" Value="48" />
<Setter Property="TextAlignment" Value="Right" /> <Setter Property="TextAlignment" Value="Right" />
<Setter Property="Foreground" Value="{DynamicResource Theme.SecondaryForeground}" /> <Setter Property="Foreground" Value="{DynamicResource Theme.SecondaryForeground}" />
<Style.Triggers>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.5" />
</Trigger>
</Style.Triggers>
</Style> </Style>
</Window.Resources> </Window.Resources>
@@ -282,8 +292,24 @@
<RowDefinition Height="Auto" /> <RowDefinition Height="Auto" />
</Grid.RowDefinitions> </Grid.RowDefinitions>
<TextBlock Style="{StaticResource FieldLabel}" <TextBlock Style="{StaticResource FieldLabel}">
Text="{Binding Localization[PlacementModeLabel]}" /> <Run Text="{Binding Localization[PlacementModeLabel], Mode=OneWay}" />
<InlineUIContainer BaselineAlignment="Baseline">
<TextBlock Text="{Binding Localization[MoreInfoLink]}"
Foreground="{DynamicResource Theme.Accent}"
TextDecorations="Underline"
Cursor="Help"
ToolTipService.InitialShowDelay="200"
ToolTipService.ShowDuration="60000">
<TextBlock.ToolTip>
<ToolTip>
<TextBlock TextWrapping="Wrap" MaxWidth="320"
Text="{Binding Localization[PopupLookPerModeHint]}" />
</ToolTip>
</TextBlock.ToolTip>
</TextBlock>
</InlineUIContainer>
</TextBlock>
<ComboBox Grid.Column="1" Grid.ColumnSpan="2" <ComboBox Grid.Column="1" Grid.ColumnSpan="2"
ItemsSource="{Binding PlacementModes}" ItemsSource="{Binding PlacementModes}"
ItemTemplate="{StaticResource EnumOptionTemplate}" ItemTemplate="{StaticResource EnumOptionTemplate}"
@@ -301,7 +327,7 @@
ItemsSource="{Binding AnchorSides}" ItemsSource="{Binding AnchorSides}"
ItemTemplate="{StaticResource EnumOptionTemplate}" ItemTemplate="{StaticResource EnumOptionTemplate}"
SelectedValuePath="Value" SelectedValuePath="Value"
SelectedValue="{Binding Settings.CursorSide}" SelectedValue="{Binding Settings.AtCursor.Side}"
Visibility="{Binding Settings.PlacementMode, Visibility="{Binding Settings.PlacementMode,
Converter={StaticResource EnumToVisibility}, Converter={StaticResource EnumToVisibility},
ConverterParameter=AtCursor}" /> ConverterParameter=AtCursor}" />
@@ -314,13 +340,13 @@
ConverterParameter=AtCursor}" /> ConverterParameter=AtCursor}" />
<Slider Grid.Row="2" Grid.Column="1" Margin="0,12,0,0" <Slider Grid.Row="2" Grid.Column="1" Margin="0,12,0,0"
Minimum="0" Maximum="80" TickFrequency="1" Minimum="0" Maximum="80" TickFrequency="1"
Value="{Binding Settings.CursorOffset}" Value="{Binding Settings.AtCursor.Offset}"
Visibility="{Binding Settings.PlacementMode, Visibility="{Binding Settings.PlacementMode,
Converter={StaticResource EnumToVisibility}, Converter={StaticResource EnumToVisibility},
ConverterParameter=AtCursor}" /> ConverterParameter=AtCursor}" />
<TextBlock Grid.Row="2" Grid.Column="2" Margin="12,12,0,0" <TextBlock Grid.Row="2" Grid.Column="2" Margin="12,12,0,0"
Style="{StaticResource FieldValue}" Style="{StaticResource FieldValue}"
Text="{Binding Settings.CursorOffset, StringFormat={}{0:F0}}" Text="{Binding Settings.AtCursor.Offset, StringFormat={}{0:F0}}"
Visibility="{Binding Settings.PlacementMode, Visibility="{Binding Settings.PlacementMode,
Converter={StaticResource EnumToVisibility}, Converter={StaticResource EnumToVisibility},
ConverterParameter=AtCursor}" /> ConverterParameter=AtCursor}" />
@@ -333,10 +359,10 @@
Converter={StaticResource EnumToVisibility}, Converter={StaticResource EnumToVisibility},
ConverterParameter=AtCaret}" /> ConverterParameter=AtCaret}" />
<ComboBox Grid.Row="3" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,12,0,0" <ComboBox Grid.Row="3" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,12,0,0"
ItemsSource="{Binding AnchorSides}" ItemsSource="{Binding CaretSides}"
ItemTemplate="{StaticResource EnumOptionTemplate}" ItemTemplate="{StaticResource EnumOptionTemplate}"
SelectedValuePath="Value" SelectedValuePath="Value"
SelectedValue="{Binding Settings.CaretSide}" SelectedValue="{Binding Settings.AtCaret.Side}"
Visibility="{Binding Settings.PlacementMode, Visibility="{Binding Settings.PlacementMode,
Converter={StaticResource EnumToVisibility}, Converter={StaticResource EnumToVisibility},
ConverterParameter=AtCaret}" /> ConverterParameter=AtCaret}" />
@@ -349,13 +375,13 @@
ConverterParameter=AtCaret}" /> ConverterParameter=AtCaret}" />
<Slider Grid.Row="4" Grid.Column="1" Margin="0,12,0,0" <Slider Grid.Row="4" Grid.Column="1" Margin="0,12,0,0"
Minimum="0" Maximum="80" TickFrequency="1" Minimum="0" Maximum="80" TickFrequency="1"
Value="{Binding Settings.CaretOffset}" Value="{Binding Settings.AtCaret.Offset}"
Visibility="{Binding Settings.PlacementMode, Visibility="{Binding Settings.PlacementMode,
Converter={StaticResource EnumToVisibility}, Converter={StaticResource EnumToVisibility},
ConverterParameter=AtCaret}" /> ConverterParameter=AtCaret}" />
<TextBlock Grid.Row="4" Grid.Column="2" Margin="12,12,0,0" <TextBlock Grid.Row="4" Grid.Column="2" Margin="12,12,0,0"
Style="{StaticResource FieldValue}" Style="{StaticResource FieldValue}"
Text="{Binding Settings.CaretOffset, StringFormat={}{0:F0}}" Text="{Binding Settings.AtCaret.Offset, StringFormat={}{0:F0}}"
Visibility="{Binding Settings.PlacementMode, Visibility="{Binding Settings.PlacementMode,
Converter={StaticResource EnumToVisibility}, Converter={StaticResource EnumToVisibility},
ConverterParameter=AtCaret}" /> ConverterParameter=AtCaret}" />
@@ -371,7 +397,7 @@
ItemsSource="{Binding ScreenPositions}" ItemsSource="{Binding ScreenPositions}"
ItemTemplate="{StaticResource EnumOptionTemplate}" ItemTemplate="{StaticResource EnumOptionTemplate}"
SelectedValuePath="Value" SelectedValuePath="Value"
SelectedValue="{Binding Settings.ScreenPosition}" SelectedValue="{Binding Settings.FixedPoint.Position}"
Visibility="{Binding Settings.PlacementMode, Visibility="{Binding Settings.PlacementMode,
Converter={StaticResource EnumToVisibility}, Converter={StaticResource EnumToVisibility},
ConverterParameter=FixedPoint}" /> ConverterParameter=FixedPoint}" />
@@ -379,18 +405,21 @@
<TextBlock Grid.Row="6" Margin="0,12,12,0" <TextBlock Grid.Row="6" Margin="0,12,12,0"
Style="{StaticResource FieldLabel}" Style="{StaticResource FieldLabel}"
Text="{Binding Localization[ScreenMarginLabel]}" Text="{Binding Localization[ScreenMarginLabel]}"
IsEnabled="{Binding Settings.FixedPoint.IsAtAnEdge}"
Visibility="{Binding Settings.PlacementMode, Visibility="{Binding Settings.PlacementMode,
Converter={StaticResource EnumToVisibility}, Converter={StaticResource EnumToVisibility},
ConverterParameter=FixedPoint}" /> ConverterParameter=FixedPoint}" />
<Slider Grid.Row="6" Grid.Column="1" Margin="0,12,0,0" <Slider Grid.Row="6" Grid.Column="1" Margin="0,12,0,0"
Minimum="0" Maximum="200" TickFrequency="1" Minimum="0" Maximum="200" TickFrequency="1"
Value="{Binding Settings.ScreenMargin}" Value="{Binding Settings.FixedPoint.Offset}"
IsEnabled="{Binding Settings.FixedPoint.IsAtAnEdge}"
Visibility="{Binding Settings.PlacementMode, Visibility="{Binding Settings.PlacementMode,
Converter={StaticResource EnumToVisibility}, Converter={StaticResource EnumToVisibility},
ConverterParameter=FixedPoint}" /> ConverterParameter=FixedPoint}" />
<TextBlock Grid.Row="6" Grid.Column="2" Margin="12,12,0,0" <TextBlock Grid.Row="6" Grid.Column="2" Margin="12,12,0,0"
Style="{StaticResource FieldValue}" Style="{StaticResource FieldValue}"
Text="{Binding Settings.ScreenMargin, StringFormat={}{0:F0}}" Text="{Binding Settings.FixedPoint.Offset, StringFormat={}{0:F0}}"
IsEnabled="{Binding Settings.FixedPoint.IsAtAnEdge}"
Visibility="{Binding Settings.PlacementMode, Visibility="{Binding Settings.PlacementMode,
Converter={StaticResource EnumToVisibility}, Converter={StaticResource EnumToVisibility},
ConverterParameter=FixedPoint}" /> ConverterParameter=FixedPoint}" />
@@ -406,20 +435,20 @@
Text="{Binding Localization[FontSizeLabel]}" /> Text="{Binding Localization[FontSizeLabel]}" />
<Slider Grid.Row="8" Grid.Column="1" Margin="0,12,0,0" <Slider Grid.Row="8" Grid.Column="1" Margin="0,12,0,0"
Minimum="10" Maximum="72" TickFrequency="1" Minimum="10" Maximum="72" TickFrequency="1"
Value="{Binding Settings.FontSize}" /> Value="{Binding Settings.Current.FontSize}" />
<TextBlock Grid.Row="8" Grid.Column="2" Margin="12,12,0,0" <TextBlock Grid.Row="8" Grid.Column="2" Margin="12,12,0,0"
Style="{StaticResource FieldValue}" Style="{StaticResource FieldValue}"
Text="{Binding Settings.FontSize, StringFormat={}{0:F0}}" /> Text="{Binding Settings.Current.FontSize, StringFormat={}{0:F0}}" />
<TextBlock Grid.Row="9" Margin="0,12,12,0" <TextBlock Grid.Row="9" Margin="0,12,12,0"
Style="{StaticResource FieldLabel}" Style="{StaticResource FieldLabel}"
Text="{Binding Localization[OpacityLabel]}" /> Text="{Binding Localization[OpacityLabel]}" />
<Slider Grid.Row="9" Grid.Column="1" Margin="0,12,0,0" <Slider Grid.Row="9" Grid.Column="1" Margin="0,12,0,0"
Minimum="0.1" Maximum="1" TickFrequency="0.05" Minimum="0.1" Maximum="1" TickFrequency="0.05"
Value="{Binding Settings.Opacity}" /> Value="{Binding Settings.Current.Opacity}" />
<TextBlock Grid.Row="9" Grid.Column="2" Margin="12,12,0,0" <TextBlock Grid.Row="9" Grid.Column="2" Margin="12,12,0,0"
Style="{StaticResource FieldValue}" Style="{StaticResource FieldValue}"
Text="{Binding Settings.Opacity, StringFormat={}{0:P0}}" /> Text="{Binding Settings.Current.Opacity, StringFormat={}{0:P0}}" />
<TextBlock Grid.Row="10" Margin="0,12,12,0" <TextBlock Grid.Row="10" Margin="0,12,12,0"
Style="{StaticResource FieldLabel}" Style="{StaticResource FieldLabel}"
@@ -427,7 +456,7 @@
<ComboBox Grid.Row="10" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,12,0,0" <ComboBox Grid.Row="10" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,12,0,0"
ItemsSource="{Binding BackgroundPalette}" ItemsSource="{Binding BackgroundPalette}"
ItemTemplate="{StaticResource ColorSwatchTemplate}" ItemTemplate="{StaticResource ColorSwatchTemplate}"
SelectedItem="{Binding Settings.BackgroundColor}" /> SelectedItem="{Binding Settings.Current.BackgroundColor}" />
<TextBlock Grid.Row="11" Margin="0,12,12,0" <TextBlock Grid.Row="11" Margin="0,12,12,0"
Style="{StaticResource FieldLabel}" Style="{StaticResource FieldLabel}"
@@ -435,14 +464,11 @@
<ComboBox Grid.Row="11" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,12,0,0" <ComboBox Grid.Row="11" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,12,0,0"
ItemsSource="{Binding TextPalette}" ItemsSource="{Binding TextPalette}"
ItemTemplate="{StaticResource ColorSwatchTemplate}" ItemTemplate="{StaticResource ColorSwatchTemplate}"
SelectedItem="{Binding Settings.ForegroundColor}" /> SelectedItem="{Binding Settings.Current.ForegroundColor}" />
<TextBlock Grid.Row="12" Margin="0,12,12,0" <TextBlock Grid.Row="12" Margin="0,12,12,0"
Style="{StaticResource FieldLabel}" Style="{StaticResource FieldLabel}"
Text="{Binding Localization[PreviewLabel]}" /> Text="{Binding Localization[PreviewLabel]}" />
<!-- The height of the area is sized for the largest font
available: the sample text is fully visible at any
position of the slider -->
<Border Grid.Row="12" Grid.Column="1" Grid.ColumnSpan="2" <Border Grid.Row="12" Grid.Column="1" Grid.ColumnSpan="2"
Margin="0,12,0,0" Padding="16" Margin="0,12,0,0" Padding="16"
Height="152" ClipToBounds="True" Height="152" ClipToBounds="True"
@@ -450,12 +476,12 @@
HorizontalAlignment="Stretch"> HorizontalAlignment="Stretch">
<Border CornerRadius="4" Padding="10,4" <Border CornerRadius="4" Padding="10,4"
HorizontalAlignment="Center" VerticalAlignment="Center" HorizontalAlignment="Center" VerticalAlignment="Center"
Opacity="{Binding Settings.Opacity}" Opacity="{Binding Settings.Current.Opacity}"
Background="{Binding Settings.BackgroundColor, Background="{Binding Settings.Current.BackgroundColor,
Converter={StaticResource ColorToBrush}}"> Converter={StaticResource ColorToBrush}}">
<TextBlock Text="RU" FontWeight="SemiBold" <TextBlock Text="RU" FontWeight="SemiBold"
FontSize="{Binding Settings.FontSize}" FontSize="{Binding Settings.Current.FontSize}"
Foreground="{Binding Settings.ForegroundColor, Foreground="{Binding Settings.Current.ForegroundColor,
Converter={StaticResource ColorToBrush}}" /> Converter={StaticResource ColorToBrush}}" />
</Border> </Border>
</Border> </Border>
+22
View File
@@ -143,6 +143,28 @@ MSIX всегда выполняются в контексте вошедшег
быть установлены рядом, и приложение не вправе удалять настройки, которые ему не быть установлены рядом, и приложение не вправе удалять настройки, которые ему не
принадлежат. принадлежат.
Всё, что относится к всплывающей подсказке, принадлежит режиму размещения, а не
приложению, и файл хранит по разделу на каждый режим — `AtCursor`, `AtCaret` и
`FixedPoint`. В каждом лежат сторона, отступ, размер шрифта, прозрачность и оба
цвета. Подсказка рядом с курсором ввода находится внутри читаемого текста, и её
хотят видеть небольшой и незаметной; ту, что в углу монитора, ищут намеренно, и её
хотят видеть крупной. Общее для всех режимов оформление приходилось настраивать
заново после каждого переключения, поэтому окно настроек показывает оформление
выбранного выше режима и записывает только в него.
Сторона означает в каждом режиме своё, и у каждого для неё собственный тип. Рядом с
курсором мыши это любой из шести углов и сторон. Рядом с курсором ввода — только
слева или справа: сверху и снизу от каретки находится следующая строка текста, и
подсказка закрывала бы читаемое. Для фиксированной точки это место на мониторе, а
отступ — расстояние от его края: в середине края нет, поэтому выбор середины
сбрасывает отступ в нуль, а окно настроек показывает его неактивным. Отступ остаётся
на месте, а не исчезает из раздела — появляющаяся и исчезающая строка сдвигала бы
всё, что ниже, при каждой смене места.
К какому режиму относится оформление, объясняет всплывающая подсказка рядом с самим
режимом, а не строка текста в разделе: ползунки ниже показывают другие числа после
переключения режима, и это вопрос, который задают один раз.
## Обновления ## Обновления
Проверка выполняется при открытии окна настроек, а не при включении машины: Проверка выполняется при открытии окна настроек, а не при включении машины:
+22
View File
@@ -138,6 +138,28 @@ install and copies them over. The original file stays where it is: both builds
may be installed side by side, and the app has no business deleting settings it may be installed side by side, and the app has no business deleting settings it
does not own. does not own.
Everything about the popup belongs to a placement mode rather than to the app, and
the file keeps a section per mode — `AtCursor`, `AtCaret` and `FixedPoint`. Each of
them holds the side, the offset, the font size, the opacity and both colours. 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, so the settings
window shows the look of the mode chosen above it and writes into that mode alone.
What the side means differs by mode, and each of them has a type saying so. Next to
the cursor it is any of the six corners and sides. Next to the caret it is left or
right and nothing else — above or below the caret is exactly where the next line of
the text is, so the popup would cover what is being read. For the fixed point it is a
place on the monitor, and the offset is the distance from its edge: in the middle
there is no edge to stand off from, so choosing the middle puts the offset back to
zero and the settings window shows it greyed out. It stays in place rather than
leaving the section — a row that comes and goes would move everything below it on
every switch of the place.
Which mode the look belongs to is explained in a tooltip next to the mode itself
rather than by a line of text in the section: the sliders below show other numbers
after a switch of the mode, and that is a question asked once.
## Updates ## Updates
The check runs when the settings window is opened, not when the machine is The check runs when the settings window is opened, not when the machine is