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 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( 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(); } 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( 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() {