From 619af37acc796c3b915d1286140aabe60089ec0d Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:39:59 +0200 Subject: [PATCH 01/12] Make settings snapshots read-only and unify transaction persistence Addresses #121 and #122. Signed-off-by: KeyffMS <124252104+KeyffMS@users.noreply.github.com> --- src/SightAdapt/SettingsCoordinator.cs | 53 ++++++++++++++------------- 1 file changed, 28 insertions(+), 25 deletions(-) diff --git a/src/SightAdapt/SettingsCoordinator.cs b/src/SightAdapt/SettingsCoordinator.cs index cd618622..741b2ff7 100644 --- a/src/SightAdapt/SettingsCoordinator.cs +++ b/src/SightAdapt/SettingsCoordinator.cs @@ -65,7 +65,7 @@ public SettingsCoordinator( _current = _store.Load(); } - public SightAdaptSettings Current => + public IReadOnlySightAdaptSettings Current => _current.CreateWorkingCopy(); public string SettingsPath => _store.SettingsPath; @@ -98,13 +98,36 @@ public SettingsCommitResult Commit( Func mutation) { ArgumentNullException.ThrowIfNull(mutation); + return ExecuteTransaction( + mutation, + publishChanged: true); + } + + public SettingsCommitResult PersistCurrent() + { + var result = ExecuteTransaction( + _ => null, + publishChanged: false); + + return result.Succeeded + ? SettingsCommitResult.Success() + : SettingsCommitResult.Failure( + result.ErrorMessage ?? + "Settings could not be saved."); + } + + private SettingsCommitResult ExecuteTransaction( + Func operation, + bool publishChanged) + { + ArgumentNullException.ThrowIfNull(operation); var candidate = _current.CreateWorkingCopy(); T value; try { - value = mutation(candidate); + value = operation(candidate); _store.Save(candidate); } catch (Exception exception) @@ -120,32 +143,12 @@ public SettingsCommitResult Commit( } _current.ReplaceWith(candidate); - Changed?.Invoke(this, EventArgs.Empty); - return SettingsCommitResult.Success(value); - } - - public SettingsCommitResult PersistCurrent() - { - var candidate = _current.CreateWorkingCopy(); - - try - { - _store.Save(candidate); - } - catch (Exception exception) - when (IsExpectedError(exception)) + if (publishChanged) { - return SettingsCommitResult.Failure( - FormatError(exception)); - } - catch (Exception exception) - { - _reportUnexpectedError(exception); - throw; + Changed?.Invoke(this, EventArgs.Empty); } - _current.ReplaceWith(candidate); - return SettingsCommitResult.Success(); + return SettingsCommitResult.Success(value); } private static bool IsExpectedError( From 6e1ac674f4e742cc16463940ee57918d1bcc19d9 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:40:11 +0200 Subject: [PATCH 02/12] Share settings transaction plumbing across use cases Addresses #122. Signed-off-by: KeyffMS <124252104+KeyffMS@users.noreply.github.com> --- src/SightAdapt/SettingsUseCaseContext.cs | 33 ++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 src/SightAdapt/SettingsUseCaseContext.cs diff --git a/src/SightAdapt/SettingsUseCaseContext.cs b/src/SightAdapt/SettingsUseCaseContext.cs new file mode 100644 index 00000000..3a414a2c --- /dev/null +++ b/src/SightAdapt/SettingsUseCaseContext.cs @@ -0,0 +1,33 @@ +namespace SightAdapt; + +internal sealed class SettingsUseCaseContext +{ + private readonly SettingsCoordinator _coordinator; + + public SettingsUseCaseContext( + SettingsCoordinator coordinator) + { + _coordinator = coordinator ?? + throw new ArgumentNullException(nameof(coordinator)); + } + + public IReadOnlySightAdaptSettings Snapshot => + _coordinator.Current; + + public string SettingsPath => + _coordinator.SettingsPath; + + public event EventHandler? Changed + { + add => _coordinator.Changed += value; + remove => _coordinator.Changed -= value; + } + + public SettingsCommitResult Commit( + Action mutation) => + _coordinator.Commit(mutation); + + public SettingsCommitResult Commit( + Func mutation) => + _coordinator.Commit(mutation); +} From c4bc8b3edbe4ad1f9c11c62169f012375d203fff Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:40:38 +0200 Subject: [PATCH 03/12] Refactor shared settings plumbing in src/SightAdapt/ConfigurationUseCases.cs Addresses #121 and #122. Signed-off-by: KeyffMS <124252104+KeyffMS@users.noreply.github.com> --- src/SightAdapt/ConfigurationUseCases.cs | 36 +++++++++++++++---------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/src/SightAdapt/ConfigurationUseCases.cs b/src/SightAdapt/ConfigurationUseCases.cs index 6ca4b2a0..b8de5bdf 100644 --- a/src/SightAdapt/ConfigurationUseCases.cs +++ b/src/SightAdapt/ConfigurationUseCases.cs @@ -2,31 +2,39 @@ namespace SightAdapt; internal sealed class ConfigurationUseCases { - private readonly SettingsCoordinator _settingsCoordinator; + private readonly SettingsUseCaseContext _settings; public ConfigurationUseCases( SettingsCoordinator settingsCoordinator) - { - _settingsCoordinator = settingsCoordinator ?? + : this(new SettingsUseCaseContext( + settingsCoordinator ?? throw new ArgumentNullException( - nameof(settingsCoordinator)); + nameof(settingsCoordinator)))) + { + } + + internal ConfigurationUseCases( + SettingsUseCaseContext settings) + { + _settings = settings ?? + throw new ArgumentNullException(nameof(settings)); } - public SightAdaptSettings Snapshot => - _settingsCoordinator.Current; + public IReadOnlySightAdaptSettings Snapshot => + _settings.Snapshot; public string SettingsPath => - _settingsCoordinator.SettingsPath; + _settings.SettingsPath; public event EventHandler? Changed { - add => _settingsCoordinator.Changed += value; - remove => _settingsCoordinator.Changed -= value; + add => _settings.Changed += value; + remove => _settings.Changed -= value; } public SettingsCommitResult SetAutomaticMode(bool enabled) { - return _settingsCoordinator.Commit(settings => + return _settings.Commit(settings => { AutomaticModeManagementService.Set( settings, @@ -41,7 +49,7 @@ public SettingsCommitResult Apply( ArgumentException.ThrowIfNullOrWhiteSpace( change.ExecutablePath); - return _settingsCoordinator.Commit(settings => + return _settings.Commit(settings => { var assignment = ProfileResolver.RequireAssignmentByExecutablePath( @@ -85,7 +93,7 @@ public SettingsCommitResult AddOrEnable( { ArgumentNullException.ThrowIfNull(identity); - return _settingsCoordinator.Commit(settings => + return _settings.Commit(settings => { var result = ApplicationAssignmentService.AddOrEnable( @@ -100,7 +108,7 @@ public SettingsCommitResult Remove(string executablePath) { ArgumentException.ThrowIfNullOrWhiteSpace(executablePath); - return _settingsCoordinator.Commit(settings => + return _settings.Commit(settings => ApplicationAssignmentService.Remove( settings, ProfileResolver.RequireAssignmentByExecutablePath( @@ -115,7 +123,7 @@ public SettingsCommitResult UpdateTuning( ArgumentException.ThrowIfNullOrWhiteSpace(profileId); ArgumentNullException.ThrowIfNull(values); - return _settingsCoordinator.Commit(settings => + return _settings.Commit(settings => VisualProfileManagementService.UpdateTuning( settings, ProfileResolver.RequireVisualProfile( From 0216c56bb42bf5e0a34f157875b5839beb154738 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:40:42 +0200 Subject: [PATCH 04/12] Refactor shared settings plumbing in src/SightAdapt/VisualProfileUseCases.cs Addresses #121 and #122. Signed-off-by: KeyffMS <124252104+KeyffMS@users.noreply.github.com> --- src/SightAdapt/VisualProfileUseCases.cs | 34 +++++++++++++++---------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/src/SightAdapt/VisualProfileUseCases.cs b/src/SightAdapt/VisualProfileUseCases.cs index 4e13c9e1..da1dba52 100644 --- a/src/SightAdapt/VisualProfileUseCases.cs +++ b/src/SightAdapt/VisualProfileUseCases.cs @@ -2,23 +2,31 @@ namespace SightAdapt; internal sealed class VisualProfileUseCases { - private readonly SettingsCoordinator _settingsCoordinator; + private readonly SettingsUseCaseContext _settings; public VisualProfileUseCases( SettingsCoordinator settingsCoordinator) - { - _settingsCoordinator = settingsCoordinator ?? + : this(new SettingsUseCaseContext( + settingsCoordinator ?? throw new ArgumentNullException( - nameof(settingsCoordinator)); + nameof(settingsCoordinator)))) + { + } + + internal VisualProfileUseCases( + SettingsUseCaseContext settings) + { + _settings = settings ?? + throw new ArgumentNullException(nameof(settings)); } - public SightAdaptSettings Snapshot => - _settingsCoordinator.Current; + public IReadOnlySightAdaptSettings Snapshot => + _settings.Snapshot; public event EventHandler? Changed { - add => _settingsCoordinator.Changed += value; - remove => _settingsCoordinator.Changed -= value; + add => _settings.Changed += value; + remove => _settings.Changed -= value; } public string CreateAvailableName(string baseName) @@ -31,7 +39,7 @@ public string CreateAvailableName(string baseName) public SettingsCommitResult Create(string name) { - return _settingsCoordinator.Commit(settings => + return _settings.Commit(settings => VisualProfileManagementService.Create( settings, name).Id); @@ -43,7 +51,7 @@ public SettingsCommitResult Duplicate( { ArgumentException.ThrowIfNullOrWhiteSpace(sourceProfileId); - return _settingsCoordinator.Commit(settings => + return _settings.Commit(settings => VisualProfileManagementService.Duplicate( settings, ProfileResolver.RequireVisualProfile( @@ -58,7 +66,7 @@ public SettingsCommitResult Rename( { ArgumentException.ThrowIfNullOrWhiteSpace(profileId); - return _settingsCoordinator.Commit(settings => + return _settings.Commit(settings => { VisualProfileManagementService.Rename( settings, @@ -77,7 +85,7 @@ public SettingsCommitResult UpdateTuning( ArgumentException.ThrowIfNullOrWhiteSpace(profileId); ArgumentNullException.ThrowIfNull(values); - return _settingsCoordinator.Commit(settings => + return _settings.Commit(settings => { VisualProfileManagementService.UpdateTuning( settings, @@ -97,7 +105,7 @@ public SettingsCommitResult Delete( ArgumentException.ThrowIfNullOrWhiteSpace( fallbackProfileId); - return _settingsCoordinator.Commit(settings => + return _settings.Commit(settings => { VisualProfileManagementService.Delete( settings, From 03370d9dfdd66629ba5c8b7608b891f569f372c5 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:40:56 +0200 Subject: [PATCH 05/12] Use read-only settings snapshots in runtime reads Addresses #121. Signed-off-by: KeyffMS <124252104+KeyffMS@users.noreply.github.com> --- src/SightAdapt/RuntimeCoordinator.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/SightAdapt/RuntimeCoordinator.cs b/src/SightAdapt/RuntimeCoordinator.cs index 4b692230..4d4c25ba 100644 --- a/src/SightAdapt/RuntimeCoordinator.cs +++ b/src/SightAdapt/RuntimeCoordinator.cs @@ -12,7 +12,7 @@ internal sealed class RuntimeCoordinator private readonly IRuntimeEnvironment _environment; private readonly RuntimeOverlayActivator _overlayActivator; private readonly AutomaticActivationService _automaticActivation; - private readonly Func _readSettings; + private readonly Func _readSettings; private bool _committingSettings; public RuntimeCoordinator( @@ -43,7 +43,7 @@ internal RuntimeCoordinator( ApplicationStateController stateController, IRuntimeOverlay overlay, IRuntimeEnvironment environment, - Func? readSettings) + Func? readSettings) { _settingsCoordinator = settingsCoordinator ?? throw new ArgumentNullException(nameof(settingsCoordinator)); @@ -257,7 +257,7 @@ public void DisableForExit() _overlayActivator.DisableForExit(); } - private SightAdaptSettings ReadSettings() + private IReadOnlySightAdaptSettings ReadSettings() { return _readSettings(); } From 07369d5b0639aec1118131756c87a44391ae2649 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:41:14 +0200 Subject: [PATCH 06/12] Test read-only snapshots and shared persistence behavior Addresses #121 and #122. Signed-off-by: KeyffMS <124252104+KeyffMS@users.noreply.github.com> --- .../SettingsCoordinatorTests.cs | 45 ++++++++++++++++--- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/tests/SightAdapt.Tests/SettingsCoordinatorTests.cs b/tests/SightAdapt.Tests/SettingsCoordinatorTests.cs index cb6c295b..49ff5b62 100644 --- a/tests/SightAdapt.Tests/SettingsCoordinatorTests.cs +++ b/tests/SightAdapt.Tests/SettingsCoordinatorTests.cs @@ -60,11 +60,14 @@ public void CurrentReturnsDefensiveSnapshot() var changedEvents = 0; coordinator.Changed += (_, _) => changedEvents++; - var snapshot = coordinator.Current; - snapshot.AutomaticMode = false; - snapshot.Assignments[0].Enabled = false; - snapshot.Assignments.Clear(); - snapshot.VisualProfiles.Clear(); + IReadOnlySightAdaptSettings snapshot = + coordinator.Current; + var mutableSnapshot = + (SightAdaptSettings)snapshot; + mutableSnapshot.AutomaticMode = false; + mutableSnapshot.Assignments[0].Enabled = false; + mutableSnapshot.Assignments.Clear(); + mutableSnapshot.VisualProfiles.Clear(); var current = coordinator.Current; Assert.IsTrue(current.AutomaticMode); @@ -74,6 +77,38 @@ public void CurrentReturnsDefensiveSnapshot() Assert.AreEqual(0, changedEvents); } + + [TestMethod] + public void CurrentExposesReadOnlySnapshotContract() + { + var property = typeof(SettingsCoordinator) + .GetProperty(nameof(SettingsCoordinator.Current)); + + Assert.IsNotNull(property); + Assert.AreEqual( + typeof(IReadOnlySightAdaptSettings), + property.PropertyType); + } + + [TestMethod] + public void PersistCurrentDoesNotPublishChangedEvent() + { + using var temporaryDirectory = + new TestWorkspace(); + var coordinator = + new SettingsCoordinator( + new SettingsStore(Path.Combine( + temporaryDirectory.Path, + "settings.json"))); + var changedEvents = 0; + coordinator.Changed += (_, _) => changedEvents++; + + var result = coordinator.PersistCurrent(); + + Assert.IsTrue(result.Succeeded); + Assert.AreEqual(0, changedEvents); + } + [TestMethod] public void FailedPersistenceDoesNotPublishCandidateState() { From 4629d41e1b50d40622073e796fde3e6abd4bc393 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:41:31 +0200 Subject: [PATCH 07/12] Document read-only settings and shared transaction plumbing Addresses #121 and #122. Signed-off-by: KeyffMS <124252104+KeyffMS@users.noreply.github.com> --- docs/ARCHITECTURE.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2b61e3b7..cf5a9df3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -40,9 +40,13 @@ MagnifierOverlay ```text SettingsCoordinator.Current +(read-only snapshot contract) ↓ CreateWorkingCopy ↓ +SettingsUseCaseContext +(shared snapshot/event/commit plumbing) + ↓ Domain-service mutation ↓ SettingsNormalizer.Normalize @@ -57,13 +61,14 @@ Current.ReplaceWith one synchronous Changed event ``` -A failed mutation or failed write does not replace committed settings and does not publish a settings change. `SettingsCoordinator.Current` returns a defensive snapshot, so consumers cannot mutate the committed in-memory object outside a transaction. +A failed mutation or failed write does not replace committed settings and does not publish a settings change. `SettingsCoordinator.Current` exposes `IReadOnlySightAdaptSettings` and returns a defensive snapshot. Mutable `SightAdaptSettings` is confined to explicit transaction callbacks and normalization/persistence boundaries. `SettingsUseCaseContext` centralizes snapshot, change-event and commit plumbing for application use-case façades without sharing their domain commands. `Commit` and `PersistCurrent` use one persistence execution path; only `Commit` publishes the synchronous `Changed` event after a successful write. ## Authorities | Concern | Authority | |---|---| -| Settings transaction and published snapshots | `SettingsCoordinator` | +| Settings transaction and published read-only snapshots | `SettingsCoordinator` | +| Shared use-case snapshot/event/commit plumbing | `SettingsUseCaseContext` | | Settings JSON persistence and atomic replacement | `SettingsStore` | | Persisted JSON DTOs and legacy-field migration | `PersistedSettingsMapper` | | Schema, profile, assignment and reference normalization | explicit `ISettingsNormalizationPass` implementations | From 2c6af1ef74d1f75b667310b28696af0ee332dd0b Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:45:21 +0200 Subject: [PATCH 08/12] Make visual profile definitions own canonical tuning Addresses #114. Signed-off-by: KeyffMS <124252104+KeyffMS@users.noreply.github.com> --- src/SightAdapt/VisualProfileCatalog.cs | 87 +++++++++++++++++++------- 1 file changed, 63 insertions(+), 24 deletions(-) diff --git a/src/SightAdapt/VisualProfileCatalog.cs b/src/SightAdapt/VisualProfileCatalog.cs index afaad57b..4700f84d 100644 --- a/src/SightAdapt/VisualProfileCatalog.cs +++ b/src/SightAdapt/VisualProfileCatalog.cs @@ -11,6 +11,7 @@ public VisualProfileDefinition( string displayName, bool supportsTuning, IVisualTransform transform, + VisualProfileTuning canonicalTuning, Func normalizeTuning) { ArgumentException.ThrowIfNullOrWhiteSpace(profileId); @@ -24,6 +25,7 @@ public VisualProfileDefinition( DisplayName = displayName; SupportsTuning = supportsTuning; Transform = transform; + CanonicalTuning = canonicalTuning; _normalizeTuning = normalizeTuning; } @@ -37,6 +39,8 @@ public VisualProfileDefinition( public IVisualTransform Transform { get; } + public VisualProfileTuning CanonicalTuning { get; } + public VisualProfile CreateBuiltInProfile() { var profile = new VisualProfile @@ -45,9 +49,7 @@ public VisualProfile CreateBuiltInProfile() Name = DisplayName, TransformId = TransformId, }; - VisualProfileDefaults.ApplyTuning( - profile, - _normalizeTuning(profile)); + ResetTuning(profile); return profile; } @@ -72,10 +74,14 @@ public bool CanonicalizeBuiltInProfile( profile.Id = ProfileId; profile.Name = DisplayName; profile.TransformId = TransformId; - return VisualProfileDefaults.ApplyTuningIfChanged( - profile, - _normalizeTuning(profile)) || - changed; + return NormalizeTuning(profile) || changed; + } + + public VisualProfileTuning GetNormalizedTuning( + VisualProfile profile) + { + ArgumentNullException.ThrowIfNull(profile); + return _normalizeTuning(profile); } public bool NormalizeTuning( @@ -84,7 +90,16 @@ public bool NormalizeTuning( ArgumentNullException.ThrowIfNull(profile); return VisualProfileDefaults.ApplyTuningIfChanged( profile, - _normalizeTuning(profile)); + GetNormalizedTuning(profile)); + } + + public void ResetTuning( + VisualProfile profile) + { + ArgumentNullException.ThrowIfNull(profile); + VisualProfileDefaults.ApplyTuning( + profile, + CanonicalTuning); } } @@ -105,6 +120,7 @@ private static readonly VisualProfileDefinition[] "Exact invert", supportsTuning: false, new InvertVisualTransform(), + VisualProfileDefaults.ExactInvertTuning, _ => VisualProfileDefaults.ExactInvertTuning), new( DefaultSoftInvertId, @@ -112,6 +128,7 @@ private static readonly VisualProfileDefinition[] "Soft invert", supportsTuning: true, new SoftInvertVisualTransform(), + VisualProfileDefaults.SoftInvertTuning, VisualProfileDefaults.NormalizeSoftInvertTuning), new( DefaultNoneId, @@ -119,13 +136,13 @@ private static readonly VisualProfileDefinition[] "None", supportsTuning: false, new NoneVisualTransform(), + VisualProfileDefaults.ExactInvertTuning, _ => VisualProfileDefaults.ExactInvertTuning), ]; - private static readonly IReadOnlyList - ReadOnlyDefinitions = Array.AsReadOnly( - CanonicalDefinitions); - + private readonly VisualProfileDefinition[] _definitions; + private readonly IReadOnlyList + _readOnlyDefinitions; private readonly IReadOnlyDictionary< string, VisualProfileDefinition> _definitionsByProfileId; @@ -134,25 +151,43 @@ private readonly IReadOnlyDictionary< VisualProfileDefinition> _definitionsByTransformId; private VisualProfileCatalog() + : this(CanonicalDefinitions) { + } + + internal VisualProfileCatalog( + IEnumerable definitions) + { + ArgumentNullException.ThrowIfNull(definitions); + + _definitions = definitions.ToArray(); + if (_definitions.Length == 0) + { + throw new ArgumentException( + "At least one visual profile definition is required.", + nameof(definitions)); + } + _definitionsByProfileId = - CanonicalDefinitions.ToDictionary( + _definitions.ToDictionary( definition => definition.ProfileId, StringComparer.OrdinalIgnoreCase); _definitionsByTransformId = - CanonicalDefinitions.ToDictionary( + _definitions.ToDictionary( definition => definition.TransformId, StringComparer.OrdinalIgnoreCase); + _readOnlyDefinitions = + Array.AsReadOnly(_definitions); } public static VisualProfileCatalog Default { get; } = new(); public IReadOnlyList Definitions => - ReadOnlyDefinitions; + _readOnlyDefinitions; public IEnumerable CreateBuiltInProfiles() { - return CanonicalDefinitions.Select( + return _definitions.Select( definition => definition.CreateBuiltInProfile()); } @@ -227,12 +262,20 @@ public string GetTransformDisplayName( public IVisualTransform GetRequiredTransform( string transformId) + { + return GetRequiredTransformDefinition( + transformId) + .Transform; + } + + public VisualProfileDefinition GetRequiredTransformDefinition( + string transformId) { if (TryGetTransformDefinition( transformId, out var definition)) { - return definition.Transform; + return definition; } throw new InvalidOperationException( @@ -243,13 +286,9 @@ public bool NormalizeTuningForTransform( VisualProfile profile) { ArgumentNullException.ThrowIfNull(profile); - - return TryGetTransformDefinition( - profile.TransformId, - out var definition) - ? definition.NormalizeTuning(profile) - : throw new InvalidOperationException( - $"The visual transform '{profile.TransformId}' is not registered."); + return GetRequiredTransformDefinition( + profile.TransformId) + .NormalizeTuning(profile); } private bool TryGetTransformDefinition( From 97db979ce5f73f1db34f724c4f96b22effe589d8 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:45:35 +0200 Subject: [PATCH 09/12] Normalize profile edits through the registered transform definition Addresses #114. Signed-off-by: KeyffMS <124252104+KeyffMS@users.noreply.github.com> --- src/SightAdapt/VisualProfileManagementService.cs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/SightAdapt/VisualProfileManagementService.cs b/src/SightAdapt/VisualProfileManagementService.cs index a7a7d372..26ba19c1 100644 --- a/src/SightAdapt/VisualProfileManagementService.cs +++ b/src/SightAdapt/VisualProfileManagementService.cs @@ -69,7 +69,8 @@ public static void Rename( public static void UpdateTuning( SightAdaptSettings settings, VisualProfile profile, - VisualProfile values) + VisualProfile values, + VisualProfileCatalog? catalog = null) { ArgumentNullException.ThrowIfNull(settings); ArgumentNullException.ThrowIfNull(profile); @@ -77,7 +78,11 @@ public static void UpdateTuning( settings.EnsureCollections(); EnsureMember(settings, profile); - if (!profile.SupportsTuning) + catalog ??= VisualProfileCatalog.Default; + var definition = + catalog.GetRequiredTransformDefinition( + profile.TransformId); + if (!definition.SupportsTuning) { throw new SettingsValidationException( "Only editable visual profiles can be tuned."); @@ -85,8 +90,7 @@ public static void UpdateTuning( VisualProfileDefaults.ApplyTuning( profile, - VisualProfileDefaults - .NormalizeSoftInvertTuning(values)); + definition.GetNormalizedTuning(values)); } public static int Delete( From 9f2c644824d171a8afa21b9a8b2240c638f033ee Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:46:01 +0200 Subject: [PATCH 10/12] Reset editable profiles through their transform definition Addresses #114. Signed-off-by: KeyffMS <124252104+KeyffMS@users.noreply.github.com> --- src/SightAdapt/VisualProfileEditorForm.cs | 35 ++++++++++++++++------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/src/SightAdapt/VisualProfileEditorForm.cs b/src/SightAdapt/VisualProfileEditorForm.cs index 9b8e1f37..219130c4 100644 --- a/src/SightAdapt/VisualProfileEditorForm.cs +++ b/src/SightAdapt/VisualProfileEditorForm.cs @@ -3,6 +3,7 @@ namespace SightAdapt; internal sealed class VisualProfileEditorForm : Form { private readonly VisualProfile _workingProfile; + private readonly VisualProfileDefinition _definition; private readonly ColorProfilePreview _preview; private readonly OutputLimitPreview _outputPreview; private readonly IReadOnlyDictionary @@ -16,9 +17,21 @@ private readonly IReadOnlyDictionary private bool _loadingValues; internal VisualProfileEditorForm(VisualProfile profile) + : this(profile, VisualProfileCatalog.Default) + { + } + + internal VisualProfileEditorForm( + VisualProfile profile, + VisualProfileCatalog catalog) { ArgumentNullException.ThrowIfNull(profile); - if (!profile.SupportsTuning) + ArgumentNullException.ThrowIfNull(catalog); + + _definition = + catalog.GetRequiredTransformDefinition( + profile.TransformId); + if (!_definition.SupportsTuning) { throw new ArgumentException( "Only editable visual profiles can be edited.", @@ -31,14 +44,14 @@ internal VisualProfileEditorForm(VisualProfile profile) Dock = DockStyle.Fill, Margin = Padding.Empty, Profile = _workingProfile, - TransformCatalog = VisualProfileCatalog.Default, + TransformCatalog = catalog, }; _outputPreview = new OutputLimitPreview { Dock = DockStyle.Fill, Margin = Padding.Empty, Profile = _workingProfile, - TransformCatalog = VisualProfileCatalog.Default, + TransformCatalog = catalog, }; _adjustments = VisualAdjustmentDefinitions.All .Select(definition => new VisualAdjustmentBinding( @@ -299,13 +312,12 @@ private static Control CreateSliderPanel( private Control CreateActionBar() { var reset = CreateButton( - "Reset soft profile", + "Reset profile", ModernButtonStyle.Secondary, 160); reset.AccessibleDescription = - $"Restore the canonical {VisualProfileCatalog.Default.GetTransformDisplayName( - SoftInvertVisualTransform.TransformId)} tuning values."; - reset.Click += (_, _) => ResetValues(); + $"Restore the canonical {_definition.DisplayName} tuning values."; + reset.Click += (_, _) => ResetToCanonicalTuning(); var cancel = CreateButton("Cancel", ModernButtonStyle.Ghost, 100); cancel.DialogResult = DialogResult.Cancel; @@ -411,11 +423,12 @@ private void LoadValues() } } - private void ResetValues() + internal VisualProfile WorkingProfile => + _workingProfile; + + internal void ResetToCanonicalTuning() { - VisualProfileDefaults.ApplyTuning( - _workingProfile, - VisualProfileDefaults.SoftInvertTuning); + _definition.ResetTuning(_workingProfile); LoadValues(); InvalidatePreviews(); } From 0f1d49d6565cd2d286bd74980fd25f9060a84cad Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:46:38 +0200 Subject: [PATCH 11/12] Prove transform-specific tuning and editor reset authority Addresses #114. Signed-off-by: KeyffMS <124252104+KeyffMS@users.noreply.github.com> --- .../VisualProfileTuningAuthorityTests.cs | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/tests/SightAdapt.Tests/VisualProfileTuningAuthorityTests.cs b/tests/SightAdapt.Tests/VisualProfileTuningAuthorityTests.cs index 84be4e9d..23b90702 100644 --- a/tests/SightAdapt.Tests/VisualProfileTuningAuthorityTests.cs +++ b/tests/SightAdapt.Tests/VisualProfileTuningAuthorityTests.cs @@ -48,6 +48,104 @@ public void UpdateTuningRejectsDetachedAndExactInvertProfiles() exact.CreateWorkingCopy())); } + + [TestMethod] + public void SecondEditableTransformUsesItsOwnNormalizationAndReset() + { + var canonical = new VisualProfileTuning( + 0.12f, + 0.88f, + 0.10f, + 1.10f, + 0.90f, + 15.0f); + var normalized = new VisualProfileTuning( + 0.20f, + 0.80f, + 0.25f, + 1.50f, + 0.75f, + 30.0f); + var definition = new VisualProfileDefinition( + "test-editable-profile", + TestEditableTransform.TransformId, + "Test editable", + supportsTuning: true, + new TestEditableTransform(), + canonical, + _ => normalized); + var catalog = new VisualProfileCatalog([definition]); + var profile = new VisualProfile + { + Id = "user-test-editable", + Name = "Test profile", + TransformId = TestEditableTransform.TransformId, + }; + var settings = new SightAdaptSettings + { + VisualProfiles = [profile], + }; + var values = profile.CreateWorkingCopy(); + values.OutputBlack = 0.49f; + values.OutputWhite = 0.51f; + values.Brightness = -0.5f; + values.Contrast = 2.0f; + values.Saturation = 2.0f; + values.HueShiftDegrees = -180.0f; + + VisualProfileManagementService.UpdateTuning( + settings, + profile, + values, + catalog); + + AssertTuning(profile, normalized); + + StaTest.Run(() => + { + using var editor = + new VisualProfileEditorForm( + profile, + catalog); + editor.WorkingProfile.Brightness = -0.4f; + + editor.ResetToCanonicalTuning(); + + AssertTuning( + editor.WorkingProfile, + canonical); + }); + } + + private static void AssertTuning( + VisualProfile profile, + VisualProfileTuning expected) + { + Assert.AreEqual(expected.OutputBlack, profile.OutputBlack); + Assert.AreEqual(expected.OutputWhite, profile.OutputWhite); + Assert.AreEqual(expected.Brightness, profile.Brightness); + Assert.AreEqual(expected.Contrast, profile.Contrast); + Assert.AreEqual(expected.Saturation, profile.Saturation); + Assert.AreEqual( + expected.HueShiftDegrees, + profile.HueShiftDegrees); + } + + private sealed class TestEditableTransform : + IVisualTransform + { + public const string TransformId = "test-editable"; + + public string Id => TransformId; + + public MagColorEffect CreateColorEffect( + VisualProfile profile) + { + ArgumentNullException.ThrowIfNull(profile); + return MagColorEffect.Invert; + } + } + [TestMethod] public void WorkingCopyDoesNotMutatePersistedProfile() { From 67fc698ca3c25cd88ee34d938a942d4a9ac3ca10 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:46:55 +0200 Subject: [PATCH 12/12] Document catalog definitions as tuning authority Addresses #114. Signed-off-by: KeyffMS <124252104+KeyffMS@users.noreply.github.com> --- docs/ARCHITECTURE.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index cf5a9df3..ed4d855f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -109,13 +109,19 @@ A failed mutation or failed write does not replace committed settings and does n | Per-application overlay scope | `ApplicationAssignment.OverlayScopeId` | | Optional native-menu profile reference and inheritance sentinel | `ApplicationAssignment.MenuVisualProfileId` and `ApplicationMenuProfilePolicy` | | Scope enum values, canonical identifiers, aliases, default, and display names | `OverlayScopePolicy` definition table | -| Built-in profile IDs, names, transforms, ordering, tuning capability and canonicalization | `VisualProfileCatalog` definition table | +| Built-in profile IDs, names, transforms, ordering, tuning capability, canonical tuning and normalization policy | `VisualProfileCatalog` definition table | | Assignment defaults, fallbacks, user-ID and user-name rules | `VisualProfilePolicy` | -| Canonical tuning values and numeric normalization | `VisualProfileDefaults` | +| Built-in tuning constants and shared numeric helpers | `VisualProfileDefaults` | | Parameter ranges | `VisualProfileLimits` | | Runtime polling, refresh, transition and fault-recovery intervals | `RuntimeTimingPolicy.Default` | | Product name, version, milestone, repository, author, and license | project and assembly metadata exposed through `ProductInfo` | +## Visual-profile tuning authority + +Each registered `VisualProfileDefinition` is the authority for its transform's editable status, canonical/reset tuning and normalization function. `VisualProfileManagementService.UpdateTuning` resolves the target profile's transform definition before normalizing submitted values, and `VisualProfileEditorForm` resolves the same definition for reset wording and canonical reset values. Adding another editable transform therefore does not inherit Soft Invert normalization or defaults implicitly. + +`VisualProfileDefaults` retains the built-in numeric constants and reusable normalization helpers used by the current definitions; it is not a second transform registry. + `ApplicationIdentityCache` is an optimization, not a product source of truth. Entries are keyed by both PID and process creation time so a reused PID cannot inherit another process lifetime's identity. ## Foreground and overlay lifecycle