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
+71 -60
View File
@@ -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);
}
+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>
/// 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>