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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <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
|
||||
@@ -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
|
||||
/// <summary>
|
||||
/// 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]
|
||||
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
|
||||
}
|
||||
|
||||
/// <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() =>
|
||||
typeof(AppSettings).GetProperties()
|
||||
.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);
|
||||
}
|
||||
|
||||
foreach (string key in EnumKeysOf<CaretSide>())
|
||||
{
|
||||
data.Add(key);
|
||||
}
|
||||
|
||||
foreach (string key in EnumKeysOf<ScreenPosition>())
|
||||
{
|
||||
data.Add(key);
|
||||
|
||||
@@ -71,7 +71,7 @@ public sealed class CapsLockSwitchCoordinatorTests
|
||||
int startsBefore = hotkey.StartCalls;
|
||||
int stopsBefore = hotkey.StopCalls;
|
||||
|
||||
settings.FontSize = 44;
|
||||
settings.Current.FontSize = 44;
|
||||
settings.CapsLockHoldMilliseconds = 700;
|
||||
|
||||
Assert.Equal(startsBefore, hotkey.StartCalls);
|
||||
|
||||
@@ -30,7 +30,7 @@ public sealed class SettingsServiceTests
|
||||
});
|
||||
|
||||
Assert.Equal(AppTheme.System, settings.Theme);
|
||||
Assert.Equal(20, settings.FontSize);
|
||||
Assert.Equal(20, settings.Current.FontSize);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -72,10 +72,10 @@ public sealed class SettingsServiceTests
|
||||
using SettingsService service = Create(folder);
|
||||
AppSettings settings = service.Load();
|
||||
|
||||
settings.FontSize = 42;
|
||||
settings.Theme = AppTheme.Dark;
|
||||
settings.PlacementMode = PopupPlacementMode.AtCaret;
|
||||
settings.BackgroundColor = Color.FromArgb(0x11, 0x22, 0x33);
|
||||
settings.Current.FontSize = 42;
|
||||
settings.Current.BackgroundColor = Color.FromArgb(0x11, 0x22, 0x33);
|
||||
settings.UseCapsLockHotkey = true;
|
||||
|
||||
service.Save();
|
||||
@@ -87,10 +87,10 @@ public sealed class SettingsServiceTests
|
||||
return service.Load();
|
||||
});
|
||||
|
||||
Assert.Equal(42, restored.FontSize);
|
||||
Assert.Equal(42, restored.Current.FontSize);
|
||||
Assert.Equal(AppTheme.Dark, restored.Theme);
|
||||
Assert.Equal(PopupPlacementMode.AtCaret, restored.PlacementMode);
|
||||
Assert.Equal(Color.FromArgb(0x11, 0x22, 0x33), restored.BackgroundColor);
|
||||
Assert.Equal(Color.FromArgb(0x11, 0x22, 0x33), restored.Current.BackgroundColor);
|
||||
Assert.True(restored.UseCapsLockHotkey);
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ public sealed class SettingsServiceTests
|
||||
using SettingsService service = Create(folder);
|
||||
AppSettings settings = service.Load();
|
||||
settings.Theme = AppTheme.Dark;
|
||||
settings.BackgroundColor = Color.FromArgb(0x20, 0x20, 0x20);
|
||||
settings.Current.BackgroundColor = Color.FromArgb(0x20, 0x20, 0x20);
|
||||
service.Save();
|
||||
});
|
||||
|
||||
@@ -130,7 +130,7 @@ public sealed class SettingsServiceTests
|
||||
AppSettings settings = service.Load();
|
||||
service.TrackChanges();
|
||||
|
||||
settings.FontSize = 33;
|
||||
settings.Current.FontSize = 33;
|
||||
|
||||
// Right after the edit there is nothing on disk yet: the write is deferred
|
||||
Assert.False(File.Exists(path));
|
||||
@@ -156,7 +156,7 @@ public sealed class SettingsServiceTests
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
settings.Opacity = 0.5 + (i * 0.01);
|
||||
settings.Current.Opacity = 0.5 + (i * 0.01);
|
||||
Assert.False(File.Exists(path));
|
||||
Pump.Pause(TimeSpan.FromMilliseconds(10));
|
||||
}
|
||||
@@ -188,7 +188,7 @@ public sealed class SettingsServiceTests
|
||||
service.TrackChanges();
|
||||
|
||||
AppSettings settings = service.Load();
|
||||
settings.FontSize = 29;
|
||||
settings.Current.FontSize = 29;
|
||||
|
||||
Pump.WaitFor(() => File.Exists(path), "the settings were written by the timer");
|
||||
});
|
||||
@@ -230,11 +230,11 @@ public sealed class SettingsServiceTests
|
||||
using SettingsService service = Create(folder);
|
||||
AppSettings settings = service.Load();
|
||||
|
||||
File.WriteAllText(path, """{"FontSize": 31, "BackgroundColor": "#FF102030"}""");
|
||||
File.WriteAllText(path, """{"AtCursor": {"FontSize": 31, "BackgroundColor": "#FF102030"}}""");
|
||||
service.Reload();
|
||||
|
||||
Assert.Equal(31, settings.FontSize);
|
||||
Assert.Equal(Color.FromArgb(0x10, 0x20, 0x30), settings.BackgroundColor);
|
||||
Assert.Equal(31, settings.Current.FontSize);
|
||||
Assert.Equal(Color.FromArgb(0x10, 0x20, 0x30), settings.Current.BackgroundColor);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -250,12 +250,12 @@ public sealed class SettingsServiceTests
|
||||
{
|
||||
using SettingsService service = Create(folder);
|
||||
AppSettings settings = service.Load();
|
||||
settings.FontSize = 44;
|
||||
settings.Current.FontSize = 44;
|
||||
|
||||
File.WriteAllText(path, "not json at all");
|
||||
service.Reload();
|
||||
|
||||
Assert.Equal(44, settings.FontSize);
|
||||
Assert.Equal(44, settings.Current.FontSize);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -269,7 +269,7 @@ public sealed class SettingsServiceTests
|
||||
SettingsService service = Create(folder);
|
||||
AppSettings settings = service.Load();
|
||||
service.TrackChanges();
|
||||
settings.FontSize = 27;
|
||||
settings.Current.FontSize = 27;
|
||||
|
||||
service.Dispose();
|
||||
});
|
||||
@@ -295,7 +295,7 @@ public sealed class SettingsServiceTests
|
||||
|
||||
string afterDispose = File.ReadAllText(path);
|
||||
|
||||
settings.FontSize = 99;
|
||||
settings.Current.FontSize = 99;
|
||||
Pump.Pause(TimeSpan.FromMilliseconds(60));
|
||||
|
||||
Assert.Equal(afterDispose, File.ReadAllText(path));
|
||||
@@ -309,7 +309,7 @@ public sealed class SettingsServiceTests
|
||||
string inherited = folder.File("inherited.json");
|
||||
string own = folder.File("settings.json");
|
||||
|
||||
File.WriteAllText(inherited, """{"FontSize": 31, "Language": "ru"}""");
|
||||
File.WriteAllText(inherited, """{"AtCursor": {"FontSize": 31}, "Language": "ru"}""");
|
||||
|
||||
AppSettings settings = Pump.Run(() =>
|
||||
{
|
||||
@@ -317,7 +317,7 @@ public sealed class SettingsServiceTests
|
||||
return service.Load();
|
||||
});
|
||||
|
||||
Assert.Equal(31, settings.FontSize);
|
||||
Assert.Equal(31, settings.Current.FontSize);
|
||||
Assert.Equal("ru", settings.Language);
|
||||
|
||||
// What was taken over is pinned to its new place at once rather than on the first edit
|
||||
@@ -331,7 +331,7 @@ public sealed class SettingsServiceTests
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
string inherited = folder.File("inherited.json");
|
||||
string original = """{"FontSize": 31}""";
|
||||
string original = """{"AtCursor": {"FontSize": 31}}""";
|
||||
|
||||
File.WriteAllText(inherited, original);
|
||||
|
||||
@@ -351,8 +351,8 @@ public sealed class SettingsServiceTests
|
||||
string own = folder.File("settings.json");
|
||||
string inherited = folder.File("inherited.json");
|
||||
|
||||
File.WriteAllText(own, """{"FontSize": 12}""");
|
||||
File.WriteAllText(inherited, """{"FontSize": 31}""");
|
||||
File.WriteAllText(own, """{"AtCursor": {"FontSize": 12}}""");
|
||||
File.WriteAllText(inherited, """{"AtCursor": {"FontSize": 31}}""");
|
||||
|
||||
AppSettings settings = Pump.Run(() =>
|
||||
{
|
||||
@@ -360,7 +360,7 @@ public sealed class SettingsServiceTests
|
||||
return service.Load();
|
||||
});
|
||||
|
||||
Assert.Equal(12, settings.FontSize);
|
||||
Assert.Equal(12, settings.Current.FontSize);
|
||||
}
|
||||
|
||||
// Outside a package both paths are the same, so there is nothing to take over
|
||||
@@ -392,14 +392,14 @@ public sealed class SettingsServiceTests
|
||||
return service.Load();
|
||||
});
|
||||
|
||||
Assert.Equal(20, settings.FontSize);
|
||||
Assert.Equal(20, settings.Current.FontSize);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Settings_with_unknown_fields_are_still_read()
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
File.WriteAllText(folder.File("settings.json"), """{"FontSize": 15, "SomethingNew": true}""");
|
||||
File.WriteAllText(folder.File("settings.json"), """{"AtCursor": {"FontSize": 15}, "SomethingNew": true}""");
|
||||
|
||||
AppSettings settings = Pump.Run(() =>
|
||||
{
|
||||
@@ -407,7 +407,7 @@ public sealed class SettingsServiceTests
|
||||
return service.Load();
|
||||
});
|
||||
|
||||
Assert.Equal(15, settings.FontSize);
|
||||
Assert.Equal(15, settings.Current.FontSize);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -417,7 +417,7 @@ public sealed class SettingsServiceTests
|
||||
public void A_colour_is_read_from_its_usual_notation(string stored, byte r, byte g, byte b)
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
File.WriteAllText(folder.File("settings.json"), $$"""{"BackgroundColor": {{stored}}}""");
|
||||
File.WriteAllText(folder.File("settings.json"), $$"""{"AtCursor": {"BackgroundColor": {{stored}} } }""");
|
||||
|
||||
AppSettings settings = Pump.Run(() =>
|
||||
{
|
||||
@@ -425,7 +425,7 @@ public sealed class SettingsServiceTests
|
||||
return service.Load();
|
||||
});
|
||||
|
||||
Assert.Equal(Color.FromArgb(r, g, b), settings.BackgroundColor);
|
||||
Assert.Equal(Color.FromArgb(r, g, b), settings.Current.BackgroundColor);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -435,7 +435,7 @@ public sealed class SettingsServiceTests
|
||||
public void An_unintelligible_colour_becomes_black(string stored)
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
File.WriteAllText(folder.File("settings.json"), $$"""{"BackgroundColor": {{stored}}}""");
|
||||
File.WriteAllText(folder.File("settings.json"), $$"""{"AtCursor": {"BackgroundColor": {{stored}} } }""");
|
||||
|
||||
AppSettings settings = Pump.Run(() =>
|
||||
{
|
||||
@@ -443,7 +443,7 @@ public sealed class SettingsServiceTests
|
||||
return service.Load();
|
||||
});
|
||||
|
||||
Assert.Equal(Color.Black, settings.BackgroundColor);
|
||||
Assert.Equal(Color.Black, settings.Current.BackgroundColor);
|
||||
}
|
||||
|
||||
// The service creates the settings folder itself
|
||||
@@ -478,7 +478,7 @@ public sealed class SettingsServiceTests
|
||||
using SettingsService service = new(path, folder.File("inherited.json"), SaveDelay);
|
||||
AppSettings settings = service.Load();
|
||||
|
||||
settings.FontSize = 18;
|
||||
settings.Current.FontSize = 18;
|
||||
service.Save();
|
||||
});
|
||||
|
||||
@@ -534,6 +534,107 @@ public sealed class SettingsServiceTests
|
||||
}
|
||||
}
|
||||
|
||||
// The modes are sections of their own, and every setting of a mode has to reach the
|
||||
// file inside its own section
|
||||
[Theory]
|
||||
[InlineData(nameof(AppSettings.AtCursor), typeof(CursorModeSettings))]
|
||||
[InlineData(nameof(AppSettings.AtCaret), typeof(CaretModeSettings))]
|
||||
[InlineData(nameof(AppSettings.FixedPoint), typeof(FixedPointModeSettings))]
|
||||
public void Every_setting_of_a_mode_reaches_its_section_of_the_file(string section, Type mode)
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
|
||||
Pump.Run(() =>
|
||||
{
|
||||
using SettingsService service = Create(folder);
|
||||
_ = service.Load();
|
||||
service.Save();
|
||||
});
|
||||
|
||||
using JsonDocument document = JsonDocument.Parse(File.ReadAllText(folder.File("settings.json")));
|
||||
|
||||
Assert.True(document.RootElement.TryGetProperty(section, out JsonElement stored));
|
||||
|
||||
foreach (string name in PopupModeSettingsTests.WritablePropertyNames(mode))
|
||||
{
|
||||
Assert.True(stored.TryGetProperty(name, out _), $"{section}.{name}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <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
|
||||
// of its own, a separate install keeps them in the user profile
|
||||
[Fact]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Drawing;
|
||||
using System.ComponentModel;
|
||||
using System.Text.Json.Serialization;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
@@ -8,11 +8,10 @@ namespace CursorLang.Core.Models;
|
||||
/// The application settings, as the settings window writes them to settings.json.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The colours are <see cref="System.Drawing.Color"/> rather than
|
||||
/// <c>System.Windows.Media.Color</c>. The former lives in System.Drawing.Primitives,
|
||||
/// which is part of the base runtime and brings neither WPF nor GDI+ along; the latter
|
||||
/// is WindowsBase, and this type is read by the agent, which must stay clear of it.
|
||||
/// The settings window turns them into brushes in its converters.
|
||||
/// Everything about the popup — the side, the offset, the font size, the opacity and
|
||||
/// both colours — belongs to a placement mode rather than to the application, and is
|
||||
/// kept in a <see cref="PopupModeSettings"/> of its own for each of them. So the
|
||||
/// settings of a mode are still there after a trip through the other two.
|
||||
///
|
||||
/// Both processes hold an instance of this, but only the settings window writes: the
|
||||
/// agent re-reads the file and pours the fresh values into the instance it already has,
|
||||
@@ -32,43 +31,6 @@ public sealed partial class AppSettings : ObservableObject
|
||||
[ObservableProperty]
|
||||
private PopupPlacementMode _placementMode = PopupPlacementMode.AtCursor;
|
||||
|
||||
// The side and the offset are stored per mode: the cursor and the caret call for
|
||||
// different settings, and switching the mode does not reset them
|
||||
|
||||
/// <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>
|
||||
[ObservableProperty]
|
||||
private double _durationMilliseconds = 500;
|
||||
@@ -87,13 +49,44 @@ public sealed partial class AppSettings : ObservableObject
|
||||
[ObservableProperty]
|
||||
private double _capsLockHoldMilliseconds = 300;
|
||||
|
||||
/// <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);
|
||||
public AppSettings()
|
||||
{
|
||||
AtCursor.PropertyChanged += OnModeChanged;
|
||||
AtCaret.PropertyChanged += OnModeChanged;
|
||||
FixedPoint.PropertyChanged += OnModeChanged;
|
||||
}
|
||||
|
||||
/// <summary>The colour of the layout name in the popup.</summary>
|
||||
[ObservableProperty]
|
||||
private Color _foregroundColor = Color.FromArgb(0xFF, 0xFF, 0xFF, 0xFF);
|
||||
/// <summary>The <see cref="PopupPlacementMode.AtCursor"/> mode.</summary>
|
||||
/// <remarks>
|
||||
/// 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>
|
||||
[JsonIgnore]
|
||||
@@ -110,25 +103,43 @@ public sealed partial class AppSettings : ObservableObject
|
||||
/// <remarks>
|
||||
/// This is how the agent learns about an edit: the settings window is a separate
|
||||
/// process, so the fresh values arrive as a freshly parsed instance and are poured
|
||||
/// into the one everything is already bound to, rather than replacing it.
|
||||
/// into the one everything is already bound to, rather than replacing it. The modes
|
||||
/// are filled the same way, and for the same reason.
|
||||
/// </remarks>
|
||||
public void CopyFrom(AppSettings other)
|
||||
{
|
||||
Language = other.Language;
|
||||
Theme = other.Theme;
|
||||
PlacementMode = other.PlacementMode;
|
||||
CursorSide = other.CursorSide;
|
||||
CursorOffset = other.CursorOffset;
|
||||
CaretSide = other.CaretSide;
|
||||
CaretOffset = other.CaretOffset;
|
||||
ScreenPosition = other.ScreenPosition;
|
||||
ScreenMargin = other.ScreenMargin;
|
||||
FontSize = other.FontSize;
|
||||
Opacity = other.Opacity;
|
||||
DurationMilliseconds = other.DurationMilliseconds;
|
||||
UseCapsLockHotkey = other.UseCapsLockHotkey;
|
||||
CapsLockHoldMilliseconds = other.CapsLockHoldMilliseconds;
|
||||
BackgroundColor = other.BackgroundColor;
|
||||
ForegroundColor = other.ForegroundColor;
|
||||
|
||||
AtCursor.CopyFrom(other.AtCursor);
|
||||
AtCaret.CopyFrom(other.AtCaret);
|
||||
FixedPoint.CopyFrom(other.FixedPoint);
|
||||
}
|
||||
|
||||
// The mode in force decides what the look means, so a switch of the mode is a
|
||||
// change of everything bound through Current
|
||||
partial void OnPlacementModeChanged(PopupPlacementMode value) => OnPropertyChanged(nameof(Current));
|
||||
|
||||
/// <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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ public enum PopupPlacementMode
|
||||
}
|
||||
|
||||
/// <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>
|
||||
public enum AnchorSide
|
||||
{
|
||||
@@ -31,6 +31,21 @@ public enum AnchorSide
|
||||
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>
|
||||
/// The place on the monitor for the <see cref="PopupPlacementMode.FixedPoint"/> mode.
|
||||
/// </summary>
|
||||
|
||||
@@ -121,6 +121,12 @@
|
||||
<data name="AnchorSide_Right" xml:space="preserve">
|
||||
<value>Right</value>
|
||||
</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">
|
||||
<value>Offset</value>
|
||||
</data>
|
||||
@@ -151,6 +157,9 @@
|
||||
<data name="ScreenMarginLabel" xml:space="preserve">
|
||||
<value>Margin from screen edge</value>
|
||||
</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">
|
||||
<value>Font size</value>
|
||||
</data>
|
||||
|
||||
@@ -121,6 +121,12 @@
|
||||
<data name="AnchorSide_Right" xml:space="preserve">
|
||||
<value>Справа</value>
|
||||
</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">
|
||||
<value>Отступ</value>
|
||||
</data>
|
||||
@@ -151,6 +157,9 @@
|
||||
<data name="ScreenMarginLabel" xml:space="preserve">
|
||||
<value>Отступ от края экрана</value>
|
||||
</data>
|
||||
<data name="PopupLookPerModeHint" xml:space="preserve">
|
||||
<value>Оформление ниже запоминается только для выбранного режима.</value>
|
||||
</data>
|
||||
<data name="FontSizeLabel" xml:space="preserve">
|
||||
<value>Размер шрифта</value>
|
||||
</data>
|
||||
|
||||
@@ -114,7 +114,7 @@ public sealed class ThemeServiceTests
|
||||
{
|
||||
using var service = new ThemeService(settings, static () => AppTheme.Dark);
|
||||
|
||||
settings.FontSize = 40;
|
||||
settings.Current.FontSize = 40;
|
||||
settings.Language = "ru";
|
||||
|
||||
Assert.Equal(AppTheme.Light, service.CurrentTheme);
|
||||
|
||||
@@ -43,7 +43,7 @@ public sealed class SettingsViewModelTests
|
||||
var localization = new FakeLocalizationService();
|
||||
using var viewModel = Create(settings, localization);
|
||||
|
||||
settings.FontSize = 30;
|
||||
settings.Current.FontSize = 30;
|
||||
|
||||
Assert.Equal("en", localization.CurrentLanguage);
|
||||
}
|
||||
@@ -59,6 +59,18 @@ public sealed class SettingsViewModelTests
|
||||
Assert.Equal(Enum.GetValues<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
|
||||
[Fact]
|
||||
public void The_option_captions_come_from_the_resources()
|
||||
@@ -85,6 +97,7 @@ public sealed class SettingsViewModelTests
|
||||
Assert.Same(first, viewModel.Themes[0]);
|
||||
Assert.Equal("ru:AppTheme_System", first.Display);
|
||||
Assert.Equal("ru:AnchorSide_TopLeft", viewModel.AnchorSides[0].Display);
|
||||
Assert.Equal("ru:CaretSide_Left", viewModel.CaretSides[0].Display);
|
||||
Assert.Equal("ru:ScreenPosition_TopLeft", viewModel.ScreenPositions[0].Display);
|
||||
Assert.Equal("ru:PopupPlacementMode_AtCursor", viewModel.PlacementModes[0].Display);
|
||||
}
|
||||
@@ -114,8 +127,8 @@ public sealed class SettingsViewModelTests
|
||||
var settings = new AppSettings();
|
||||
using var viewModel = Create(settings);
|
||||
|
||||
Assert.Contains(settings.BackgroundColor, viewModel.BackgroundPalette);
|
||||
Assert.Contains(settings.ForegroundColor, viewModel.TextPalette);
|
||||
Assert.Contains(settings.Current.BackgroundColor, viewModel.BackgroundPalette);
|
||||
Assert.Contains(settings.Current.ForegroundColor, viewModel.TextPalette);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -124,7 +124,8 @@ public sealed class MainWindowTests
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var settings = new AppSettings { FontSize = 33 };
|
||||
var settings = new AppSettings();
|
||||
settings.Current.FontSize = 33;
|
||||
using SettingsViewModel viewModel = CreateViewModel(settings);
|
||||
|
||||
Open(viewModel, window =>
|
||||
@@ -133,6 +134,178 @@ public sealed class MainWindowTests
|
||||
});
|
||||
}
|
||||
|
||||
/// <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]
|
||||
public void The_tooltip_colours_are_shown_as_swatches()
|
||||
{
|
||||
@@ -144,7 +317,7 @@ public sealed class MainWindowTests
|
||||
// The chosen colour is shown as a swatch with a caption — in the
|
||||
// same notation the settings file uses
|
||||
System.Drawing.Color chosen = viewModel.BackgroundPalette[2];
|
||||
settings.BackgroundColor = chosen;
|
||||
settings.Current.BackgroundColor = chosen;
|
||||
|
||||
string expected = $"#{chosen.R:X2}{chosen.G:X2}{chosen.B:X2}";
|
||||
|
||||
@@ -359,6 +532,13 @@ public sealed class MainWindowTests
|
||||
FindAll<Button>(root).Where(button =>
|
||||
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) =>
|
||||
FindCheckBoxesBoundTo(root, nameof(SettingsViewModel.RunAtStartup));
|
||||
|
||||
|
||||
@@ -337,6 +337,11 @@
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
<Style.Triggers>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter Property="Opacity" Value="0.5" />
|
||||
</Trigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
|
||||
<!-- ======================= ProgressBar ======================= -->
|
||||
|
||||
@@ -87,6 +87,7 @@ public sealed partial class SettingsViewModel : ObservableObject, IDisposable
|
||||
Themes = CreateOptions<AppTheme>();
|
||||
PlacementModes = CreateOptions<PopupPlacementMode>();
|
||||
AnchorSides = CreateOptions<AnchorSide>();
|
||||
CaretSides = CreateOptions<CaretSide>();
|
||||
ScreenPositions = CreateOptions<ScreenPosition>();
|
||||
}
|
||||
|
||||
@@ -107,6 +108,9 @@ public sealed partial class SettingsViewModel : ObservableObject, IDisposable
|
||||
|
||||
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; }
|
||||
|
||||
/// <summary>Whether to show the startup setting.</summary>
|
||||
@@ -187,6 +191,7 @@ public sealed partial class SettingsViewModel : ObservableObject, IDisposable
|
||||
Translate(Themes);
|
||||
Translate(PlacementModes);
|
||||
Translate(AnchorSides);
|
||||
Translate(CaretSides);
|
||||
Translate(ScreenPositions);
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,11 @@
|
||||
<!-- 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 -->
|
||||
<Setter Property="TextWrapping" Value="Wrap" />
|
||||
<Style.Triggers>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter Property="Opacity" Value="0.5" />
|
||||
</Trigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
<Style TargetType="TextBlock" x:Key="FieldValue">
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
@@ -46,6 +51,11 @@
|
||||
<Setter Property="MinWidth" Value="48" />
|
||||
<Setter Property="TextAlignment" Value="Right" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource Theme.SecondaryForeground}" />
|
||||
<Style.Triggers>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter Property="Opacity" Value="0.5" />
|
||||
</Trigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Window.Resources>
|
||||
|
||||
@@ -282,8 +292,24 @@
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock Style="{StaticResource FieldLabel}"
|
||||
Text="{Binding Localization[PlacementModeLabel]}" />
|
||||
<TextBlock Style="{StaticResource FieldLabel}">
|
||||
<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"
|
||||
ItemsSource="{Binding PlacementModes}"
|
||||
ItemTemplate="{StaticResource EnumOptionTemplate}"
|
||||
@@ -301,7 +327,7 @@
|
||||
ItemsSource="{Binding AnchorSides}"
|
||||
ItemTemplate="{StaticResource EnumOptionTemplate}"
|
||||
SelectedValuePath="Value"
|
||||
SelectedValue="{Binding Settings.CursorSide}"
|
||||
SelectedValue="{Binding Settings.AtCursor.Side}"
|
||||
Visibility="{Binding Settings.PlacementMode,
|
||||
Converter={StaticResource EnumToVisibility},
|
||||
ConverterParameter=AtCursor}" />
|
||||
@@ -314,13 +340,13 @@
|
||||
ConverterParameter=AtCursor}" />
|
||||
<Slider Grid.Row="2" Grid.Column="1" Margin="0,12,0,0"
|
||||
Minimum="0" Maximum="80" TickFrequency="1"
|
||||
Value="{Binding Settings.CursorOffset}"
|
||||
Value="{Binding Settings.AtCursor.Offset}"
|
||||
Visibility="{Binding Settings.PlacementMode,
|
||||
Converter={StaticResource EnumToVisibility},
|
||||
ConverterParameter=AtCursor}" />
|
||||
<TextBlock Grid.Row="2" Grid.Column="2" Margin="12,12,0,0"
|
||||
Style="{StaticResource FieldValue}"
|
||||
Text="{Binding Settings.CursorOffset, StringFormat={}{0:F0}}"
|
||||
Text="{Binding Settings.AtCursor.Offset, StringFormat={}{0:F0}}"
|
||||
Visibility="{Binding Settings.PlacementMode,
|
||||
Converter={StaticResource EnumToVisibility},
|
||||
ConverterParameter=AtCursor}" />
|
||||
@@ -333,10 +359,10 @@
|
||||
Converter={StaticResource EnumToVisibility},
|
||||
ConverterParameter=AtCaret}" />
|
||||
<ComboBox Grid.Row="3" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,12,0,0"
|
||||
ItemsSource="{Binding AnchorSides}"
|
||||
ItemsSource="{Binding CaretSides}"
|
||||
ItemTemplate="{StaticResource EnumOptionTemplate}"
|
||||
SelectedValuePath="Value"
|
||||
SelectedValue="{Binding Settings.CaretSide}"
|
||||
SelectedValue="{Binding Settings.AtCaret.Side}"
|
||||
Visibility="{Binding Settings.PlacementMode,
|
||||
Converter={StaticResource EnumToVisibility},
|
||||
ConverterParameter=AtCaret}" />
|
||||
@@ -349,13 +375,13 @@
|
||||
ConverterParameter=AtCaret}" />
|
||||
<Slider Grid.Row="4" Grid.Column="1" Margin="0,12,0,0"
|
||||
Minimum="0" Maximum="80" TickFrequency="1"
|
||||
Value="{Binding Settings.CaretOffset}"
|
||||
Value="{Binding Settings.AtCaret.Offset}"
|
||||
Visibility="{Binding Settings.PlacementMode,
|
||||
Converter={StaticResource EnumToVisibility},
|
||||
ConverterParameter=AtCaret}" />
|
||||
<TextBlock Grid.Row="4" Grid.Column="2" Margin="12,12,0,0"
|
||||
Style="{StaticResource FieldValue}"
|
||||
Text="{Binding Settings.CaretOffset, StringFormat={}{0:F0}}"
|
||||
Text="{Binding Settings.AtCaret.Offset, StringFormat={}{0:F0}}"
|
||||
Visibility="{Binding Settings.PlacementMode,
|
||||
Converter={StaticResource EnumToVisibility},
|
||||
ConverterParameter=AtCaret}" />
|
||||
@@ -371,7 +397,7 @@
|
||||
ItemsSource="{Binding ScreenPositions}"
|
||||
ItemTemplate="{StaticResource EnumOptionTemplate}"
|
||||
SelectedValuePath="Value"
|
||||
SelectedValue="{Binding Settings.ScreenPosition}"
|
||||
SelectedValue="{Binding Settings.FixedPoint.Position}"
|
||||
Visibility="{Binding Settings.PlacementMode,
|
||||
Converter={StaticResource EnumToVisibility},
|
||||
ConverterParameter=FixedPoint}" />
|
||||
@@ -379,18 +405,21 @@
|
||||
<TextBlock Grid.Row="6" Margin="0,12,12,0"
|
||||
Style="{StaticResource FieldLabel}"
|
||||
Text="{Binding Localization[ScreenMarginLabel]}"
|
||||
IsEnabled="{Binding Settings.FixedPoint.IsAtAnEdge}"
|
||||
Visibility="{Binding Settings.PlacementMode,
|
||||
Converter={StaticResource EnumToVisibility},
|
||||
ConverterParameter=FixedPoint}" />
|
||||
<Slider Grid.Row="6" Grid.Column="1" Margin="0,12,0,0"
|
||||
Minimum="0" Maximum="200" TickFrequency="1"
|
||||
Value="{Binding Settings.ScreenMargin}"
|
||||
Value="{Binding Settings.FixedPoint.Offset}"
|
||||
IsEnabled="{Binding Settings.FixedPoint.IsAtAnEdge}"
|
||||
Visibility="{Binding Settings.PlacementMode,
|
||||
Converter={StaticResource EnumToVisibility},
|
||||
ConverterParameter=FixedPoint}" />
|
||||
<TextBlock Grid.Row="6" Grid.Column="2" Margin="12,12,0,0"
|
||||
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,
|
||||
Converter={StaticResource EnumToVisibility},
|
||||
ConverterParameter=FixedPoint}" />
|
||||
@@ -406,20 +435,20 @@
|
||||
Text="{Binding Localization[FontSizeLabel]}" />
|
||||
<Slider Grid.Row="8" Grid.Column="1" Margin="0,12,0,0"
|
||||
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"
|
||||
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"
|
||||
Style="{StaticResource FieldLabel}"
|
||||
Text="{Binding Localization[OpacityLabel]}" />
|
||||
<Slider Grid.Row="9" Grid.Column="1" Margin="0,12,0,0"
|
||||
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"
|
||||
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"
|
||||
Style="{StaticResource FieldLabel}"
|
||||
@@ -427,7 +456,7 @@
|
||||
<ComboBox Grid.Row="10" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,12,0,0"
|
||||
ItemsSource="{Binding BackgroundPalette}"
|
||||
ItemTemplate="{StaticResource ColorSwatchTemplate}"
|
||||
SelectedItem="{Binding Settings.BackgroundColor}" />
|
||||
SelectedItem="{Binding Settings.Current.BackgroundColor}" />
|
||||
|
||||
<TextBlock Grid.Row="11" Margin="0,12,12,0"
|
||||
Style="{StaticResource FieldLabel}"
|
||||
@@ -435,14 +464,11 @@
|
||||
<ComboBox Grid.Row="11" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,12,0,0"
|
||||
ItemsSource="{Binding TextPalette}"
|
||||
ItemTemplate="{StaticResource ColorSwatchTemplate}"
|
||||
SelectedItem="{Binding Settings.ForegroundColor}" />
|
||||
SelectedItem="{Binding Settings.Current.ForegroundColor}" />
|
||||
|
||||
<TextBlock Grid.Row="12" Margin="0,12,12,0"
|
||||
Style="{StaticResource FieldLabel}"
|
||||
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"
|
||||
Margin="0,12,0,0" Padding="16"
|
||||
Height="152" ClipToBounds="True"
|
||||
@@ -450,12 +476,12 @@
|
||||
HorizontalAlignment="Stretch">
|
||||
<Border CornerRadius="4" Padding="10,4"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
Opacity="{Binding Settings.Opacity}"
|
||||
Background="{Binding Settings.BackgroundColor,
|
||||
Opacity="{Binding Settings.Current.Opacity}"
|
||||
Background="{Binding Settings.Current.BackgroundColor,
|
||||
Converter={StaticResource ColorToBrush}}">
|
||||
<TextBlock Text="RU" FontWeight="SemiBold"
|
||||
FontSize="{Binding Settings.FontSize}"
|
||||
Foreground="{Binding Settings.ForegroundColor,
|
||||
FontSize="{Binding Settings.Current.FontSize}"
|
||||
Foreground="{Binding Settings.Current.ForegroundColor,
|
||||
Converter={StaticResource ColorToBrush}}" />
|
||||
</Border>
|
||||
</Border>
|
||||
|
||||
@@ -143,6 +143,28 @@ MSIX всегда выполняются в контексте вошедшег
|
||||
быть установлены рядом, и приложение не вправе удалять настройки, которые ему не
|
||||
принадлежат.
|
||||
|
||||
Всё, что относится к всплывающей подсказке, принадлежит режиму размещения, а не
|
||||
приложению, и файл хранит по разделу на каждый режим — `AtCursor`, `AtCaret` и
|
||||
`FixedPoint`. В каждом лежат сторона, отступ, размер шрифта, прозрачность и оба
|
||||
цвета. Подсказка рядом с курсором ввода находится внутри читаемого текста, и её
|
||||
хотят видеть небольшой и незаметной; ту, что в углу монитора, ищут намеренно, и её
|
||||
хотят видеть крупной. Общее для всех режимов оформление приходилось настраивать
|
||||
заново после каждого переключения, поэтому окно настроек показывает оформление
|
||||
выбранного выше режима и записывает только в него.
|
||||
|
||||
Сторона означает в каждом режиме своё, и у каждого для неё собственный тип. Рядом с
|
||||
курсором мыши это любой из шести углов и сторон. Рядом с курсором ввода — только
|
||||
слева или справа: сверху и снизу от каретки находится следующая строка текста, и
|
||||
подсказка закрывала бы читаемое. Для фиксированной точки это место на мониторе, а
|
||||
отступ — расстояние от его края: в середине края нет, поэтому выбор середины
|
||||
сбрасывает отступ в нуль, а окно настроек показывает его неактивным. Отступ остаётся
|
||||
на месте, а не исчезает из раздела — появляющаяся и исчезающая строка сдвигала бы
|
||||
всё, что ниже, при каждой смене места.
|
||||
|
||||
К какому режиму относится оформление, объясняет всплывающая подсказка рядом с самим
|
||||
режимом, а не строка текста в разделе: ползунки ниже показывают другие числа после
|
||||
переключения режима, и это вопрос, который задают один раз.
|
||||
|
||||
## Обновления
|
||||
|
||||
Проверка выполняется при открытии окна настроек, а не при включении машины:
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
The check runs when the settings window is opened, not when the machine is
|
||||
|
||||
Reference in New Issue
Block a user