diff --git a/ConfigFiles/MBE/ui/grid_style.yaml b/ConfigFiles/MBE/ui/grid_style.yaml index b156f1a2..b655c905 100644 --- a/ConfigFiles/MBE/ui/grid_style.yaml +++ b/ConfigFiles/MBE/ui/grid_style.yaml @@ -1,8 +1,6 @@ # Grid Style Configuration # Color formats supported: -# - #RGB (e.g., #FFF) # - #RRGGBB (e.g., #FFFFFF) -# - #ARGB (e.g., #0FFF for transparent white) # - #AARRGGBB (e.g., #00FFFFFF for fully transparent white) # Alpha: 00 = fully transparent, FF = fully opaque diff --git a/ConfigFiles/MOCVD/ui/grid_style.yaml b/ConfigFiles/MOCVD/ui/grid_style.yaml index b156f1a2..b655c905 100644 --- a/ConfigFiles/MOCVD/ui/grid_style.yaml +++ b/ConfigFiles/MOCVD/ui/grid_style.yaml @@ -1,8 +1,6 @@ # Grid Style Configuration # Color formats supported: -# - #RGB (e.g., #FFF) # - #RRGGBB (e.g., #FFFFFF) -# - #ARGB (e.g., #0FFF for transparent white) # - #AARRGGBB (e.g., #00FFFFFF for fully transparent white) # Alpha: 00 = fully transparent, FF = fully opaque diff --git a/Docs/architecture/error-reporting.md b/Docs/architecture/error-reporting.md index 7f3c4bb9..4cf47b03 100644 --- a/Docs/architecture/error-reporting.md +++ b/Docs/architecture/error-reporting.md @@ -169,7 +169,7 @@ type. Its two `Result`-join sites (`LoadAsync`, `Save`) route each error through rather than joining raw `.Message` strings, so both the validation failures and the file-I/O failures render in the current culture. Eight typed producers back it (all in `SemiStep.Core/Configuration/`): -- **Validation** (`GridStyleValidator`): `GridStyleConfigMissingError`, `GridStyleSectionMissingError`, +- **Validation** (`GridStyleMapper`): `GridStyleConfigMissingError`, `GridStyleSectionMissingError`, `GridStyleOrientationInvalidError`, `GridStyleKeyMissingError`, `GridStyleHexColorInvalidError`. The orientation error carries its two expected values as properties (`ExpectedRows`/`ExpectedColumns`) rather than referencing the `internal GridOrientationValues` constants, which `ReasonLocalizer` (in `SemiStep.UI`) @@ -184,7 +184,7 @@ the dropped `ex.Message` is logged at each consumer, and the `ExceptionalError` - **Editor consumer** — `GridStyleEditorViewModel.LoadAsync`/`Save` log each nested `ExceptionalError` at `LogWarning` after setting the localized `ErrorMessage`. -- **Startup consumer** — `GridStyleLoader`/`GridStyleValidator` are also on the startup config-load path +- **Startup consumer** — `GridStyleLoader`/`GridStyleMapper` are also on the startup config-load path (`ConfigFacade.LoadAndValidateAsync`, consumed by `Program.cs`), which logs the same nested `ExceptionalError` so a YAML parse detail is not lost at launch. diff --git a/Docs/architecture/grid-style-configuration.md b/Docs/architecture/grid-style-configuration.md index 179a40bc..9e2bde1c 100644 --- a/Docs/architecture/grid-style-configuration.md +++ b/Docs/architecture/grid-style-configuration.md @@ -41,7 +41,7 @@ are the deliberate exception: `GridStyleLoader.LoadAsync` reads exactly one file unambiguous: one config-dir maps to one style file. If styles were ever split across `ui/*.yaml`, write-back would have no single target, so single-file is intentional. -The file header documents the accepted hex formats (`#RGB`, `#RRGGBB`, `#ARGB`, `#AARRGGBB`); the +The file header documents the accepted hex formats (`#RRGGBB`, `#AARRGGBB`); the writer preserves that header on save. ## The load pipeline @@ -49,29 +49,34 @@ writer preserves that header on save. ``` grid_style.yaml → GridStyleOptionsDto (internal DTOs, snake_case [YamlMember], one per section) - → GridStyleValidator.Validate (hex-format check; see "Known gap" below) - → GridStyleMapper.Map (DTO → record, applies defaults) + → GridStyleMapper.Map (DTO → record, aggregated per-key validation, applies defaults) → GridStyleOptions (immutable record, SemiStep.Core, Avalonia-free) → AppConfiguration.GridStyle (bundled by ConfigFacade) → DI singleton (UiDi.AddUi: AppConfiguration.GridStyle registered as GridStyleOptions) ``` -`ConfigFacade` loads the DTO, runs `GridStyleValidator`, calls `GridStyleMapper.Map`, and bundles the -resulting `GridStyleOptions` record into `AppConfiguration`. `UiDi.AddUi` registers +`ConfigFacade` loads the DTO, calls `GridStyleMapper.Map`, which validates and maps in one pass, and +bundles the resulting `GridStyleOptions` record into `AppConfiguration`. `UiDi.AddUi` registers `AppConfiguration.GridStyle` as a `GridStyleOptions` singleton, so every consumer (the installers, the column factories, `ColumnWidthCalculator`) injects the same typed record. `GridStyleOptions` is an immutable record with a `Default` fallback used only by tests; the `#000000` cell-palette placeholders in `Default` are never rendered in production. +**Interim (slice 4).** Colors are now the typed `StyleColor` value (channels `A`/`R`/`G`/`B`) end to +end in the record; hex is a string only at the two I/O edges. Validation lives inside the load mapper — +`GridStyleMapper.Map` returns a `Result` and accumulates every per-key error in one +pass. The standalone `GridStyleValidator` and the editor's `HexColor` helper are gone. The full doc +rewrite (and removal of the now-obsolete "Known gap" section) lands in slice 5. + ## Record shape: nested per group (interim, slice 3) `GridStyleOptions` is no longer a flat 78-field record. It is a one-level nested root that composes ~10 per-group records (`Fonts`, `Layout`, `Selection`, `ChangedCells`, `ReadOnlyCells`, `DisabledCells`, `Execution`, `StatusBar`, `ValidationPanel`, `Chrome`) plus the root-level `Orientation`. `ReadOnlyCells` and `DisabledCells` share one `DepthPalette` type. Consumers read short nested paths — `gridStyle.Fonts.CellFontSize`, -`gridStyle.ReadOnlyCells.Depth1`, `gridStyle.Chrome.GridLine` — instead of the old flat fields. Colors stay -`string` in this shape; typing them as `StyleColor` is slice 4. +`gridStyle.ReadOnlyCells.Depth1`, `gridStyle.Chrome.GridLine` — instead of the old flat fields. Colors were +`string` at slice 3; slice 4 typed them as `StyleColor` (see the interim note above). **The YAML file, the DTOs, and `GridStyleValidator` are unchanged** by this slice: the file on disk is byte-identical, the DTO layer keeps its nested snake_case shape and per-key error reporting, and the load @@ -83,8 +88,8 @@ show a flat path (for example `GridStyleOptions.FontFamily`) now live under thei **Note for slice 4.** The error-path key `colors.grid_line` and the record path `Chrome.GridLine` deliberately diverge: `grid_line` is a loose field in the `colors:` DTO section (beside `grid_border` / `grid_background`), but the one-level record folds it into `Chrome` so the root carries no lone `string`. -Slice 4 moves `GridStyleValidator`'s per-key checks into the load mapper; that mapper-resident validation -**must keep emitting the `colors.grid_line` key**, not rename it to `chrome.grid_line`. The key names the +Slice 4 moved those per-key checks into the load mapper (`GridStyleMapper.Map`); that mapper-resident +validation keeps emitting the `colors.grid_line` key, not `chrome.grid_line`. The key names the YAML path the operator edits, not the record path. ## Resources projection @@ -128,8 +133,8 @@ orientation: rows_as_steps # canonical (rows = steps); or columns_as_steps (tr ``` - **Values.** `rows_as_steps` (canonical) or `columns_as_steps` (transposed). An absent key - defaults to `rows_as_steps`, so existing files load unchanged. `GridStyleValidator` rejects - any other string with a config error naming both accepted values. + defaults to `rows_as_steps`, so existing files load unchanged. The load mapper + (`GridStyleMapper.Map`) rejects any other string with a config error naming both accepted values. - **Typed model.** The record carries a Core enum, `GridStyleOptions.Orientation` (`GridOrientation.RowsAsSteps | ColumnsAsSteps`), not the raw string. Parsing and serialization go through `GridOrientationValues` (`Configuration/Dto/`); an absent DTO value @@ -237,9 +242,10 @@ GridStyleEditorWindow - **ViewModel.** Seeds a mutable draft from the loaded record (a separate copy, never the DI singleton). Colors are exposed as `Color` for the `ColorPicker`; sizes as `decimal?` for - `NumericUpDown`. Hex↔`Color` goes through an explicit `HexColor.ToHex` / `HexColor.Parse`, not - `Color.ToString()`. `CanSave` is gated by **both** the facade's color validation **and** VM-side - numeric range checks (font/padding/row-height/spacing/panel-height bounds). The editor surfaces + `NumericUpDown`. Channel↔`Color` conversion goes through `StyleColorConversions` + (`ToMediaColor` / `ToStyleColor`, in `SemiStep.UI/Styles`), not `Color.ToString()`. `CanSave` is gated + by VM-side numeric range checks alone (font/padding/row-height/spacing/panel-height bounds); an invalid + color is unrepresentable in the typed record, so the facade no longer color-validates on save. The editor surfaces effectively the whole record: all ~54 colors, 13 numerics, plus the font controls — one global font-family `ComboBox`, and a per-role weight `ComboBox` (curated 300–900 list) and italic `CheckBox`. The window groups these into two cards: **Fonts** (the family row plus a size / weight / @@ -254,9 +260,11 @@ GridStyleEditorWindow - **Facade.** `GridStyleEditorFacade` is the only public Core seam for the editor: `Load(configDir)` → `Result`, `Validate(GridStyleOptions)` → `Result`, `Save(configDir, GridStyleOptions)` → `Task`. `Save` is async and runs the file write off the - UI thread (mirroring `Load`); the editor's `SaveCommand` awaits it. `Validate` stays synchronous — - `RecomputeCanSave` calls it per keystroke, and it is an in-memory pass with no I/O. The loader, - validator, writer, and the ~12 DTOs stay `internal`. The UI never touches Core internals directly. + UI thread (mirroring `Load`); the editor's `SaveCommand` awaits it. `Validate` is a vacuous + `Result.Ok()` pass-through — color validation now lives in the load mapper, so it validates nothing; + `RecomputeCanSave` still calls it per keystroke but `CanSave` turns on the numeric-range checks alone. + The method stays on the interface until slice 5 trims it. The loader, writer, and the ~12 DTOs stay + `internal`. The UI never touches Core internals directly. (Layering: the config stays in `SemiStep.Core`, settled by review; the editor reaches it only through this facade.) - **Writer.** `GridStyleWriter` maps the record back to the DTO (`GridStyleDtoMapper`), serializes with diff --git a/SemiStep/SemiStep.Core/Configuration/ChangedCellColors.cs b/SemiStep/SemiStep.Core/Configuration/ChangedCellColors.cs index dc7b4015..c87a93a2 100644 --- a/SemiStep/SemiStep.Core/Configuration/ChangedCellColors.cs +++ b/SemiStep/SemiStep.Core/Configuration/ChangedCellColors.cs @@ -1,5 +1,5 @@ namespace SemiStep.Core.Configuration; public sealed record ChangedCellColors( - string Changed, - string ChangedSelected); + StyleColor Changed, + StyleColor ChangedSelected); diff --git a/SemiStep/SemiStep.Core/Configuration/ChromeColors.cs b/SemiStep/SemiStep.Core/Configuration/ChromeColors.cs index d3696891..b3aad909 100644 --- a/SemiStep/SemiStep.Core/Configuration/ChromeColors.cs +++ b/SemiStep/SemiStep.Core/Configuration/ChromeColors.cs @@ -1,17 +1,17 @@ namespace SemiStep.Core.Configuration; public sealed record ChromeColors( - string Info, - string Connected, - string Disconnected, - string LocalMode, - string Connecting, - string PanelBackground, - string PanelHeaderBackground, - string SubtleBorder, - string Separator, - string SecondaryForeground, - string GridBorder, - string GridBackground, - string HeaderForeground, - string GridLine); + StyleColor Info, + StyleColor Connected, + StyleColor Disconnected, + StyleColor LocalMode, + StyleColor Connecting, + StyleColor PanelBackground, + StyleColor PanelHeaderBackground, + StyleColor SubtleBorder, + StyleColor Separator, + StyleColor SecondaryForeground, + StyleColor GridBorder, + StyleColor GridBackground, + StyleColor HeaderForeground, + StyleColor GridLine); diff --git a/SemiStep/SemiStep.Core/Configuration/DepthPalette.cs b/SemiStep/SemiStep.Core/Configuration/DepthPalette.cs index ee790a5b..4cdadfd6 100644 --- a/SemiStep/SemiStep.Core/Configuration/DepthPalette.cs +++ b/SemiStep/SemiStep.Core/Configuration/DepthPalette.cs @@ -1,13 +1,13 @@ namespace SemiStep.Core.Configuration; public sealed record DepthPalette( - string Depth0, - string Depth1, - string Depth2, - string Depth3, - string Depth0Past, - string Depth1Past, - string Depth2Past, - string Depth3Past, - string Selected, - string Foreground); + StyleColor Depth0, + StyleColor Depth1, + StyleColor Depth2, + StyleColor Depth3, + StyleColor Depth0Past, + StyleColor Depth1Past, + StyleColor Depth2Past, + StyleColor Depth3Past, + StyleColor Selected, + StyleColor Foreground); diff --git a/SemiStep/SemiStep.Core/Configuration/Dto/GridOrientationValues.cs b/SemiStep/SemiStep.Core/Configuration/Dto/GridOrientationValues.cs index c3f45ba0..ccc294cf 100644 --- a/SemiStep/SemiStep.Core/Configuration/Dto/GridOrientationValues.cs +++ b/SemiStep/SemiStep.Core/Configuration/Dto/GridOrientationValues.cs @@ -5,7 +5,7 @@ internal static class GridOrientationValues public const string RowsAsSteps = "rows_as_steps"; public const string ColumnsAsSteps = "columns_as_steps"; - // GridStyleValidator rejects unknown values before this parser runs; throwing here is + // GridStyleMapper rejects unknown values before this parser runs; throwing here is // defense-in-depth against a future path that maps an unvalidated DTO. public static GridOrientation Parse(string? value) { diff --git a/SemiStep/SemiStep.Core/Configuration/ExecutionPalette.cs b/SemiStep/SemiStep.Core/Configuration/ExecutionPalette.cs index afd8cf69..69726f81 100644 --- a/SemiStep/SemiStep.Core/Configuration/ExecutionPalette.cs +++ b/SemiStep/SemiStep.Core/Configuration/ExecutionPalette.cs @@ -1,12 +1,12 @@ namespace SemiStep.Core.Configuration; public sealed record ExecutionPalette( - string Depth0, - string Depth1, - string Depth2, - string Depth3, - string Depth0Past, - string Depth1Past, - string Depth2Past, - string Depth3Past, - string CurrentStepMarker); + StyleColor Depth0, + StyleColor Depth1, + StyleColor Depth2, + StyleColor Depth3, + StyleColor Depth0Past, + StyleColor Depth1Past, + StyleColor Depth2Past, + StyleColor Depth3Past, + StyleColor CurrentStepMarker); diff --git a/SemiStep/SemiStep.Core/Configuration/Facade/ConfigFacade.cs b/SemiStep/SemiStep.Core/Configuration/Facade/ConfigFacade.cs index 7a0d692e..fba1b72b 100644 --- a/SemiStep/SemiStep.Core/Configuration/Facade/ConfigFacade.cs +++ b/SemiStep/SemiStep.Core/Configuration/Facade/ConfigFacade.cs @@ -25,7 +25,7 @@ public static async Task> LoadAndValidateAsync(string c var (properties, columns, groups, actions, gridStyle, connection, appUi) = loadResult.Value; - var gridStyleResult = GridStyleValidator.Validate(gridStyle); + var gridStyleResult = GridStyleMapper.Map(gridStyle); if (gridStyleResult.IsFailed) { return Propagate(gridStyleResult, loadResult); @@ -43,7 +43,7 @@ public static async Task> LoadAndValidateAsync(string c return Propagate(defaultsResult, loadResult, xrefResult); } - var mapResult = MapToDomain(properties, columns, groups, actions, gridStyle, connection, appUi); + var mapResult = MapToDomain(properties, columns, groups, actions, gridStyleResult.Value, connection, appUi); if (mapResult.IsFailed) { @@ -118,7 +118,7 @@ private static Result MapToDomain( List columns, Dictionary> groups, List actions, - Dto.GridStyleOptionsDto? gridStyle, + GridStyleOptions gridStyle, Dto.ConnectionDto? connection, Dto.AppUiOptionsDto? appUi) { @@ -152,15 +152,13 @@ private static Result MapToDomain( mappedActions.Add(action.Id, action); } - var mappedGridStyle = GridStyleMapper.Map(gridStyle); - var plcConfiguration = ConnectionMapper.Map(connection); var appUiOptions = AppUiOptionsMapper.Map(appUi); return Result.Ok(new AppConfiguration( mappedProperties, mappedColumns, mappedGroups, - mappedActions, mappedGridStyle, plcConfiguration, appUiOptions)); + mappedActions, gridStyle, plcConfiguration, appUiOptions)); } private sealed record LoadedSections( diff --git a/SemiStep/SemiStep.Core/Configuration/GridStyleEditorFacade.cs b/SemiStep/SemiStep.Core/Configuration/GridStyleEditorFacade.cs index cc4ce520..3b646385 100644 --- a/SemiStep/SemiStep.Core/Configuration/GridStyleEditorFacade.cs +++ b/SemiStep/SemiStep.Core/Configuration/GridStyleEditorFacade.cs @@ -2,15 +2,15 @@ using SemiStep.Core.Configuration.Loaders; using SemiStep.Core.Configuration.Mapping; -using SemiStep.Core.Configuration.Validation; namespace SemiStep.Core.Configuration; /// -/// The single public Core seam for the in-app style editor. and -/// check color hex format only; numeric-range validation (font sizes, paddings, -/// row height, spacing, panel height) is the caller's responsibility — the editor view model enforces -/// those bounds before invoking . +/// The single public Core seam for the in-app style editor. Color hex validation now lives in +/// , which parses and validates in one pass on ; an invalid +/// color is unrepresentable in , so writes without a +/// pre-write gate. Numeric-range validation (font sizes, paddings, row height, spacing, panel height) is the +/// caller's responsibility — the editor view model enforces those bounds before invoking . /// public sealed class GridStyleEditorFacade : IGridStyleEditorFacade { @@ -24,28 +24,18 @@ public async Task> Load(string configDir) return loadResult.ToResult(); } - var validation = GridStyleValidator.Validate(loadResult.Value); - if (validation.IsFailed) - { - return validation.ToResult(); - } - - return Result.Ok(GridStyleMapper.Map(loadResult.Value)); + return GridStyleMapper.Map(loadResult.Value); } public Result Validate(GridStyleOptions options) { - return GridStyleValidator.Validate(GridStyleDtoMapper.Map(options)); + // Deliberate vacuous pass-through: a typed GridStyleOptions cannot hold an invalid color, so there is + // nothing to validate here. The method stays on IGridStyleEditorFacade until slice 5 trims the interface. + return Result.Ok(); } public async Task Save(string configDir, GridStyleOptions options) { - var validation = Validate(options); - if (validation.IsFailed) - { - return validation; - } - return await _gridStyleWriter.SaveAsync(configDir, options); } } diff --git a/SemiStep/SemiStep.Core/Configuration/GridStyleOptions.cs b/SemiStep/SemiStep.Core/Configuration/GridStyleOptions.cs index 8ed6c194..6d786ca9 100644 --- a/SemiStep/SemiStep.Core/Configuration/GridStyleOptions.cs +++ b/SemiStep/SemiStep.Core/Configuration/GridStyleOptions.cs @@ -30,46 +30,46 @@ public sealed record GridStyleOptions( CellPaddingBottom: 4, RowHeight: 28), Selection: new SelectionColors( - Background: "#CCE4F7", - Foreground: "#202020"), + Background: StyleColor.Parse("#CCE4F7"), + Foreground: StyleColor.Parse("#202020")), ChangedCells: new ChangedCellColors( - Changed: "#FFCC80", - ChangedSelected: "#EFD3A4"), + Changed: StyleColor.Parse("#FFCC80"), + ChangedSelected: StyleColor.Parse("#EFD3A4")), ReadOnlyCells: new DepthPalette( - Depth0: "#000000", - Depth1: "#000000", - Depth2: "#000000", - Depth3: "#000000", - Depth0Past: "#000000", - Depth1Past: "#000000", - Depth2Past: "#000000", - Depth3Past: "#000000", - Selected: "#000000", - Foreground: "#000000"), + Depth0: StyleColor.Parse("#000000"), + Depth1: StyleColor.Parse("#000000"), + Depth2: StyleColor.Parse("#000000"), + Depth3: StyleColor.Parse("#000000"), + Depth0Past: StyleColor.Parse("#000000"), + Depth1Past: StyleColor.Parse("#000000"), + Depth2Past: StyleColor.Parse("#000000"), + Depth3Past: StyleColor.Parse("#000000"), + Selected: StyleColor.Parse("#000000"), + Foreground: StyleColor.Parse("#000000")), DisabledCells: new DepthPalette( - Depth0: "#000000", - Depth1: "#000000", - Depth2: "#000000", - Depth3: "#000000", - Depth0Past: "#000000", - Depth1Past: "#000000", - Depth2Past: "#000000", - Depth3Past: "#000000", - Selected: "#000000", - Foreground: "#000000"), + Depth0: StyleColor.Parse("#000000"), + Depth1: StyleColor.Parse("#000000"), + Depth2: StyleColor.Parse("#000000"), + Depth3: StyleColor.Parse("#000000"), + Depth0Past: StyleColor.Parse("#000000"), + Depth1Past: StyleColor.Parse("#000000"), + Depth2Past: StyleColor.Parse("#000000"), + Depth3Past: StyleColor.Parse("#000000"), + Selected: StyleColor.Parse("#000000"), + Foreground: StyleColor.Parse("#000000")), Execution: new ExecutionPalette( - Depth0: "#000000", - Depth1: "#000000", - Depth2: "#000000", - Depth3: "#000000", - Depth0Past: "#000000", - Depth1Past: "#000000", - Depth2Past: "#000000", - Depth3Past: "#000000", - CurrentStepMarker: "#000000"), + Depth0: StyleColor.Parse("#000000"), + Depth1: StyleColor.Parse("#000000"), + Depth2: StyleColor.Parse("#000000"), + Depth3: StyleColor.Parse("#000000"), + Depth0Past: StyleColor.Parse("#000000"), + Depth1Past: StyleColor.Parse("#000000"), + Depth2Past: StyleColor.Parse("#000000"), + Depth3Past: StyleColor.Parse("#000000"), + CurrentStepMarker: StyleColor.Parse("#000000")), StatusBar: new StatusBarStyle( - Background: "#F0F0F0", - Foreground: "#000000", + Background: StyleColor.Parse("#F0F0F0"), + Foreground: StyleColor.Parse("#000000"), Padding: 5, ItemSpacing: 10, FontSize: 12, @@ -82,25 +82,25 @@ public sealed record GridStyleOptions( TimerValueWeight: 400, TimerValueItalic: false), ValidationPanel: new ValidationPanelStyle( - Background: "#FFFFFF", - Foreground: "#000000", - ErrorColor: "#D32F2F", - WarningColor: "#F57C00", + Background: StyleColor.Parse("#FFFFFF"), + Foreground: StyleColor.Parse("#000000"), + ErrorColor: StyleColor.Parse("#D32F2F"), + WarningColor: StyleColor.Parse("#F57C00"), MaxHeight: 100), Chrome: new ChromeColors( - Info: "#1976D2", - Connected: "#44BB44", - Disconnected: "#FF4444", - LocalMode: "#6C707E", - Connecting: "#FFAF0F", - PanelBackground: "#F8F8F8", - PanelHeaderBackground: "#EEEEEE", - SubtleBorder: "#D0D0D0", - Separator: "#C0C0C0", - SecondaryForeground: "#888888", - GridBorder: "#808080", - GridBackground: "#FFFFFF", - HeaderForeground: "#000000", - GridLine: "#E0E0E0"), + Info: StyleColor.Parse("#1976D2"), + Connected: StyleColor.Parse("#44BB44"), + Disconnected: StyleColor.Parse("#FF4444"), + LocalMode: StyleColor.Parse("#6C707E"), + Connecting: StyleColor.Parse("#FFAF0F"), + PanelBackground: StyleColor.Parse("#F8F8F8"), + PanelHeaderBackground: StyleColor.Parse("#EEEEEE"), + SubtleBorder: StyleColor.Parse("#D0D0D0"), + Separator: StyleColor.Parse("#C0C0C0"), + SecondaryForeground: StyleColor.Parse("#888888"), + GridBorder: StyleColor.Parse("#808080"), + GridBackground: StyleColor.Parse("#FFFFFF"), + HeaderForeground: StyleColor.Parse("#000000"), + GridLine: StyleColor.Parse("#E0E0E0")), Orientation: GridOrientation.RowsAsSteps); } diff --git a/SemiStep/SemiStep.Core/Configuration/Mapping/GridStyleDtoMapper.cs b/SemiStep/SemiStep.Core/Configuration/Mapping/GridStyleDtoMapper.cs index 3632a5e9..ca84aaa1 100644 --- a/SemiStep/SemiStep.Core/Configuration/Mapping/GridStyleDtoMapper.cs +++ b/SemiStep/SemiStep.Core/Configuration/Mapping/GridStyleDtoMapper.cs @@ -30,58 +30,58 @@ public static GridStyleOptionsDto Map(GridStyleOptions options) { Selection = new GridStyleSelectionColorsDto { - Background = options.Selection.Background, - Foreground = options.Selection.Foreground + Background = options.Selection.Background.ToString(), + Foreground = options.Selection.Foreground.ToString() }, Cells = new GridStyleCellColorsDto { - Changed = options.ChangedCells.Changed, - ChangedSelected = options.ChangedCells.ChangedSelected, + Changed = options.ChangedCells.Changed.ToString(), + ChangedSelected = options.ChangedCells.ChangedSelected.ToString(), ReadOnly = new GridStyleReadOnlyCellColorsDto { - Depth0 = options.ReadOnlyCells.Depth0, - Depth1 = options.ReadOnlyCells.Depth1, - Depth2 = options.ReadOnlyCells.Depth2, - Depth3 = options.ReadOnlyCells.Depth3, - Depth0Past = options.ReadOnlyCells.Depth0Past, - Depth1Past = options.ReadOnlyCells.Depth1Past, - Depth2Past = options.ReadOnlyCells.Depth2Past, - Depth3Past = options.ReadOnlyCells.Depth3Past, - Selected = options.ReadOnlyCells.Selected, - Foreground = options.ReadOnlyCells.Foreground + Depth0 = options.ReadOnlyCells.Depth0.ToString(), + Depth1 = options.ReadOnlyCells.Depth1.ToString(), + Depth2 = options.ReadOnlyCells.Depth2.ToString(), + Depth3 = options.ReadOnlyCells.Depth3.ToString(), + Depth0Past = options.ReadOnlyCells.Depth0Past.ToString(), + Depth1Past = options.ReadOnlyCells.Depth1Past.ToString(), + Depth2Past = options.ReadOnlyCells.Depth2Past.ToString(), + Depth3Past = options.ReadOnlyCells.Depth3Past.ToString(), + Selected = options.ReadOnlyCells.Selected.ToString(), + Foreground = options.ReadOnlyCells.Foreground.ToString() }, Disabled = new GridStyleDisabledCellColorsDto { - Depth0 = options.DisabledCells.Depth0, - Depth1 = options.DisabledCells.Depth1, - Depth2 = options.DisabledCells.Depth2, - Depth3 = options.DisabledCells.Depth3, - Depth0Past = options.DisabledCells.Depth0Past, - Depth1Past = options.DisabledCells.Depth1Past, - Depth2Past = options.DisabledCells.Depth2Past, - Depth3Past = options.DisabledCells.Depth3Past, - Selected = options.DisabledCells.Selected, - Foreground = options.DisabledCells.Foreground + Depth0 = options.DisabledCells.Depth0.ToString(), + Depth1 = options.DisabledCells.Depth1.ToString(), + Depth2 = options.DisabledCells.Depth2.ToString(), + Depth3 = options.DisabledCells.Depth3.ToString(), + Depth0Past = options.DisabledCells.Depth0Past.ToString(), + Depth1Past = options.DisabledCells.Depth1Past.ToString(), + Depth2Past = options.DisabledCells.Depth2Past.ToString(), + Depth3Past = options.DisabledCells.Depth3Past.ToString(), + Selected = options.DisabledCells.Selected.ToString(), + Foreground = options.DisabledCells.Foreground.ToString() }, Execution = new GridStyleExecutionColorsDto { - Depth0 = options.Execution.Depth0, - Depth1 = options.Execution.Depth1, - Depth2 = options.Execution.Depth2, - Depth3 = options.Execution.Depth3, - Depth0Past = options.Execution.Depth0Past, - Depth1Past = options.Execution.Depth1Past, - Depth2Past = options.Execution.Depth2Past, - Depth3Past = options.Execution.Depth3Past, - CurrentStepMarker = options.Execution.CurrentStepMarker + Depth0 = options.Execution.Depth0.ToString(), + Depth1 = options.Execution.Depth1.ToString(), + Depth2 = options.Execution.Depth2.ToString(), + Depth3 = options.Execution.Depth3.ToString(), + Depth0Past = options.Execution.Depth0Past.ToString(), + Depth1Past = options.Execution.Depth1Past.ToString(), + Depth2Past = options.Execution.Depth2Past.ToString(), + Depth3Past = options.Execution.Depth3Past.ToString(), + CurrentStepMarker = options.Execution.CurrentStepMarker.ToString() } }, - GridLine = options.Chrome.GridLine + GridLine = options.Chrome.GridLine.ToString() }, StatusBar = new StatusBarStyleDto { - Background = options.StatusBar.Background, - Foreground = options.StatusBar.Foreground, + Background = options.StatusBar.Background.ToString(), + Foreground = options.StatusBar.Foreground.ToString(), Padding = options.StatusBar.Padding, ItemSpacing = options.StatusBar.ItemSpacing, FontSize = options.StatusBar.FontSize, @@ -96,27 +96,27 @@ public static GridStyleOptionsDto Map(GridStyleOptions options) }, ValidationPanel = new ValidationPanelStyleDto { - Background = options.ValidationPanel.Background, - Foreground = options.ValidationPanel.Foreground, - ErrorColor = options.ValidationPanel.ErrorColor, - WarningColor = options.ValidationPanel.WarningColor, + Background = options.ValidationPanel.Background.ToString(), + Foreground = options.ValidationPanel.Foreground.ToString(), + ErrorColor = options.ValidationPanel.ErrorColor.ToString(), + WarningColor = options.ValidationPanel.WarningColor.ToString(), MaxHeight = options.ValidationPanel.MaxHeight }, Chrome = new GridStyleChromeColorsDto { - Info = options.Chrome.Info, - Connected = options.Chrome.Connected, - Disconnected = options.Chrome.Disconnected, - LocalMode = options.Chrome.LocalMode, - Connecting = options.Chrome.Connecting, - PanelBackground = options.Chrome.PanelBackground, - PanelHeaderBackground = options.Chrome.PanelHeaderBackground, - SubtleBorder = options.Chrome.SubtleBorder, - Separator = options.Chrome.Separator, - SecondaryForeground = options.Chrome.SecondaryForeground, - GridBorder = options.Chrome.GridBorder, - GridBackground = options.Chrome.GridBackground, - HeaderForeground = options.Chrome.HeaderForeground + Info = options.Chrome.Info.ToString(), + Connected = options.Chrome.Connected.ToString(), + Disconnected = options.Chrome.Disconnected.ToString(), + LocalMode = options.Chrome.LocalMode.ToString(), + Connecting = options.Chrome.Connecting.ToString(), + PanelBackground = options.Chrome.PanelBackground.ToString(), + PanelHeaderBackground = options.Chrome.PanelHeaderBackground.ToString(), + SubtleBorder = options.Chrome.SubtleBorder.ToString(), + Separator = options.Chrome.Separator.ToString(), + SecondaryForeground = options.Chrome.SecondaryForeground.ToString(), + GridBorder = options.Chrome.GridBorder.ToString(), + GridBackground = options.Chrome.GridBackground.ToString(), + HeaderForeground = options.Chrome.HeaderForeground.ToString() }, Orientation = GridOrientationValues.Serialize(options.Orientation) }; diff --git a/SemiStep/SemiStep.Core/Configuration/Mapping/GridStyleMapper.cs b/SemiStep/SemiStep.Core/Configuration/Mapping/GridStyleMapper.cs index 469a6b5d..3b174473 100644 --- a/SemiStep/SemiStep.Core/Configuration/Mapping/GridStyleMapper.cs +++ b/SemiStep/SemiStep.Core/Configuration/Mapping/GridStyleMapper.cs @@ -1,23 +1,47 @@ -using SemiStep.Core.Configuration.Dto; +using FluentResults; + +using SemiStep.Core.Configuration.Dto; namespace SemiStep.Core.Configuration.Mapping; internal static class GridStyleMapper { - public static GridStyleOptions Map(GridStyleOptionsDto? dto) + public static Result Map(GridStyleOptionsDto? dto) { if (dto is null) { - return GridStyleOptions.Default; + return Result.Fail(new GridStyleConfigMissingError()); + } + + if (dto.Colors is null) + { + return Result.Fail(new GridStyleSectionMissingError("colors")); } var defaults = GridStyleOptions.Default; - // Presence guaranteed by GridStyleValidator, which runs before this mapper; hence the `!` chain. - var readOnlyCells = dto.Colors!.Cells!.ReadOnly!; - var disabledCells = dto.Colors!.Cells!.Disabled!; - var executionCells = dto.Colors!.Cells!.Execution!; + var errors = new List(); + + // Parse phase in the validator's emission order: execution -> readonly -> disabled -> chrome -> + // selection -> cells.changed/changed_selected -> colors.grid_line -> status_bar -> validation_panel -> + // orientation. The join into one user-visible message depends on this order. + var execution = MapExecution(dto.Colors.Cells?.Execution, defaults.Execution, errors); + var readOnly = MapReadOnly(dto.Colors.Cells?.ReadOnly, defaults.ReadOnlyCells, errors); + var disabled = MapDisabled(dto.Colors.Cells?.Disabled, defaults.DisabledCells, errors); + var chrome = MapChrome(dto.Chrome, defaults.Chrome, errors); + var selection = MapSelection(dto.Colors.Selection, defaults.Selection, errors); + var changedCells = MapChangedCells(dto.Colors.Cells, defaults.ChangedCells, errors); + var gridLine = OptionalColor(dto.Colors.GridLine, defaults.Chrome.GridLine, "colors", "grid_line", errors); + chrome = chrome with { GridLine = gridLine }; + var statusBar = MapStatusBar(dto.StatusBar, defaults.StatusBar, errors); + var validationPanel = MapValidationPanel(dto.ValidationPanel, defaults.ValidationPanel, errors); + var orientation = MapOrientation(dto.Orientation, errors); - return new GridStyleOptions( + if (errors.Count > 0) + { + return Result.Fail(errors); + } + + return Result.Ok(new GridStyleOptions( Fonts: new GridStyleFonts( FontFamily: dto.Fonts?.Family ?? defaults.Fonts.FontFamily, HeaderFontSize: dto.Fonts?.HeaderSize ?? defaults.Fonts.HeaderFontSize, @@ -32,79 +56,240 @@ public static GridStyleOptions Map(GridStyleOptionsDto? dto) CellPaddingRight: dto.Layout?.CellPaddingRight ?? defaults.Layout.CellPaddingRight, CellPaddingBottom: dto.Layout?.CellPaddingBottom ?? defaults.Layout.CellPaddingBottom, RowHeight: dto.Layout?.RowHeight ?? defaults.Layout.RowHeight), - Selection: new SelectionColors( - Background: dto.Colors?.Selection?.Background ?? defaults.Selection.Background, - Foreground: dto.Colors?.Selection?.Foreground ?? defaults.Selection.Foreground), - ChangedCells: new ChangedCellColors( - Changed: dto.Colors?.Cells?.Changed ?? defaults.ChangedCells.Changed, - ChangedSelected: dto.Colors?.Cells?.ChangedSelected ?? defaults.ChangedCells.ChangedSelected), - ReadOnlyCells: new DepthPalette( - Depth0: readOnlyCells.Depth0!, - Depth1: readOnlyCells.Depth1!, - Depth2: readOnlyCells.Depth2!, - Depth3: readOnlyCells.Depth3!, - Depth0Past: readOnlyCells.Depth0Past!, - Depth1Past: readOnlyCells.Depth1Past!, - Depth2Past: readOnlyCells.Depth2Past!, - Depth3Past: readOnlyCells.Depth3Past!, - Selected: readOnlyCells.Selected!, - Foreground: readOnlyCells.Foreground!), - DisabledCells: new DepthPalette( - Depth0: disabledCells.Depth0!, - Depth1: disabledCells.Depth1!, - Depth2: disabledCells.Depth2!, - Depth3: disabledCells.Depth3!, - Depth0Past: disabledCells.Depth0Past!, - Depth1Past: disabledCells.Depth1Past!, - Depth2Past: disabledCells.Depth2Past!, - Depth3Past: disabledCells.Depth3Past!, - Selected: disabledCells.Selected!, - Foreground: disabledCells.Foreground!), - Execution: new ExecutionPalette( - Depth0: executionCells.Depth0!, - Depth1: executionCells.Depth1!, - Depth2: executionCells.Depth2!, - Depth3: executionCells.Depth3!, - Depth0Past: executionCells.Depth0Past!, - Depth1Past: executionCells.Depth1Past!, - Depth2Past: executionCells.Depth2Past!, - Depth3Past: executionCells.Depth3Past!, - CurrentStepMarker: executionCells.CurrentStepMarker!), - StatusBar: new StatusBarStyle( - Background: dto.StatusBar?.Background ?? defaults.StatusBar.Background, - Foreground: dto.StatusBar?.Foreground ?? defaults.StatusBar.Foreground, - Padding: dto.StatusBar?.Padding ?? defaults.StatusBar.Padding, - ItemSpacing: dto.StatusBar?.ItemSpacing ?? defaults.StatusBar.ItemSpacing, - FontSize: dto.StatusBar?.FontSize ?? defaults.StatusBar.FontSize, - Weight: dto.StatusBar?.Weight ?? defaults.StatusBar.Weight, - Italic: dto.StatusBar?.Italic ?? defaults.StatusBar.Italic, - TimerLabelFontSize: dto.StatusBar?.TimerLabelFontSize ?? defaults.StatusBar.TimerLabelFontSize, - TimerLabelWeight: dto.StatusBar?.TimerLabelWeight ?? defaults.StatusBar.TimerLabelWeight, - TimerLabelItalic: dto.StatusBar?.TimerLabelItalic ?? defaults.StatusBar.TimerLabelItalic, - TimerValueFontSize: dto.StatusBar?.TimerValueFontSize ?? defaults.StatusBar.TimerValueFontSize, - TimerValueWeight: dto.StatusBar?.TimerValueWeight ?? defaults.StatusBar.TimerValueWeight, - TimerValueItalic: dto.StatusBar?.TimerValueItalic ?? defaults.StatusBar.TimerValueItalic), - ValidationPanel: new ValidationPanelStyle( - Background: dto.ValidationPanel?.Background ?? defaults.ValidationPanel.Background, - Foreground: dto.ValidationPanel?.Foreground ?? defaults.ValidationPanel.Foreground, - ErrorColor: dto.ValidationPanel?.ErrorColor ?? defaults.ValidationPanel.ErrorColor, - WarningColor: dto.ValidationPanel?.WarningColor ?? defaults.ValidationPanel.WarningColor, - MaxHeight: dto.ValidationPanel?.MaxHeight ?? defaults.ValidationPanel.MaxHeight), - Chrome: new ChromeColors( - Info: dto.Chrome?.Info ?? defaults.Chrome.Info, - Connected: dto.Chrome?.Connected ?? defaults.Chrome.Connected, - Disconnected: dto.Chrome?.Disconnected ?? defaults.Chrome.Disconnected, - LocalMode: dto.Chrome?.LocalMode ?? defaults.Chrome.LocalMode, - Connecting: dto.Chrome?.Connecting ?? defaults.Chrome.Connecting, - PanelBackground: dto.Chrome?.PanelBackground ?? defaults.Chrome.PanelBackground, - PanelHeaderBackground: dto.Chrome?.PanelHeaderBackground ?? defaults.Chrome.PanelHeaderBackground, - SubtleBorder: dto.Chrome?.SubtleBorder ?? defaults.Chrome.SubtleBorder, - Separator: dto.Chrome?.Separator ?? defaults.Chrome.Separator, - SecondaryForeground: dto.Chrome?.SecondaryForeground ?? defaults.Chrome.SecondaryForeground, - GridBorder: dto.Chrome?.GridBorder ?? defaults.Chrome.GridBorder, - GridBackground: dto.Chrome?.GridBackground ?? defaults.Chrome.GridBackground, - HeaderForeground: dto.Chrome?.HeaderForeground ?? defaults.Chrome.HeaderForeground, - GridLine: dto.Colors?.GridLine ?? defaults.Chrome.GridLine), - Orientation: GridOrientationValues.Parse(dto.Orientation)); + Selection: selection, + ChangedCells: changedCells, + ReadOnlyCells: readOnly, + DisabledCells: disabled, + Execution: execution, + StatusBar: statusBar, + ValidationPanel: validationPanel, + Chrome: chrome, + Orientation: orientation)); + } + + private static ExecutionPalette MapExecution( + GridStyleExecutionColorsDto? dto, + ExecutionPalette defaults, + List errors) + { + var section = "colors.cells.execution"; + if (dto is null) + { + errors.Add(new GridStyleSectionMissingError(section)); + return defaults; + } + + return new ExecutionPalette( + Depth0: RequiredColor(dto.Depth0, section, "depth_0", errors), + Depth1: RequiredColor(dto.Depth1, section, "depth_1", errors), + Depth2: RequiredColor(dto.Depth2, section, "depth_2", errors), + Depth3: RequiredColor(dto.Depth3, section, "depth_3", errors), + Depth0Past: RequiredColor(dto.Depth0Past, section, "depth_0_past", errors), + Depth1Past: RequiredColor(dto.Depth1Past, section, "depth_1_past", errors), + Depth2Past: RequiredColor(dto.Depth2Past, section, "depth_2_past", errors), + Depth3Past: RequiredColor(dto.Depth3Past, section, "depth_3_past", errors), + CurrentStepMarker: RequiredColor(dto.CurrentStepMarker, section, "current_step_marker", errors)); + } + + private static DepthPalette MapReadOnly( + GridStyleReadOnlyCellColorsDto? dto, + DepthPalette defaults, + List errors) + { + var section = "colors.cells.readonly"; + if (dto is null) + { + errors.Add(new GridStyleSectionMissingError(section)); + return defaults; + } + + return new DepthPalette( + Depth0: RequiredColor(dto.Depth0, section, "depth_0", errors), + Depth1: RequiredColor(dto.Depth1, section, "depth_1", errors), + Depth2: RequiredColor(dto.Depth2, section, "depth_2", errors), + Depth3: RequiredColor(dto.Depth3, section, "depth_3", errors), + Depth0Past: RequiredColor(dto.Depth0Past, section, "depth_0_past", errors), + Depth1Past: RequiredColor(dto.Depth1Past, section, "depth_1_past", errors), + Depth2Past: RequiredColor(dto.Depth2Past, section, "depth_2_past", errors), + Depth3Past: RequiredColor(dto.Depth3Past, section, "depth_3_past", errors), + Selected: RequiredColor(dto.Selected, section, "selected", errors), + Foreground: RequiredColor(dto.Foreground, section, "foreground", errors)); + } + + private static DepthPalette MapDisabled( + GridStyleDisabledCellColorsDto? dto, + DepthPalette defaults, + List errors) + { + var section = "colors.cells.disabled"; + if (dto is null) + { + errors.Add(new GridStyleSectionMissingError(section)); + return defaults; + } + + return new DepthPalette( + Depth0: RequiredColor(dto.Depth0, section, "depth_0", errors), + Depth1: RequiredColor(dto.Depth1, section, "depth_1", errors), + Depth2: RequiredColor(dto.Depth2, section, "depth_2", errors), + Depth3: RequiredColor(dto.Depth3, section, "depth_3", errors), + Depth0Past: RequiredColor(dto.Depth0Past, section, "depth_0_past", errors), + Depth1Past: RequiredColor(dto.Depth1Past, section, "depth_1_past", errors), + Depth2Past: RequiredColor(dto.Depth2Past, section, "depth_2_past", errors), + Depth3Past: RequiredColor(dto.Depth3Past, section, "depth_3_past", errors), + Selected: RequiredColor(dto.Selected, section, "selected", errors), + Foreground: RequiredColor(dto.Foreground, section, "foreground", errors)); + } + + private static ChromeColors MapChrome( + GridStyleChromeColorsDto? dto, + ChromeColors defaults, + List errors) + { + var section = "chrome"; + return new ChromeColors( + Info: OptionalColor(dto?.Info, defaults.Info, section, "info", errors), + Connected: OptionalColor(dto?.Connected, defaults.Connected, section, "connected", errors), + Disconnected: OptionalColor(dto?.Disconnected, defaults.Disconnected, section, "disconnected", errors), + LocalMode: OptionalColor(dto?.LocalMode, defaults.LocalMode, section, "local_mode", errors), + Connecting: OptionalColor(dto?.Connecting, defaults.Connecting, section, "connecting", errors), + PanelBackground: OptionalColor( + dto?.PanelBackground, defaults.PanelBackground, section, "panel_background", errors), + PanelHeaderBackground: OptionalColor( + dto?.PanelHeaderBackground, defaults.PanelHeaderBackground, section, "panel_header_background", errors), + SubtleBorder: OptionalColor(dto?.SubtleBorder, defaults.SubtleBorder, section, "subtle_border", errors), + Separator: OptionalColor(dto?.Separator, defaults.Separator, section, "separator", errors), + SecondaryForeground: OptionalColor( + dto?.SecondaryForeground, defaults.SecondaryForeground, section, "secondary_foreground", errors), + GridBorder: OptionalColor(dto?.GridBorder, defaults.GridBorder, section, "grid_border", errors), + GridBackground: OptionalColor( + dto?.GridBackground, defaults.GridBackground, section, "grid_background", errors), + HeaderForeground: OptionalColor( + dto?.HeaderForeground, defaults.HeaderForeground, section, "header_foreground", errors), + // Placeholder: Map overwrites this with the parsed colors.grid_line so that key's error lands + // after changed_selected in emission order, not here in the chrome block. + GridLine: defaults.GridLine); + } + + private static SelectionColors MapSelection( + GridStyleSelectionColorsDto? dto, + SelectionColors defaults, + List errors) + { + var section = "colors.selection"; + return new SelectionColors( + Background: OptionalColor(dto?.Background, defaults.Background, section, "background", errors), + Foreground: OptionalColor(dto?.Foreground, defaults.Foreground, section, "foreground", errors)); + } + + private static ChangedCellColors MapChangedCells( + GridStyleCellColorsDto? cells, + ChangedCellColors defaults, + List errors) + { + var section = "colors.cells"; + return new ChangedCellColors( + Changed: OptionalColor(cells?.Changed, defaults.Changed, section, "changed", errors), + ChangedSelected: OptionalColor( + cells?.ChangedSelected, defaults.ChangedSelected, section, "changed_selected", errors)); + } + + private static StatusBarStyle MapStatusBar( + StatusBarStyleDto? dto, + StatusBarStyle defaults, + List errors) + { + var section = "status_bar"; + return new StatusBarStyle( + Background: OptionalColor(dto?.Background, defaults.Background, section, "background", errors), + Foreground: OptionalColor(dto?.Foreground, defaults.Foreground, section, "foreground", errors), + Padding: dto?.Padding ?? defaults.Padding, + ItemSpacing: dto?.ItemSpacing ?? defaults.ItemSpacing, + FontSize: dto?.FontSize ?? defaults.FontSize, + Weight: dto?.Weight ?? defaults.Weight, + Italic: dto?.Italic ?? defaults.Italic, + TimerLabelFontSize: dto?.TimerLabelFontSize ?? defaults.TimerLabelFontSize, + TimerLabelWeight: dto?.TimerLabelWeight ?? defaults.TimerLabelWeight, + TimerLabelItalic: dto?.TimerLabelItalic ?? defaults.TimerLabelItalic, + TimerValueFontSize: dto?.TimerValueFontSize ?? defaults.TimerValueFontSize, + TimerValueWeight: dto?.TimerValueWeight ?? defaults.TimerValueWeight, + TimerValueItalic: dto?.TimerValueItalic ?? defaults.TimerValueItalic); + } + + private static ValidationPanelStyle MapValidationPanel( + ValidationPanelStyleDto? dto, + ValidationPanelStyle defaults, + List errors) + { + var section = "validation_panel"; + return new ValidationPanelStyle( + Background: OptionalColor(dto?.Background, defaults.Background, section, "background", errors), + Foreground: OptionalColor(dto?.Foreground, defaults.Foreground, section, "foreground", errors), + ErrorColor: OptionalColor(dto?.ErrorColor, defaults.ErrorColor, section, "error_color", errors), + WarningColor: OptionalColor(dto?.WarningColor, defaults.WarningColor, section, "warning_color", errors), + MaxHeight: dto?.MaxHeight ?? defaults.MaxHeight); + } + + private static GridOrientation MapOrientation(string? orientation, List errors) + { + if (orientation is not null + && orientation != GridOrientationValues.RowsAsSteps + && orientation != GridOrientationValues.ColumnsAsSteps) + { + errors.Add(new GridStyleOrientationInvalidError( + orientation, + GridOrientationValues.RowsAsSteps, + GridOrientationValues.ColumnsAsSteps)); + return GridOrientation.RowsAsSteps; + } + + return GridOrientationValues.Parse(orientation); + } + + // The placeholder returned on error is discarded once `errors` is non-empty. + private static StyleColor RequiredColor(string? value, string section, string key, List errors) + { + if (string.IsNullOrWhiteSpace(value)) + { + errors.Add(new GridStyleKeyMissingError(section, key)); + return default; + } + + if (!StyleColor.TryParse(value, out var color)) + { + errors.Add(new GridStyleHexColorInvalidError(section, key, value)); + return default; + } + + return color; + } + + // Optional key: null falls back silently; present-but-whitespace is a + // missing key; a non-parsing value is an invalid hex. + private static StyleColor OptionalColor( + string? value, + StyleColor fallback, + string section, + string key, + List errors) + { + if (value is null) + { + return fallback; + } + + if (string.IsNullOrWhiteSpace(value)) + { + errors.Add(new GridStyleKeyMissingError(section, key)); + return fallback; + } + + if (!StyleColor.TryParse(value, out var color)) + { + errors.Add(new GridStyleHexColorInvalidError(section, key, value)); + return fallback; + } + + return color; } } diff --git a/SemiStep/SemiStep.Core/Configuration/SelectionColors.cs b/SemiStep/SemiStep.Core/Configuration/SelectionColors.cs index 087a5de5..5998b73d 100644 --- a/SemiStep/SemiStep.Core/Configuration/SelectionColors.cs +++ b/SemiStep/SemiStep.Core/Configuration/SelectionColors.cs @@ -1,5 +1,5 @@ namespace SemiStep.Core.Configuration; public sealed record SelectionColors( - string Background, - string Foreground); + StyleColor Background, + StyleColor Foreground); diff --git a/SemiStep/SemiStep.Core/Configuration/StatusBarStyle.cs b/SemiStep/SemiStep.Core/Configuration/StatusBarStyle.cs index a4fc107d..910266d8 100644 --- a/SemiStep/SemiStep.Core/Configuration/StatusBarStyle.cs +++ b/SemiStep/SemiStep.Core/Configuration/StatusBarStyle.cs @@ -1,8 +1,8 @@ namespace SemiStep.Core.Configuration; public sealed record StatusBarStyle( - string Background, - string Foreground, + StyleColor Background, + StyleColor Foreground, double Padding, double ItemSpacing, int FontSize, diff --git a/SemiStep/SemiStep.Core/Configuration/StyleColor.cs b/SemiStep/SemiStep.Core/Configuration/StyleColor.cs new file mode 100644 index 00000000..3ef36fe4 --- /dev/null +++ b/SemiStep/SemiStep.Core/Configuration/StyleColor.cs @@ -0,0 +1,95 @@ +using System; +using System.Globalization; + +namespace SemiStep.Core.Configuration; + +/// +/// A grid-style color as ARGB channels, parsed from and formatted to the config's +/// #RRGGBB / #AARRGGBB hex form. is manual rather than +/// deferring to a platform color type: the platform's Color.ToString() always emits the +/// #AARRGGBB form, which injects an #FF alpha prefix onto opaque colors and breaks +/// the config's #RRGGBB style on round-trip. Opaque colors ( equals +/// 0xFF) format as #RRGGBB; translucent colors format as #AARRGGBB. Casing +/// is normalized to uppercase. +/// +public readonly record struct StyleColor(byte A, byte R, byte G, byte B) +{ + /// + /// Parses a #RRGGBB or #AARRGGBB hex string. Throws + /// naming the offending input when the value is not one of those two exact forms. + /// + public static StyleColor Parse(string value) + { + if (!TryParse(value, out var color)) + { + throw new FormatException( + $"Invalid hex color: '{value}'. Expected format: '#RRGGBB' or '#AARRGGBB'."); + } + + return color; + } + + /// + /// Attempts to parse a hex color. Accepts exactly # followed by 6 hex digits + /// (opaque, set to 0xFF) or 8 hex digits (AARRGGBB channel + /// order), any letter case. Everything else — null, whitespace, missing #, shorthand + /// (#RGB / #ARGB), wrong length, non-hex characters — returns false. + /// + public static bool TryParse(string? value, out StyleColor color) + { + color = default; + + if (string.IsNullOrWhiteSpace(value) || value[0] != '#') + { + return false; + } + + if (value.Length == 7) + { + if (!TryParseChannel(value, 1, out var r) + || !TryParseChannel(value, 3, out var g) + || !TryParseChannel(value, 5, out var b)) + { + return false; + } + + color = new StyleColor(byte.MaxValue, r, g, b); + return true; + } + + if (value.Length == 9) + { + if (!TryParseChannel(value, 1, out var a) + || !TryParseChannel(value, 3, out var r) + || !TryParseChannel(value, 5, out var g) + || !TryParseChannel(value, 7, out var b)) + { + return false; + } + + color = new StyleColor(a, r, g, b); + return true; + } + + return false; + } + + public override string ToString() + { + return A == byte.MaxValue + ? $"#{R:X2}{G:X2}{B:X2}" + : $"#{A:X2}{R:X2}{G:X2}{B:X2}"; + } + + // AllowHexSpecifier alone is strict: it accepts only hex digits, rejecting sign and whitespace (unlike + // NumberStyles.HexNumber, which also permits leading/trailing whitespace). The two-char span never + // includes the leading '#', which callers strip via the fixed indices. + private static bool TryParseChannel(string value, int index, out byte channel) + { + return byte.TryParse( + value.AsSpan(index, 2), + NumberStyles.AllowHexSpecifier, + CultureInfo.InvariantCulture, + out channel); + } +} diff --git a/SemiStep/SemiStep.Core/Configuration/Validation/GridStyleValidator.cs b/SemiStep/SemiStep.Core/Configuration/Validation/GridStyleValidator.cs deleted file mode 100644 index dcbc82c0..00000000 --- a/SemiStep/SemiStep.Core/Configuration/Validation/GridStyleValidator.cs +++ /dev/null @@ -1,242 +0,0 @@ -using System.Text.RegularExpressions; - -using FluentResults; - -using SemiStep.Core.Configuration.Dto; - -namespace SemiStep.Core.Configuration.Validation; - -internal static class GridStyleValidator -{ - private static readonly Regex _hexColorRegex = new( - "^#([0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$", - RegexOptions.Compiled); - - public static Result Validate(GridStyleOptionsDto? dto) - { - if (dto is null) - { - return Result.Fail(new GridStyleConfigMissingError()); - } - - if (dto.Colors is null) - { - return Result.Fail(new GridStyleSectionMissingError("colors")); - } - - var errors = new List(); - - var execution = dto.Colors.Cells?.Execution; - ValidateSection( - "colors.cells.execution", - execution is null - ? null - : new (string Name, string? Value)[] - { - ("depth_0", execution.Depth0), - ("depth_1", execution.Depth1), - ("depth_2", execution.Depth2), - ("depth_3", execution.Depth3), - ("depth_0_past", execution.Depth0Past), - ("depth_1_past", execution.Depth1Past), - ("depth_2_past", execution.Depth2Past), - ("depth_3_past", execution.Depth3Past), - ("current_step_marker", execution.CurrentStepMarker) - }, - errors); - - var readOnlyCells = dto.Colors.Cells?.ReadOnly; - ValidateSection( - "colors.cells.readonly", - readOnlyCells is null - ? null - : new (string Name, string? Value)[] - { - ("depth_0", readOnlyCells.Depth0), - ("depth_1", readOnlyCells.Depth1), - ("depth_2", readOnlyCells.Depth2), - ("depth_3", readOnlyCells.Depth3), - ("depth_0_past", readOnlyCells.Depth0Past), - ("depth_1_past", readOnlyCells.Depth1Past), - ("depth_2_past", readOnlyCells.Depth2Past), - ("depth_3_past", readOnlyCells.Depth3Past), - ("selected", readOnlyCells.Selected), - ("foreground", readOnlyCells.Foreground) - }, - errors); - - var disabled = dto.Colors.Cells?.Disabled; - ValidateSection( - "colors.cells.disabled", - disabled is null - ? null - : new (string Name, string? Value)[] - { - ("depth_0", disabled.Depth0), - ("depth_1", disabled.Depth1), - ("depth_2", disabled.Depth2), - ("depth_3", disabled.Depth3), - ("depth_0_past", disabled.Depth0Past), - ("depth_1_past", disabled.Depth1Past), - ("depth_2_past", disabled.Depth2Past), - ("depth_3_past", disabled.Depth3Past), - ("selected", disabled.Selected), - ("foreground", disabled.Foreground) - }, - errors); - - var chrome = dto.Chrome; - ValidateOptionalSection( - "chrome", - chrome is null - ? null - : new (string Name, string? Value)[] - { - ("info", chrome.Info), - ("connected", chrome.Connected), - ("disconnected", chrome.Disconnected), - ("local_mode", chrome.LocalMode), - ("connecting", chrome.Connecting), - ("panel_background", chrome.PanelBackground), - ("panel_header_background", chrome.PanelHeaderBackground), - ("subtle_border", chrome.SubtleBorder), - ("separator", chrome.Separator), - ("secondary_foreground", chrome.SecondaryForeground), - ("grid_border", chrome.GridBorder), - ("grid_background", chrome.GridBackground), - ("header_foreground", chrome.HeaderForeground) - }, - errors); - - var selection = dto.Colors.Selection; - ValidateOptionalSection( - "colors.selection", - selection is null - ? null - : new (string Name, string? Value)[] - { - ("background", selection.Background), - ("foreground", selection.Foreground) - }, - errors); - - var cells = dto.Colors.Cells; - ValidateOptionalSection( - "colors.cells", - cells is null - ? null - : new (string Name, string? Value)[] - { - ("changed", cells.Changed), - ("changed_selected", cells.ChangedSelected) - }, - errors); - - if (dto.Colors.GridLine is not null) - { - ValidateKey("colors", "grid_line", dto.Colors.GridLine, errors); - } - - var statusBar = dto.StatusBar; - ValidateOptionalSection( - "status_bar", - statusBar is null - ? null - : new (string Name, string? Value)[] - { - ("background", statusBar.Background), - ("foreground", statusBar.Foreground) - }, - errors); - - var validationPanel = dto.ValidationPanel; - ValidateOptionalSection( - "validation_panel", - validationPanel is null - ? null - : new (string Name, string? Value)[] - { - ("background", validationPanel.Background), - ("foreground", validationPanel.Foreground), - ("error_color", validationPanel.ErrorColor), - ("warning_color", validationPanel.WarningColor) - }, - errors); - - ValidateOrientation(dto.Orientation, errors); - - if (errors.Count > 0) - { - return Result.Fail(errors); - } - - return Result.Ok(); - } - - private static void ValidateOrientation(string? orientation, List errors) - { - if (orientation is null - || orientation == GridOrientationValues.RowsAsSteps - || orientation == GridOrientationValues.ColumnsAsSteps) - { - return; - } - - errors.Add(new GridStyleOrientationInvalidError( - orientation, - GridOrientationValues.RowsAsSteps, - GridOrientationValues.ColumnsAsSteps)); - } - - private static void ValidateSection( - string sectionPath, - IReadOnlyList<(string Name, string? Value)>? keys, - List errors) - { - if (keys is null) - { - errors.Add(new GridStyleSectionMissingError(sectionPath)); - return; - } - - foreach (var (name, value) in keys) - { - ValidateKey(sectionPath, name, value, errors); - } - } - - private static void ValidateOptionalSection( - string sectionPath, - IReadOnlyList<(string Name, string? Value)>? keys, - List errors) - { - if (keys is null) - { - return; - } - - foreach (var (name, value) in keys) - { - if (value is null) - { - continue; - } - - ValidateKey(sectionPath, name, value, errors); - } - } - - private static void ValidateKey(string sectionPath, string keyName, string? value, List errors) - { - if (string.IsNullOrWhiteSpace(value)) - { - errors.Add(new GridStyleKeyMissingError(sectionPath, keyName)); - return; - } - - if (!_hexColorRegex.IsMatch(value)) - { - errors.Add(new GridStyleHexColorInvalidError(sectionPath, keyName, value)); - } - } -} diff --git a/SemiStep/SemiStep.Core/Configuration/ValidationPanelStyle.cs b/SemiStep/SemiStep.Core/Configuration/ValidationPanelStyle.cs index eb827430..c203b72f 100644 --- a/SemiStep/SemiStep.Core/Configuration/ValidationPanelStyle.cs +++ b/SemiStep/SemiStep.Core/Configuration/ValidationPanelStyle.cs @@ -1,8 +1,8 @@ namespace SemiStep.Core.Configuration; public sealed record ValidationPanelStyle( - string Background, - string Foreground, - string ErrorColor, - string WarningColor, + StyleColor Background, + StyleColor Foreground, + StyleColor ErrorColor, + StyleColor WarningColor, double MaxHeight); diff --git a/SemiStep/SemiStep.Tests/Core/Configuration/ConfigFacadeGridStyleValidationTests.cs b/SemiStep/SemiStep.Tests/Core/Configuration/ConfigFacadeGridStyleValidationTests.cs index 18b433be..130559a5 100644 --- a/SemiStep/SemiStep.Tests/Core/Configuration/ConfigFacadeGridStyleValidationTests.cs +++ b/SemiStep/SemiStep.Tests/Core/Configuration/ConfigFacadeGridStyleValidationTests.cs @@ -20,8 +20,8 @@ public async Task LoadAndValidateAsync_StandardFixture_LoadsSuccessfully() var result = await ConfigFacade.LoadAndValidateAsync(tempDir.Path); result.IsSuccess.Should().BeTrue(); - result.Value.GridStyle.Execution.Depth0.Should().StartWith("#"); - result.Value.GridStyle.Execution.CurrentStepMarker.Should().StartWith("#"); + result.Value.GridStyle.Execution.Depth0.ToString().Should().StartWith("#"); + result.Value.GridStyle.Execution.CurrentStepMarker.ToString().Should().StartWith("#"); } [Fact] diff --git a/SemiStep/SemiStep.Tests/Core/Configuration/GridStyleColorsValidationTests.cs b/SemiStep/SemiStep.Tests/Core/Configuration/GridStyleColorsValidationTests.cs index 9b1dc45f..63878deb 100644 --- a/SemiStep/SemiStep.Tests/Core/Configuration/GridStyleColorsValidationTests.cs +++ b/SemiStep/SemiStep.Tests/Core/Configuration/GridStyleColorsValidationTests.cs @@ -1,7 +1,10 @@ -using FluentAssertions; +using System.Linq; +using FluentAssertions; + +using SemiStep.Core.Configuration; using SemiStep.Core.Configuration.Dto; -using SemiStep.Core.Configuration.Validation; +using SemiStep.Core.Configuration.Mapping; using Xunit; @@ -17,7 +20,7 @@ public void Validate_ValidDto_Succeeds() { var dto = CreateValidDto(); - var result = GridStyleValidator.Validate(dto); + var result = GridStyleMapper.Map(dto); result.IsSuccess.Should().BeTrue(); } @@ -25,7 +28,7 @@ public void Validate_ValidDto_Succeeds() [Fact] public void Validate_NullDto_Fails() { - var result = GridStyleValidator.Validate(null); + var result = GridStyleMapper.Map(null); result.IsFailed.Should().BeTrue(); result.Errors.Should().Contain(e => e.Message.Contains("missing")); @@ -36,7 +39,7 @@ public void Validate_MissingColors_Fails() { var dto = new GridStyleOptionsDto(); - var result = GridStyleValidator.Validate(dto); + var result = GridStyleMapper.Map(dto); result.IsFailed.Should().BeTrue(); result.Errors.Should().Contain(e => e.Message.Contains("colors")); @@ -57,7 +60,7 @@ public void Validate_MissingExecutionSection_Fails() } }; - var result = GridStyleValidator.Validate(dto); + var result = GridStyleMapper.Map(dto); result.IsFailed.Should().BeTrue(); result.Errors.Should().Contain(e => e.Message.Contains("colors.cells.execution")); @@ -78,7 +81,7 @@ public void Validate_MissingDisabledSection_Fails() } }; - var result = GridStyleValidator.Validate(dto); + var result = GridStyleMapper.Map(dto); result.IsFailed.Should().BeTrue(); result.Errors.Should().Contain(e => e.Message.Contains("colors.cells.disabled")); @@ -99,12 +102,29 @@ public void Validate_MissingReadOnlySection_Fails() } }; - var result = GridStyleValidator.Validate(dto); + var result = GridStyleMapper.Map(dto); result.IsFailed.Should().BeTrue(); result.Errors.Should().Contain(e => e.Message.Contains("colors.cells.readonly")); } + [Fact] + public void Validate_NullCells_FailsWithExactlyThreeSectionErrors() + { + var dto = CreateValidDto(); + dto.Colors!.Cells = null; + + var result = GridStyleMapper.Map(dto); + + result.IsFailed.Should().BeTrue(); + result.Errors.Should().OnlyContain(error => error is GridStyleSectionMissingError); + result.Errors + .Cast() + .Select(error => error.Section) + .Should() + .BeEquivalentTo("colors.cells.execution", "colors.cells.readonly", "colors.cells.disabled"); + } + public static TheoryData DisabledKeyNames => new() { @@ -136,7 +156,7 @@ public void Validate_MissingDisabledKey_FailsWithKeyName(string keyName) var dto = CreateValidDto(); ClearDisabledKey(dto, keyName); - var result = GridStyleValidator.Validate(dto); + var result = GridStyleMapper.Map(dto); result.IsFailed.Should().BeTrue(); result.Errors.Should().Contain(e => e.Message.Contains($"'colors.cells.disabled.{keyName}'")); @@ -149,7 +169,7 @@ public void Validate_MissingReadOnlyKey_FailsWithKeyName(string keyName) var dto = CreateValidDto(); ClearReadOnlyKey(dto, keyName); - var result = GridStyleValidator.Validate(dto); + var result = GridStyleMapper.Map(dto); result.IsFailed.Should().BeTrue(); result.Errors.Should().Contain(e => e.Message.Contains($"'colors.cells.readonly.{keyName}'")); @@ -162,7 +182,7 @@ public void Validate_MissingIndividualKey_FailsWithKeyName(string keyName) var dto = CreateValidDto(); ClearKey(dto, keyName); - var result = GridStyleValidator.Validate(dto); + var result = GridStyleMapper.Map(dto); result.IsFailed.Should().BeTrue(); result.Errors.Should().Contain(e => e.Message.Contains($"'colors.cells.execution.{keyName}'")); @@ -178,7 +198,7 @@ public void Validate_MalformedHex_FailsNamingKey(string badHex) var dto = CreateValidDto(); dto.Colors!.Cells!.Execution!.Depth0 = badHex; - var result = GridStyleValidator.Validate(dto); + var result = GridStyleMapper.Map(dto); result.IsFailed.Should().BeTrue(); result.Errors.Should().Contain(e => @@ -191,7 +211,7 @@ public void Validate_EmptyValue_FailsNamingKey() var dto = CreateValidDto(); dto.Colors!.Cells!.Execution!.Depth0 = ""; - var result = GridStyleValidator.Validate(dto); + var result = GridStyleMapper.Map(dto); result.IsFailed.Should().BeTrue(); result.Errors.Should().Contain(e => e.Message.Contains("'colors.cells.execution.depth_0'")); @@ -205,7 +225,7 @@ public void Validate_MultipleErrors_AllCollected() dto.Colors!.Cells!.Execution!.Depth1 = null; dto.Colors!.Cells!.Execution!.CurrentStepMarker = "#GG"; - var result = GridStyleValidator.Validate(dto); + var result = GridStyleMapper.Map(dto); result.IsFailed.Should().BeTrue(); result.Errors.Should().HaveCountGreaterThanOrEqualTo(3); @@ -225,7 +245,7 @@ public void Validate_ValidHexFormats_Succeeds(string hex) var dto = CreateValidDto(); dto.Colors!.Cells!.Execution!.Depth0 = hex; - var result = GridStyleValidator.Validate(dto); + var result = GridStyleMapper.Map(dto); result.IsSuccess.Should().BeTrue(); } @@ -241,7 +261,7 @@ public void Validate_ValidChromeSection_Succeeds() HeaderForeground = "#000000" }; - var result = GridStyleValidator.Validate(dto); + var result = GridStyleMapper.Map(dto); result.IsSuccess.Should().BeTrue(); } @@ -255,7 +275,7 @@ public void Validate_MalformedChromeHex_FailsNamingKey() GridBorder = "not-a-color" }; - var result = GridStyleValidator.Validate(dto); + var result = GridStyleMapper.Map(dto); result.IsFailed.Should().BeTrue(); result.Errors.Should().Contain(e => @@ -272,7 +292,7 @@ public void Validate_ValidSelectionSection_Succeeds() Foreground = "#202020" }; - var result = GridStyleValidator.Validate(dto); + var result = GridStyleMapper.Map(dto); result.IsSuccess.Should().BeTrue(); } @@ -289,7 +309,7 @@ public void Validate_MalformedSelectionHex_FailsNamingKey(string keyName) Foreground = keyName == "foreground" ? "not-a-color" : "#202020" }; - var result = GridStyleValidator.Validate(dto); + var result = GridStyleMapper.Map(dto); result.IsFailed.Should().BeTrue(); result.Errors.Should().Contain(e => @@ -303,7 +323,7 @@ public void Validate_ValidChangedCellColors_Succeeds() dto.Colors!.Cells!.Changed = "#FFCC80"; dto.Colors!.Cells!.ChangedSelected = "#EFD3A4"; - var result = GridStyleValidator.Validate(dto); + var result = GridStyleMapper.Map(dto); result.IsSuccess.Should().BeTrue(); } @@ -317,7 +337,7 @@ public void Validate_MalformedChangedCellHex_FailsNamingKey(string keyName) dto.Colors!.Cells!.Changed = keyName == "changed" ? "not-a-color" : "#FFCC80"; dto.Colors!.Cells!.ChangedSelected = keyName == "changed_selected" ? "not-a-color" : "#EFD3A4"; - var result = GridStyleValidator.Validate(dto); + var result = GridStyleMapper.Map(dto); result.IsFailed.Should().BeTrue(); result.Errors.Should().Contain(e => @@ -330,7 +350,7 @@ public void Validate_ValidGridLine_Succeeds() var dto = CreateValidDto(); dto.Colors!.GridLine = "#CCCCCC"; - var result = GridStyleValidator.Validate(dto); + var result = GridStyleMapper.Map(dto); result.IsSuccess.Should().BeTrue(); } @@ -341,7 +361,7 @@ public void Validate_MalformedGridLineHex_FailsNamingKey() var dto = CreateValidDto(); dto.Colors!.GridLine = "not-a-color"; - var result = GridStyleValidator.Validate(dto); + var result = GridStyleMapper.Map(dto); result.IsFailed.Should().BeTrue(); result.Errors.Should().Contain(e => @@ -358,7 +378,7 @@ public void Validate_ValidStatusBarSection_Succeeds() Foreground = "#111111" }; - var result = GridStyleValidator.Validate(dto); + var result = GridStyleMapper.Map(dto); result.IsSuccess.Should().BeTrue(); } @@ -375,7 +395,7 @@ public void Validate_MalformedStatusBarHex_FailsNamingKey(string keyName) Foreground = keyName == "foreground" ? "not-a-color" : "#111111" }; - var result = GridStyleValidator.Validate(dto); + var result = GridStyleMapper.Map(dto); result.IsFailed.Should().BeTrue(); result.Errors.Should().Contain(e => @@ -394,7 +414,7 @@ public void Validate_ValidValidationPanelSection_Succeeds() WarningColor = "#F57C00" }; - var result = GridStyleValidator.Validate(dto); + var result = GridStyleMapper.Map(dto); result.IsSuccess.Should().BeTrue(); } @@ -415,7 +435,7 @@ public void Validate_MalformedValidationPanelHex_FailsNamingKey(string keyName) WarningColor = keyName == "warning_color" ? "not-a-color" : "#F57C00" }; - var result = GridStyleValidator.Validate(dto); + var result = GridStyleMapper.Map(dto); result.IsFailed.Should().BeTrue(); result.Errors.Should().Contain(e => diff --git a/SemiStep/SemiStep.Tests/Core/Configuration/GridStyleEditorFacadeTests.cs b/SemiStep/SemiStep.Tests/Core/Configuration/GridStyleEditorFacadeTests.cs index ed25be8b..e01cb98a 100644 --- a/SemiStep/SemiStep.Tests/Core/Configuration/GridStyleEditorFacadeTests.cs +++ b/SemiStep/SemiStep.Tests/Core/Configuration/GridStyleEditorFacadeTests.cs @@ -27,7 +27,7 @@ public async Task Load_ShippedConfig_ReturnsPopulatedRecord(string equipment) var result = await new GridStyleEditorFacade().Load(tempDir.Path); result.IsSuccess.Should().BeTrue(); - result.Value.Selection.Background.Should().NotBeNullOrWhiteSpace(); + result.Value.Selection.Background.ToString().Should().StartWith("#"); } [Fact] @@ -79,36 +79,6 @@ public async Task Validate_ShippedRecord_ReturnsOk() facade.Validate(loaded).IsSuccess.Should().BeTrue(); } - [Fact] - public void Validate_MalformedColor_ReturnsFail() - { - var defaults = GridStyleOptions.Default; - var invalid = defaults with { Execution = defaults.Execution with { Depth0 = "not-a-color" } }; - - new GridStyleEditorFacade().Validate(invalid).IsFailed.Should().BeTrue(); - } - - [Fact] - public async Task Save_InvalidRecord_FailsValidationGuardBeforeWriting() - { - using var tempDir = CopyShippedConfig("MBE"); - var facade = new GridStyleEditorFacade(); - var filePath = Path.Combine(tempDir.Path, "ui", "grid_style.yaml"); - - var token = TestContext.Current.CancellationToken; - var before = await File.ReadAllTextAsync(filePath, token); - - var loadedForInvalid = (await facade.Load(tempDir.Path)).Value; - var invalid = loadedForInvalid - with - { Execution = loadedForInvalid.Execution with { Depth0 = "not-a-color" } }; - - (await facade.Save(tempDir.Path, invalid)).IsFailed.Should().BeTrue(); - - var after = await File.ReadAllTextAsync(filePath, token); - after.Should().Be(before); - } - [Fact] public async Task Load_UnparseableYaml_CarriesOriginalExceptionOnCausedBy() { diff --git a/SemiStep/SemiStep.Tests/Core/Configuration/GridStyleMapperTests.cs b/SemiStep/SemiStep.Tests/Core/Configuration/GridStyleMapperTests.cs index 31274290..73da1c8c 100644 --- a/SemiStep/SemiStep.Tests/Core/Configuration/GridStyleMapperTests.cs +++ b/SemiStep/SemiStep.Tests/Core/Configuration/GridStyleMapperTests.cs @@ -18,44 +18,44 @@ public void Map_WiresEveryCellPaletteFieldFromDto() { var dto = BuildCompleteDto(); - var options = GridStyleMapper.Map(dto); - - options.Selection.Background.Should().Be("#ABCDEF"); - options.Selection.Foreground.Should().Be("#FEDCBA"); - options.ChangedCells.Changed.Should().Be("#0ABBCD"); - options.ChangedCells.ChangedSelected.Should().Be("#0DCCEF"); - - options.ReadOnlyCells.Depth0.Should().Be("#11111B"); - options.ReadOnlyCells.Depth1.Should().Be("#22222B"); - options.ReadOnlyCells.Depth2.Should().Be("#33333B"); - options.ReadOnlyCells.Depth3.Should().Be("#44444B"); - options.ReadOnlyCells.Depth0Past.Should().Be("#55555B"); - options.ReadOnlyCells.Depth1Past.Should().Be("#66666B"); - options.ReadOnlyCells.Depth2Past.Should().Be("#77777B"); - options.ReadOnlyCells.Depth3Past.Should().Be("#88888B"); - options.ReadOnlyCells.Selected.Should().Be("#99999B"); - options.ReadOnlyCells.Foreground.Should().Be("#AAAAAC"); - - options.DisabledCells.Depth0.Should().Be("#11111A"); - options.DisabledCells.Depth1.Should().Be("#22222A"); - options.DisabledCells.Depth2.Should().Be("#33333A"); - options.DisabledCells.Depth3.Should().Be("#44444A"); - options.DisabledCells.Depth0Past.Should().Be("#55555A"); - options.DisabledCells.Depth1Past.Should().Be("#66666A"); - options.DisabledCells.Depth2Past.Should().Be("#77777A"); - options.DisabledCells.Depth3Past.Should().Be("#88888A"); - options.DisabledCells.Selected.Should().Be("#99999A"); - options.DisabledCells.Foreground.Should().Be("#AAAAAB"); - - options.Execution.Depth0.Should().Be("#111111"); - options.Execution.Depth1.Should().Be("#222222"); - options.Execution.Depth2.Should().Be("#333333"); - options.Execution.Depth3.Should().Be("#444444"); - options.Execution.Depth0Past.Should().Be("#555555"); - options.Execution.Depth1Past.Should().Be("#666666"); - options.Execution.Depth2Past.Should().Be("#777777"); - options.Execution.Depth3Past.Should().Be("#888888"); - options.Execution.CurrentStepMarker.Should().Be("#999999"); + var options = GridStyleMapper.Map(dto).Value; + + options.Selection.Background.Should().Be(StyleColor.Parse("#ABCDEF")); + options.Selection.Foreground.Should().Be(StyleColor.Parse("#FEDCBA")); + options.ChangedCells.Changed.Should().Be(StyleColor.Parse("#0ABBCD")); + options.ChangedCells.ChangedSelected.Should().Be(StyleColor.Parse("#0DCCEF")); + + options.ReadOnlyCells.Depth0.Should().Be(StyleColor.Parse("#11111B")); + options.ReadOnlyCells.Depth1.Should().Be(StyleColor.Parse("#22222B")); + options.ReadOnlyCells.Depth2.Should().Be(StyleColor.Parse("#33333B")); + options.ReadOnlyCells.Depth3.Should().Be(StyleColor.Parse("#44444B")); + options.ReadOnlyCells.Depth0Past.Should().Be(StyleColor.Parse("#55555B")); + options.ReadOnlyCells.Depth1Past.Should().Be(StyleColor.Parse("#66666B")); + options.ReadOnlyCells.Depth2Past.Should().Be(StyleColor.Parse("#77777B")); + options.ReadOnlyCells.Depth3Past.Should().Be(StyleColor.Parse("#88888B")); + options.ReadOnlyCells.Selected.Should().Be(StyleColor.Parse("#99999B")); + options.ReadOnlyCells.Foreground.Should().Be(StyleColor.Parse("#AAAAAC")); + + options.DisabledCells.Depth0.Should().Be(StyleColor.Parse("#11111A")); + options.DisabledCells.Depth1.Should().Be(StyleColor.Parse("#22222A")); + options.DisabledCells.Depth2.Should().Be(StyleColor.Parse("#33333A")); + options.DisabledCells.Depth3.Should().Be(StyleColor.Parse("#44444A")); + options.DisabledCells.Depth0Past.Should().Be(StyleColor.Parse("#55555A")); + options.DisabledCells.Depth1Past.Should().Be(StyleColor.Parse("#66666A")); + options.DisabledCells.Depth2Past.Should().Be(StyleColor.Parse("#77777A")); + options.DisabledCells.Depth3Past.Should().Be(StyleColor.Parse("#88888A")); + options.DisabledCells.Selected.Should().Be(StyleColor.Parse("#99999A")); + options.DisabledCells.Foreground.Should().Be(StyleColor.Parse("#AAAAAB")); + + options.Execution.Depth0.Should().Be(StyleColor.Parse("#111111")); + options.Execution.Depth1.Should().Be(StyleColor.Parse("#222222")); + options.Execution.Depth2.Should().Be(StyleColor.Parse("#333333")); + options.Execution.Depth3.Should().Be(StyleColor.Parse("#444444")); + options.Execution.Depth0Past.Should().Be(StyleColor.Parse("#555555")); + options.Execution.Depth1Past.Should().Be(StyleColor.Parse("#666666")); + options.Execution.Depth2Past.Should().Be(StyleColor.Parse("#777777")); + options.Execution.Depth3Past.Should().Be(StyleColor.Parse("#888888")); + options.Execution.CurrentStepMarker.Should().Be(StyleColor.Parse("#999999")); } [Fact] @@ -79,21 +79,21 @@ public void Map_WiresEveryChromeFieldFromDto() HeaderForeground = "#000000" }; - var options = GridStyleMapper.Map(dto); - - options.Chrome.Info.Should().Be("#1976D2"); - options.Chrome.Connected.Should().Be("#44BB44"); - options.Chrome.Disconnected.Should().Be("#FF4444"); - options.Chrome.LocalMode.Should().Be("#6C707E"); - options.Chrome.Connecting.Should().Be("#FFAF0F"); - options.Chrome.PanelBackground.Should().Be("#F8F8F8"); - options.Chrome.PanelHeaderBackground.Should().Be("#EEEEEE"); - options.Chrome.SubtleBorder.Should().Be("#D0D0D0"); - options.Chrome.Separator.Should().Be("#C0C0C0"); - options.Chrome.SecondaryForeground.Should().Be("#888888"); - options.Chrome.GridBorder.Should().Be("#808080"); - options.Chrome.GridBackground.Should().Be("#FFFFFF"); - options.Chrome.HeaderForeground.Should().Be("#000000"); + var options = GridStyleMapper.Map(dto).Value; + + options.Chrome.Info.Should().Be(StyleColor.Parse("#1976D2")); + options.Chrome.Connected.Should().Be(StyleColor.Parse("#44BB44")); + options.Chrome.Disconnected.Should().Be(StyleColor.Parse("#FF4444")); + options.Chrome.LocalMode.Should().Be(StyleColor.Parse("#6C707E")); + options.Chrome.Connecting.Should().Be(StyleColor.Parse("#FFAF0F")); + options.Chrome.PanelBackground.Should().Be(StyleColor.Parse("#F8F8F8")); + options.Chrome.PanelHeaderBackground.Should().Be(StyleColor.Parse("#EEEEEE")); + options.Chrome.SubtleBorder.Should().Be(StyleColor.Parse("#D0D0D0")); + options.Chrome.Separator.Should().Be(StyleColor.Parse("#C0C0C0")); + options.Chrome.SecondaryForeground.Should().Be(StyleColor.Parse("#888888")); + options.Chrome.GridBorder.Should().Be(StyleColor.Parse("#808080")); + options.Chrome.GridBackground.Should().Be(StyleColor.Parse("#FFFFFF")); + options.Chrome.HeaderForeground.Should().Be(StyleColor.Parse("#000000")); } [Fact] @@ -101,7 +101,7 @@ public void Map_OmittedChromeSection_FallsBackToDefaults() { var dto = BuildCompleteDto(); - var options = GridStyleMapper.Map(dto); + var options = GridStyleMapper.Map(dto).Value; options.Chrome.Info.Should().Be(GridStyleOptions.Default.Chrome.Info); options.Chrome.LocalMode.Should().Be(GridStyleOptions.Default.Chrome.LocalMode); @@ -111,11 +111,12 @@ public void Map_OmittedChromeSection_FallsBackToDefaults() } [Fact] - public void Map_NullDto_ReturnsDefaults() + public void Map_NullDto_FailsWithConfigMissing() { - var options = GridStyleMapper.Map(null); + var result = GridStyleMapper.Map(null); - options.Should().Be(GridStyleOptions.Default); + result.IsFailed.Should().BeTrue(); + result.Errors.Should().ContainSingle().Which.Should().BeOfType(); } [Fact] @@ -124,7 +125,7 @@ public void Map_OmittedChangedColor_FallsBackToDefault() var dto = BuildCompleteDto(); dto.Colors!.Cells!.Changed = null; - var options = GridStyleMapper.Map(dto); + var options = GridStyleMapper.Map(dto).Value; options.ChangedCells.Changed.Should().Be(GridStyleOptions.Default.ChangedCells.Changed); } @@ -146,7 +147,7 @@ public void Map_WiresStatusBarFontFieldsFromDto() TimerValueItalic = true }; - var options = GridStyleMapper.Map(dto); + var options = GridStyleMapper.Map(dto).Value; options.StatusBar.FontSize.Should().Be(16); options.StatusBar.Weight.Should().Be(500); @@ -174,7 +175,7 @@ public void Map_WiresGridFontFieldsFromDto() CellItalic = true }; - var options = GridStyleMapper.Map(dto); + var options = GridStyleMapper.Map(dto).Value; options.Fonts.FontFamily.Should().Be("Cascadia Mono"); options.Fonts.HeaderFontSize.Should().Be(20); @@ -190,7 +191,7 @@ public void Map_OmittedFontFields_FallBackToDefaults() { var dto = BuildCompleteDto(); - var options = GridStyleMapper.Map(dto); + var options = GridStyleMapper.Map(dto).Value; options.Fonts.FontFamily.Should().Be(GridStyleOptions.Default.Fonts.FontFamily); options.Fonts.HeaderFontSize.Should().Be(14); @@ -264,7 +265,7 @@ public void Map_OptionsToDto_WritesEveryFontField() public void Map_OptionsToDto_WritesLocalModeChromeColor() { var defaults = GridStyleOptions.Default; - var options = defaults with { Chrome = defaults.Chrome with { LocalMode = "#123456" } }; + var options = defaults with { Chrome = defaults.Chrome with { LocalMode = StyleColor.Parse("#123456") } }; var dto = GridStyleDtoMapper.Map(options); @@ -275,7 +276,7 @@ public void Map_OptionsToDto_WritesLocalModeChromeColor() public void Map_OptionsToDto_WritesConnectingChromeColor() { var defaults = GridStyleOptions.Default; - var options = defaults with { Chrome = defaults.Chrome with { Connecting = "#123456" } }; + var options = defaults with { Chrome = defaults.Chrome with { Connecting = StyleColor.Parse("#123456") } }; var dto = GridStyleDtoMapper.Map(options); diff --git a/SemiStep/SemiStep.Tests/Core/Configuration/GridStyleOrientationTests.cs b/SemiStep/SemiStep.Tests/Core/Configuration/GridStyleOrientationTests.cs index 1130cdae..8f97d317 100644 --- a/SemiStep/SemiStep.Tests/Core/Configuration/GridStyleOrientationTests.cs +++ b/SemiStep/SemiStep.Tests/Core/Configuration/GridStyleOrientationTests.cs @@ -4,7 +4,6 @@ using SemiStep.Core.Configuration.Dto; using SemiStep.Core.Configuration.Loaders; using SemiStep.Core.Configuration.Mapping; -using SemiStep.Core.Configuration.Validation; using SemiStep.Tests.Config.Helpers; using SemiStep.Tests.Helpers; @@ -52,7 +51,7 @@ public async Task Validate_UnknownOrientation_Fails(string yamlValue) var loadResult = await GridStyleLoader.LoadAsync(tempDir.Path); loadResult.IsSuccess.Should().BeTrue(); - var validation = GridStyleValidator.Validate(loadResult.Value); + var validation = GridStyleMapper.Map(loadResult.Value); validation.IsFailed.Should().BeTrue(); validation.Errors.Should().Contain(error => @@ -118,8 +117,9 @@ private static async Task LoadValidated(string configDir) { var loadResult = await GridStyleLoader.LoadAsync(configDir); loadResult.IsSuccess.Should().BeTrue(); - GridStyleValidator.Validate(loadResult.Value).IsSuccess.Should().BeTrue(); - return GridStyleMapper.Map(loadResult.Value); + var mapResult = GridStyleMapper.Map(loadResult.Value); + mapResult.IsSuccess.Should().BeTrue(); + return mapResult.Value; } private static TempDirectory CopyShippedConfig(string equipment) diff --git a/SemiStep/SemiStep.Tests/Core/Configuration/GridStyleWriterTests.cs b/SemiStep/SemiStep.Tests/Core/Configuration/GridStyleWriterTests.cs index 899f2451..a0b499f6 100644 --- a/SemiStep/SemiStep.Tests/Core/Configuration/GridStyleWriterTests.cs +++ b/SemiStep/SemiStep.Tests/Core/Configuration/GridStyleWriterTests.cs @@ -163,7 +163,9 @@ private static async Task LoadOptions(string configDir) { var result = await GridStyleLoader.LoadAsync(configDir); result.IsSuccess.Should().BeTrue(); - return GridStyleMapper.Map(result.Value); + var mapResult = GridStyleMapper.Map(result.Value); + mapResult.IsSuccess.Should().BeTrue(); + return mapResult.Value; } private static TempDirectory CopyShippedConfig(string equipment) diff --git a/SemiStep/SemiStep.Tests/Core/Configuration/StyleColorTests.cs b/SemiStep/SemiStep.Tests/Core/Configuration/StyleColorTests.cs new file mode 100644 index 00000000..13792b15 --- /dev/null +++ b/SemiStep/SemiStep.Tests/Core/Configuration/StyleColorTests.cs @@ -0,0 +1,133 @@ +using System; + +using FluentAssertions; + +using SemiStep.Core.Configuration; + +using Xunit; + +namespace SemiStep.Tests.Core.Configuration; + +[Trait("Component", "Config")] +[Trait("Category", "Unit")] +public sealed class StyleColorTests +{ + [Fact] + public void TryParse_SixDigit_SetsAlphaOpaque() + { + StyleColor.TryParse("#AABBCC", out var color).Should().BeTrue(); + + color.A.Should().Be(0xFF); + color.R.Should().Be(0xAA); + color.G.Should().Be(0xBB); + color.B.Should().Be(0xCC); + } + + [Fact] + public void TryParse_EightDigit_MapsArgbChannelOrder() + { + StyleColor.TryParse("#11223344", out var color).Should().BeTrue(); + + color.A.Should().Be(0x11); + color.R.Should().Be(0x22); + color.G.Should().Be(0x33); + color.B.Should().Be(0x44); + } + + [Fact] + public void TryParse_LowercaseInput_ParsesAndRoundTripsToUppercase() + { + StyleColor.TryParse("#aabbcc", out var color).Should().BeTrue(); + + color.ToString().Should().Be("#AABBCC"); + } + + [Fact] + public void ToString_OpaqueColor_EmitsSixDigitForm() + { + var color = new StyleColor(0xFF, 0x12, 0x34, 0x56); + + color.ToString().Should().Be("#123456"); + } + + [Fact] + public void ToString_TranslucentColor_EmitsEightDigitForm() + { + var color = new StyleColor(0x80, 0x12, 0x34, 0x56); + + color.ToString().Should().Be("#80123456"); + } + + [Fact] + public void Parse_TranslucentEightDigit_RoundTripsToUppercase() + { + StyleColor.Parse("#0aabbccd").ToString().Should().Be("#0AABBCCD"); + } + + [Fact] + public void TryParse_MixedCase_ParsesAndRoundTripsToUppercase() + { + StyleColor.TryParse("#AaBbCc", out var color).Should().BeTrue(); + + color.ToString().Should().Be("#AABBCC"); + } + + [Theory] + [InlineData("#FFF")] + [InlineData("#0FFF")] + [InlineData("FFFFFF")] + [InlineData("#12345")] + [InlineData("#FFFFFFFFF")] + [InlineData("#GGGGGG")] + [InlineData("#1234ZZ")] + [InlineData("#")] + [InlineData("#1234567")] + [InlineData("")] + [InlineData(" ")] + public void TryParse_RejectedInput_ReturnsFalse(string value) + { + StyleColor.TryParse(value, out var color).Should().BeFalse(); + + color.Should().Be(default(StyleColor)); + } + + [Fact] + public void TryParse_Null_ReturnsFalse() + { + StyleColor.TryParse(null, out var color).Should().BeFalse(); + + color.Should().Be(default(StyleColor)); + } + + [Theory] + [InlineData("#FFF")] + [InlineData("#0FFF")] + [InlineData("FFFFFF")] + [InlineData("#12345")] + [InlineData("#FFFFFFFFF")] + [InlineData("#")] + [InlineData("#1234567")] + [InlineData("")] + [InlineData(" ")] + public void Parse_RejectedInput_ThrowsFormatExceptionNamingInput(string value) + { + var act = () => StyleColor.Parse(value); + + act.Should().Throw().WithMessage($"*{value}*"); + } + + [Fact] + public void Parse_Null_ThrowsFormatException() + { + var act = () => StyleColor.Parse(null!); + + act.Should().Throw(); + } + + [Fact] + public void Parse_EqualChannelsAcrossCase_AreValueEqual() + { + StyleColor.Parse("#aabbcc").Should().Be(StyleColor.Parse("#AABBCC")); + (StyleColor.Parse("#aabbcc") == StyleColor.Parse("#AABBCC")).Should().BeTrue(); + } +} diff --git a/SemiStep/SemiStep.Tests/Helpers/GridStyleOptionsTestData.cs b/SemiStep/SemiStep.Tests/Helpers/GridStyleOptionsTestData.cs index 84b5f069..940012b5 100644 --- a/SemiStep/SemiStep.Tests/Helpers/GridStyleOptionsTestData.cs +++ b/SemiStep/SemiStep.Tests/Helpers/GridStyleOptionsTestData.cs @@ -7,7 +7,7 @@ namespace SemiStep.Tests.Helpers; /// in which every surfaced field holds a distinct, valid, and /// exactly-representable value, so that a dropped or cross-wired Seed/BuildRecord/mapper line makes /// exactly one field mismatch. Colors are 53 mutually-distinct UPPERCASE opaque #RRGGBB literals -/// (equality is string equality; ToHex emits X2). Numerics are integers or clean halves so the +/// (equality is channel equality; StyleColor.ToString emits X2). Numerics are integers or clean halves so the /// int↔decimal and double↔decimal conversions are exact. All five italics are true — the one /// kind whose type default (false) can equal a fixture value — so a dropped italic Seed line /// surfaces as false ≠ true. is the non-default value, which @@ -34,51 +34,51 @@ public static GridStyleOptions Distinct() CellPaddingBottom: 3.5, RowHeight: 28.5), Selection: new SelectionColors( - Background: "#010101", - Foreground: "#020202"), + Background: StyleColor.Parse("#010101"), + Foreground: StyleColor.Parse("#020202")), ChangedCells: new ChangedCellColors( - Changed: "#030303", - ChangedSelected: "#040404"), + Changed: StyleColor.Parse("#030303"), + ChangedSelected: StyleColor.Parse("#040404")), DisabledCells: new DepthPalette( - Depth0: "#050505", - Depth1: "#060606", - Depth2: "#070707", - Depth3: "#080808", - Depth0Past: "#090909", - Depth1Past: "#0A0A0A", - Depth2Past: "#0B0B0B", - Depth3Past: "#0C0C0C", - Selected: "#0D0D0D", - Foreground: "#0E0E0E"), + Depth0: StyleColor.Parse("#050505"), + Depth1: StyleColor.Parse("#060606"), + Depth2: StyleColor.Parse("#070707"), + Depth3: StyleColor.Parse("#080808"), + Depth0Past: StyleColor.Parse("#090909"), + Depth1Past: StyleColor.Parse("#0A0A0A"), + Depth2Past: StyleColor.Parse("#0B0B0B"), + Depth3Past: StyleColor.Parse("#0C0C0C"), + Selected: StyleColor.Parse("#0D0D0D"), + Foreground: StyleColor.Parse("#0E0E0E")), ReadOnlyCells: new DepthPalette( - Depth0: "#0F0F0F", - Depth1: "#101010", - Depth2: "#111111", - Depth3: "#121212", - Depth0Past: "#131313", - Depth1Past: "#141414", - Depth2Past: "#151515", - Depth3Past: "#161616", - Selected: "#171717", - Foreground: "#181818"), + Depth0: StyleColor.Parse("#0F0F0F"), + Depth1: StyleColor.Parse("#101010"), + Depth2: StyleColor.Parse("#111111"), + Depth3: StyleColor.Parse("#121212"), + Depth0Past: StyleColor.Parse("#131313"), + Depth1Past: StyleColor.Parse("#141414"), + Depth2Past: StyleColor.Parse("#151515"), + Depth3Past: StyleColor.Parse("#161616"), + Selected: StyleColor.Parse("#171717"), + Foreground: StyleColor.Parse("#181818")), Chrome: new ChromeColors( - Info: "#292929", - Connected: "#2A2A2A", - Disconnected: "#2B2B2B", - LocalMode: "#2C2C2C", - Connecting: "#2D2D2D", - PanelBackground: "#2E2E2E", - PanelHeaderBackground: "#2F2F2F", - SubtleBorder: "#303030", - Separator: "#313131", - SecondaryForeground: "#323232", - GridBorder: "#333333", - GridBackground: "#343434", - HeaderForeground: "#353535", - GridLine: "#191919"), + Info: StyleColor.Parse("#292929"), + Connected: StyleColor.Parse("#2A2A2A"), + Disconnected: StyleColor.Parse("#2B2B2B"), + LocalMode: StyleColor.Parse("#2C2C2C"), + Connecting: StyleColor.Parse("#2D2D2D"), + PanelBackground: StyleColor.Parse("#2E2E2E"), + PanelHeaderBackground: StyleColor.Parse("#2F2F2F"), + SubtleBorder: StyleColor.Parse("#303030"), + Separator: StyleColor.Parse("#313131"), + SecondaryForeground: StyleColor.Parse("#323232"), + GridBorder: StyleColor.Parse("#333333"), + GridBackground: StyleColor.Parse("#343434"), + HeaderForeground: StyleColor.Parse("#353535"), + GridLine: StyleColor.Parse("#191919")), StatusBar: new StatusBarStyle( - Background: "#1A1A1A", - Foreground: "#1B1B1B", + Background: StyleColor.Parse("#1A1A1A"), + Foreground: StyleColor.Parse("#1B1B1B"), Padding: 5.5, ItemSpacing: 10.5, FontSize: 17, @@ -91,21 +91,21 @@ public static GridStyleOptions Distinct() TimerValueWeight: 700, TimerValueItalic: true), ValidationPanel: new ValidationPanelStyle( - Background: "#1C1C1C", - Foreground: "#1D1D1D", - ErrorColor: "#1E1E1E", - WarningColor: "#1F1F1F", + Background: StyleColor.Parse("#1C1C1C"), + Foreground: StyleColor.Parse("#1D1D1D"), + ErrorColor: StyleColor.Parse("#1E1E1E"), + WarningColor: StyleColor.Parse("#1F1F1F"), MaxHeight: 150.5), Execution: new ExecutionPalette( - Depth0: "#202020", - Depth1: "#212121", - Depth2: "#222222", - Depth3: "#232323", - Depth0Past: "#242424", - Depth1Past: "#252525", - Depth2Past: "#262626", - Depth3Past: "#272727", - CurrentStepMarker: "#282828"), + Depth0: StyleColor.Parse("#202020"), + Depth1: StyleColor.Parse("#212121"), + Depth2: StyleColor.Parse("#222222"), + Depth3: StyleColor.Parse("#232323"), + Depth0Past: StyleColor.Parse("#242424"), + Depth1Past: StyleColor.Parse("#252525"), + Depth2Past: StyleColor.Parse("#262626"), + Depth3Past: StyleColor.Parse("#272727"), + CurrentStepMarker: StyleColor.Parse("#282828")), Orientation: GridOrientation.ColumnsAsSteps); } } diff --git a/SemiStep/SemiStep.Tests/UI/RecipeGrid/Transposed/TransposedCellBackgroundConverterTests.cs b/SemiStep/SemiStep.Tests/UI/RecipeGrid/Transposed/TransposedCellBackgroundConverterTests.cs index 0e6af33d..9abd42c8 100644 --- a/SemiStep/SemiStep.Tests/UI/RecipeGrid/Transposed/TransposedCellBackgroundConverterTests.cs +++ b/SemiStep/SemiStep.Tests/UI/RecipeGrid/Transposed/TransposedCellBackgroundConverterTests.cs @@ -158,42 +158,42 @@ private static GridStyleOptions BuildDistinctPaletteGridStyle() var defaults = GridStyleOptions.Default; return defaults with { - Chrome = defaults.Chrome with { GridBackground = "#101010" }, - Selection = defaults.Selection with { Background = "#202020" }, + Chrome = defaults.Chrome with { GridBackground = StyleColor.Parse("#101010") }, + Selection = defaults.Selection with { Background = StyleColor.Parse("#202020") }, ChangedCells = new ChangedCellColors( - Changed: "#303030", - ChangedSelected: "#404040"), + Changed: StyleColor.Parse("#303030"), + ChangedSelected: StyleColor.Parse("#404040")), ReadOnlyCells = new DepthPalette( - Depth0: "#210000", - Depth1: "#220000", - Depth2: "#230000", - Depth3: "#240000", - Depth0Past: "#250000", - Depth1Past: "#260000", - Depth2Past: "#270000", - Depth3Past: "#280000", - Selected: "#505050", + Depth0: StyleColor.Parse("#210000"), + Depth1: StyleColor.Parse("#220000"), + Depth2: StyleColor.Parse("#230000"), + Depth3: StyleColor.Parse("#240000"), + Depth0Past: StyleColor.Parse("#250000"), + Depth1Past: StyleColor.Parse("#260000"), + Depth2Past: StyleColor.Parse("#270000"), + Depth3Past: StyleColor.Parse("#280000"), + Selected: StyleColor.Parse("#505050"), Foreground: defaults.ReadOnlyCells.Foreground), DisabledCells = new DepthPalette( - Depth0: "#310000", - Depth1: "#320000", - Depth2: "#330000", - Depth3: "#340000", - Depth0Past: "#350000", - Depth1Past: "#360000", - Depth2Past: "#370000", - Depth3Past: "#380000", - Selected: "#606060", + Depth0: StyleColor.Parse("#310000"), + Depth1: StyleColor.Parse("#320000"), + Depth2: StyleColor.Parse("#330000"), + Depth3: StyleColor.Parse("#340000"), + Depth0Past: StyleColor.Parse("#350000"), + Depth1Past: StyleColor.Parse("#360000"), + Depth2Past: StyleColor.Parse("#370000"), + Depth3Past: StyleColor.Parse("#380000"), + Selected: StyleColor.Parse("#606060"), Foreground: defaults.DisabledCells.Foreground), Execution = defaults.Execution with { - Depth1 = "#110000", - Depth2 = "#120000", - Depth3 = "#130000", - Depth0Past = "#140000", - Depth1Past = "#150000", - Depth2Past = "#160000", - Depth3Past = "#170000", + Depth1 = StyleColor.Parse("#110000"), + Depth2 = StyleColor.Parse("#120000"), + Depth3 = StyleColor.Parse("#130000"), + Depth0Past = StyleColor.Parse("#140000"), + Depth1Past = StyleColor.Parse("#150000"), + Depth2Past = StyleColor.Parse("#160000"), + Depth3Past = StyleColor.Parse("#170000"), }, }; } diff --git a/SemiStep/SemiStep.Tests/UI/StyleEditor/GridStyleEditorViewModelTests.cs b/SemiStep/SemiStep.Tests/UI/StyleEditor/GridStyleEditorViewModelTests.cs index 2873e157..b1ec69a2 100644 --- a/SemiStep/SemiStep.Tests/UI/StyleEditor/GridStyleEditorViewModelTests.cs +++ b/SemiStep/SemiStep.Tests/UI/StyleEditor/GridStyleEditorViewModelTests.cs @@ -23,6 +23,7 @@ using SemiStep.Tests.UI.Localization; using SemiStep.UI.Localization; using SemiStep.UI.StyleEditor; +using SemiStep.UI.Styles; using Xunit; @@ -54,7 +55,7 @@ public void Seed_PopulatesEverySurfacedProperty_FromDistinctFixture() if (property.PropertyType == typeof(Color)) { - actual.Should().Be(HexColor.Parse((string)recordValue!), because); + actual.Should().Be(((StyleColor)recordValue!).ToMediaColor(), because); } else if (property.PropertyType == typeof(decimal?)) { @@ -120,7 +121,7 @@ public void BuildRecord_AfterEditingColorAndFontSize_ChangesOnlyThoseFields() var expected = source with { Fonts = source.Fonts with { CellFontSize = 18 }, - Selection = source.Selection with { Background = "#123456" } + Selection = source.Selection with { Background = StyleColor.Parse("#123456") } }; record.Should().Be(expected); } @@ -289,7 +290,7 @@ public void ToHex_PreservesSixDigitForm_ForOpaqueColors() viewModel.SelectionBackground = Color.Parse("#A1B2C3"); - viewModel.BuildRecord().Selection.Background.Should().Be("#A1B2C3"); + viewModel.BuildRecord().Selection.Background.ToString().Should().Be("#A1B2C3"); } [AvaloniaFact] @@ -299,7 +300,7 @@ public void ToHex_EmitsEightDigitForm_ForNonOpaqueColors() viewModel.SelectionBackground = Color.FromArgb(0x80, 0x11, 0x22, 0x33); - viewModel.BuildRecord().Selection.Background.Should().Be("#80112233"); + viewModel.BuildRecord().Selection.Background.ToString().Should().Be("#80112233"); } [AvaloniaFact] @@ -618,7 +619,7 @@ private static void CollectChangedLeaves(object baseline, object candidate, stri private static bool IsLeaf(Type type) { - return type.IsPrimitive || type.IsEnum || type == typeof(string); + return type.IsPrimitive || type.IsEnum || type == typeof(string) || type == typeof(StyleColor); } private static async Task ExecuteSwallowing(ReactiveCommand command) diff --git a/SemiStep/SemiStep.Tests/UI/StyleEditor/GridStyleEditorWindowTests.cs b/SemiStep/SemiStep.Tests/UI/StyleEditor/GridStyleEditorWindowTests.cs index 2092f232..c3783cd7 100644 --- a/SemiStep/SemiStep.Tests/UI/StyleEditor/GridStyleEditorWindowTests.cs +++ b/SemiStep/SemiStep.Tests/UI/StyleEditor/GridStyleEditorWindowTests.cs @@ -46,7 +46,7 @@ public async Task Save_AfterEditingColorAndNumeric_PersistsToYaml_AndReloads() viewModel.ErrorMessage.Should().BeNull(); var reloaded = (await facade.Load(configDir)).Value; - reloaded.Selection.Background.Should().Be("#123456"); + reloaded.Selection.Background.ToString().Should().Be("#123456"); reloaded.Fonts.CellFontSize.Should().Be(loaded.Fonts.CellFontSize + 1); } diff --git a/SemiStep/SemiStep.Tests/UI/Styles/CellPaletteInstallerTests.cs b/SemiStep/SemiStep.Tests/UI/Styles/CellPaletteInstallerTests.cs index 4c316c46..e19bb672 100644 --- a/SemiStep/SemiStep.Tests/UI/Styles/CellPaletteInstallerTests.cs +++ b/SemiStep/SemiStep.Tests/UI/Styles/CellPaletteInstallerTests.cs @@ -28,36 +28,36 @@ public void Install_PopulatesAllCellBrushes_WithExpectedColors() CellFontSize = 12, }, Selection = new SelectionColors( - Background: "#CCE4F7", - Foreground: "#202020"), + Background: StyleColor.Parse("#CCE4F7"), + Foreground: StyleColor.Parse("#202020")), ChangedCells = new ChangedCellColors( - Changed: "#FFCC80", - ChangedSelected: "#EFD3A4"), + Changed: StyleColor.Parse("#FFCC80"), + ChangedSelected: StyleColor.Parse("#EFD3A4")), ReadOnlyCells = new DepthPalette( - Depth0: "#D8D8D8", - Depth1: "#CCD5E0", - Depth2: "#B8C3D1", - Depth3: "#94A2B3", - Depth0Past: "#C8C8C8", - Depth1Past: "#BCC4CE", - Depth2Past: "#ACB7C2", - Depth3Past: "#8590A0", - Selected: "#6B95C0", - Foreground: "#606060"), + Depth0: StyleColor.Parse("#D8D8D8"), + Depth1: StyleColor.Parse("#CCD5E0"), + Depth2: StyleColor.Parse("#B8C3D1"), + Depth3: StyleColor.Parse("#94A2B3"), + Depth0Past: StyleColor.Parse("#C8C8C8"), + Depth1Past: StyleColor.Parse("#BCC4CE"), + Depth2Past: StyleColor.Parse("#ACB7C2"), + Depth3Past: StyleColor.Parse("#8590A0"), + Selected: StyleColor.Parse("#6B95C0"), + Foreground: StyleColor.Parse("#606060")), DisabledCells = new DepthPalette( - Depth0: "#E0E0E0", - Depth1: "#D5DEEA", - Depth2: "#C2CEDB", - Depth3: "#9DABBC", - Depth0Past: "#D0D0D0", - Depth1Past: "#C5CDD8", - Depth2Past: "#B5C0CC", - Depth3Past: "#909AAA", - Selected: "#89B4D7", - Foreground: "#808080"), + Depth0: StyleColor.Parse("#E0E0E0"), + Depth1: StyleColor.Parse("#D5DEEA"), + Depth2: StyleColor.Parse("#C2CEDB"), + Depth3: StyleColor.Parse("#9DABBC"), + Depth0Past: StyleColor.Parse("#D0D0D0"), + Depth1Past: StyleColor.Parse("#C5CDD8"), + Depth2Past: StyleColor.Parse("#B5C0CC"), + Depth3Past: StyleColor.Parse("#909AAA"), + Selected: StyleColor.Parse("#89B4D7"), + Foreground: StyleColor.Parse("#808080")), StatusBar = new StatusBarStyle( - Background: "#F0F0F0", - Foreground: "#111111", + Background: StyleColor.Parse("#F0F0F0"), + Foreground: StyleColor.Parse("#111111"), Padding: 5, ItemSpacing: 10, FontSize: 14, @@ -70,26 +70,26 @@ public void Install_PopulatesAllCellBrushes_WithExpectedColors() TimerValueWeight: 600, TimerValueItalic: false), ValidationPanel = new ValidationPanelStyle( - Background: "#FBFBFB", - Foreground: "#222222", - ErrorColor: "#D32F2F", - WarningColor: "#F57C00", + Background: StyleColor.Parse("#FBFBFB"), + Foreground: StyleColor.Parse("#222222"), + ErrorColor: StyleColor.Parse("#D32F2F"), + WarningColor: StyleColor.Parse("#F57C00"), MaxHeight: 100), Chrome = new ChromeColors( - Info: "#1976D2", - Connected: "#44BB44", - Disconnected: "#FF4444", - LocalMode: "#6C707E", - Connecting: "#FFAF0F", - PanelBackground: "#F8F8F8", - PanelHeaderBackground: "#EEEEEE", - SubtleBorder: "#D0D0D0", - Separator: "#C0C0C0", - SecondaryForeground: "#888888", - GridBorder: "#808080", - GridBackground: "#FFFFFF", - HeaderForeground: "#000000", - GridLine: "#CCCCCC"), + Info: StyleColor.Parse("#1976D2"), + Connected: StyleColor.Parse("#44BB44"), + Disconnected: StyleColor.Parse("#FF4444"), + LocalMode: StyleColor.Parse("#6C707E"), + Connecting: StyleColor.Parse("#FFAF0F"), + PanelBackground: StyleColor.Parse("#F8F8F8"), + PanelHeaderBackground: StyleColor.Parse("#EEEEEE"), + SubtleBorder: StyleColor.Parse("#D0D0D0"), + Separator: StyleColor.Parse("#C0C0C0"), + SecondaryForeground: StyleColor.Parse("#888888"), + GridBorder: StyleColor.Parse("#808080"), + GridBackground: StyleColor.Parse("#FFFFFF"), + HeaderForeground: StyleColor.Parse("#000000"), + GridLine: StyleColor.Parse("#CCCCCC")), }; var resources = new ResourceDictionary(); diff --git a/SemiStep/SemiStep.Tests/UI/Styles/ExecutionPaletteInstallerTests.cs b/SemiStep/SemiStep.Tests/UI/Styles/ExecutionPaletteInstallerTests.cs index 15a6727e..f8dcdbfb 100644 --- a/SemiStep/SemiStep.Tests/UI/Styles/ExecutionPaletteInstallerTests.cs +++ b/SemiStep/SemiStep.Tests/UI/Styles/ExecutionPaletteInstallerTests.cs @@ -21,15 +21,15 @@ public void Install_PopulatesNineBrushKeys_WithExpectedColors() var gridStyle = GridStyleOptions.Default with { Execution = new ExecutionPalette( - Depth0: "#FFFFFF", - Depth1: "#E8F3FF", - Depth2: "#D0E7FF", - Depth3: "#A8D0FF", - Depth0Past: "#F0F0F0", - Depth1Past: "#DCE5EE", - Depth2Past: "#C4D2E0", - Depth3Past: "#9CB4CC", - CurrentStepMarker: "#FF8800"), + Depth0: StyleColor.Parse("#FFFFFF"), + Depth1: StyleColor.Parse("#E8F3FF"), + Depth2: StyleColor.Parse("#D0E7FF"), + Depth3: StyleColor.Parse("#A8D0FF"), + Depth0Past: StyleColor.Parse("#F0F0F0"), + Depth1Past: StyleColor.Parse("#DCE5EE"), + Depth2Past: StyleColor.Parse("#C4D2E0"), + Depth3Past: StyleColor.Parse("#9CB4CC"), + CurrentStepMarker: StyleColor.Parse("#FF8800")), }; var resources = new ResourceDictionary(); diff --git a/SemiStep/SemiStep.UI/StyleEditor/GridStyleEditorViewModel.cs b/SemiStep/SemiStep.UI/StyleEditor/GridStyleEditorViewModel.cs index ec9ae424..207162f2 100644 --- a/SemiStep/SemiStep.UI/StyleEditor/GridStyleEditorViewModel.cs +++ b/SemiStep/SemiStep.UI/StyleEditor/GridStyleEditorViewModel.cs @@ -12,6 +12,7 @@ using SemiStep.Core.Configuration; using SemiStep.UI.Localization; +using SemiStep.UI.Styles; namespace SemiStep.UI.StyleEditor; @@ -219,46 +220,46 @@ public GridStyleOptions BuildRecord() CellPaddingBottom: ToDouble(CellPaddingBottom, _source.Layout.CellPaddingBottom), RowHeight: ToDouble(RowHeight, _source.Layout.RowHeight)), Selection: new SelectionColors( - Background: HexColor.ToHex(SelectionBackground), - Foreground: HexColor.ToHex(SelectionForeground)), + Background: SelectionBackground.ToStyleColor(), + Foreground: SelectionForeground.ToStyleColor()), ChangedCells: new ChangedCellColors( - Changed: HexColor.ToHex(CellChanged), - ChangedSelected: HexColor.ToHex(CellChangedSelected)), + Changed: CellChanged.ToStyleColor(), + ChangedSelected: CellChangedSelected.ToStyleColor()), ReadOnlyCells: new DepthPalette( - Depth0: HexColor.ToHex(ReadOnlyCellDepth0), - Depth1: HexColor.ToHex(ReadOnlyCellDepth1), - Depth2: HexColor.ToHex(ReadOnlyCellDepth2), - Depth3: HexColor.ToHex(ReadOnlyCellDepth3), - Depth0Past: HexColor.ToHex(ReadOnlyCellDepth0Past), - Depth1Past: HexColor.ToHex(ReadOnlyCellDepth1Past), - Depth2Past: HexColor.ToHex(ReadOnlyCellDepth2Past), - Depth3Past: HexColor.ToHex(ReadOnlyCellDepth3Past), - Selected: HexColor.ToHex(ReadOnlyCellSelected), - Foreground: HexColor.ToHex(ReadOnlyCellForeground)), + Depth0: ReadOnlyCellDepth0.ToStyleColor(), + Depth1: ReadOnlyCellDepth1.ToStyleColor(), + Depth2: ReadOnlyCellDepth2.ToStyleColor(), + Depth3: ReadOnlyCellDepth3.ToStyleColor(), + Depth0Past: ReadOnlyCellDepth0Past.ToStyleColor(), + Depth1Past: ReadOnlyCellDepth1Past.ToStyleColor(), + Depth2Past: ReadOnlyCellDepth2Past.ToStyleColor(), + Depth3Past: ReadOnlyCellDepth3Past.ToStyleColor(), + Selected: ReadOnlyCellSelected.ToStyleColor(), + Foreground: ReadOnlyCellForeground.ToStyleColor()), DisabledCells: new DepthPalette( - Depth0: HexColor.ToHex(DisabledCellDepth0), - Depth1: HexColor.ToHex(DisabledCellDepth1), - Depth2: HexColor.ToHex(DisabledCellDepth2), - Depth3: HexColor.ToHex(DisabledCellDepth3), - Depth0Past: HexColor.ToHex(DisabledCellDepth0Past), - Depth1Past: HexColor.ToHex(DisabledCellDepth1Past), - Depth2Past: HexColor.ToHex(DisabledCellDepth2Past), - Depth3Past: HexColor.ToHex(DisabledCellDepth3Past), - Selected: HexColor.ToHex(DisabledCellSelected), - Foreground: HexColor.ToHex(DisabledCellForeground)), + Depth0: DisabledCellDepth0.ToStyleColor(), + Depth1: DisabledCellDepth1.ToStyleColor(), + Depth2: DisabledCellDepth2.ToStyleColor(), + Depth3: DisabledCellDepth3.ToStyleColor(), + Depth0Past: DisabledCellDepth0Past.ToStyleColor(), + Depth1Past: DisabledCellDepth1Past.ToStyleColor(), + Depth2Past: DisabledCellDepth2Past.ToStyleColor(), + Depth3Past: DisabledCellDepth3Past.ToStyleColor(), + Selected: DisabledCellSelected.ToStyleColor(), + Foreground: DisabledCellForeground.ToStyleColor()), Execution: new ExecutionPalette( - Depth0: HexColor.ToHex(ExecutionDepth0), - Depth1: HexColor.ToHex(ExecutionDepth1), - Depth2: HexColor.ToHex(ExecutionDepth2), - Depth3: HexColor.ToHex(ExecutionDepth3), - Depth0Past: HexColor.ToHex(ExecutionDepth0Past), - Depth1Past: HexColor.ToHex(ExecutionDepth1Past), - Depth2Past: HexColor.ToHex(ExecutionDepth2Past), - Depth3Past: HexColor.ToHex(ExecutionDepth3Past), - CurrentStepMarker: HexColor.ToHex(ExecutionCurrentStepMarker)), + Depth0: ExecutionDepth0.ToStyleColor(), + Depth1: ExecutionDepth1.ToStyleColor(), + Depth2: ExecutionDepth2.ToStyleColor(), + Depth3: ExecutionDepth3.ToStyleColor(), + Depth0Past: ExecutionDepth0Past.ToStyleColor(), + Depth1Past: ExecutionDepth1Past.ToStyleColor(), + Depth2Past: ExecutionDepth2Past.ToStyleColor(), + Depth3Past: ExecutionDepth3Past.ToStyleColor(), + CurrentStepMarker: ExecutionCurrentStepMarker.ToStyleColor()), StatusBar: new StatusBarStyle( - Background: HexColor.ToHex(StatusBarBackground), - Foreground: HexColor.ToHex(StatusBarForeground), + Background: StatusBarBackground.ToStyleColor(), + Foreground: StatusBarForeground.ToStyleColor(), Padding: ToDouble(StatusBarPadding, _source.StatusBar.Padding), ItemSpacing: ToDouble(StatusBarItemSpacing, _source.StatusBar.ItemSpacing), FontSize: ToInt(StatusBarFontSize, _source.StatusBar.FontSize), @@ -271,26 +272,26 @@ public GridStyleOptions BuildRecord() TimerValueWeight: StatusBarTimerValueFontWeight, TimerValueItalic: StatusBarTimerValueItalic), ValidationPanel: new ValidationPanelStyle( - Background: HexColor.ToHex(ValidationPanelBackground), - Foreground: HexColor.ToHex(ValidationPanelForeground), - ErrorColor: HexColor.ToHex(ValidationPanelError), - WarningColor: HexColor.ToHex(ValidationPanelWarning), + Background: ValidationPanelBackground.ToStyleColor(), + Foreground: ValidationPanelForeground.ToStyleColor(), + ErrorColor: ValidationPanelError.ToStyleColor(), + WarningColor: ValidationPanelWarning.ToStyleColor(), MaxHeight: ToDouble(ValidationPanelMaxHeight, _source.ValidationPanel.MaxHeight)), Chrome: new ChromeColors( - Info: HexColor.ToHex(Info), - Connected: HexColor.ToHex(Connected), - Disconnected: HexColor.ToHex(Disconnected), - LocalMode: HexColor.ToHex(LocalMode), - Connecting: HexColor.ToHex(Connecting), - PanelBackground: HexColor.ToHex(PanelBackground), - PanelHeaderBackground: HexColor.ToHex(PanelHeaderBackground), - SubtleBorder: HexColor.ToHex(SubtleBorder), - Separator: HexColor.ToHex(Separator), - SecondaryForeground: HexColor.ToHex(SecondaryForeground), - GridBorder: HexColor.ToHex(GridBorder), - GridBackground: HexColor.ToHex(GridBackground), - HeaderForeground: HexColor.ToHex(HeaderForeground), - GridLine: HexColor.ToHex(GridLine)), + Info: Info.ToStyleColor(), + Connected: Connected.ToStyleColor(), + Disconnected: Disconnected.ToStyleColor(), + LocalMode: LocalMode.ToStyleColor(), + Connecting: Connecting.ToStyleColor(), + PanelBackground: PanelBackground.ToStyleColor(), + PanelHeaderBackground: PanelHeaderBackground.ToStyleColor(), + SubtleBorder: SubtleBorder.ToStyleColor(), + Separator: Separator.ToStyleColor(), + SecondaryForeground: SecondaryForeground.ToStyleColor(), + GridBorder: GridBorder.ToStyleColor(), + GridBackground: GridBackground.ToStyleColor(), + HeaderForeground: HeaderForeground.ToStyleColor(), + GridLine: GridLine.ToStyleColor()), Orientation: _source.Orientation); } @@ -361,59 +362,59 @@ private void Seed(GridStyleOptions options) AvailableFontFamilies = BuildFontFamilies(options.Fonts.FontFamily); AvailableFontWeights = BuildFontWeights(options); - SelectionBackground = HexColor.Parse(options.Selection.Background); - SelectionForeground = HexColor.Parse(options.Selection.Foreground); - CellChanged = HexColor.Parse(options.ChangedCells.Changed); - CellChangedSelected = HexColor.Parse(options.ChangedCells.ChangedSelected); - DisabledCellDepth0 = HexColor.Parse(options.DisabledCells.Depth0); - DisabledCellDepth1 = HexColor.Parse(options.DisabledCells.Depth1); - DisabledCellDepth2 = HexColor.Parse(options.DisabledCells.Depth2); - DisabledCellDepth3 = HexColor.Parse(options.DisabledCells.Depth3); - DisabledCellDepth0Past = HexColor.Parse(options.DisabledCells.Depth0Past); - DisabledCellDepth1Past = HexColor.Parse(options.DisabledCells.Depth1Past); - DisabledCellDepth2Past = HexColor.Parse(options.DisabledCells.Depth2Past); - DisabledCellDepth3Past = HexColor.Parse(options.DisabledCells.Depth3Past); - DisabledCellSelected = HexColor.Parse(options.DisabledCells.Selected); - DisabledCellForeground = HexColor.Parse(options.DisabledCells.Foreground); - ReadOnlyCellDepth0 = HexColor.Parse(options.ReadOnlyCells.Depth0); - ReadOnlyCellDepth1 = HexColor.Parse(options.ReadOnlyCells.Depth1); - ReadOnlyCellDepth2 = HexColor.Parse(options.ReadOnlyCells.Depth2); - ReadOnlyCellDepth3 = HexColor.Parse(options.ReadOnlyCells.Depth3); - ReadOnlyCellDepth0Past = HexColor.Parse(options.ReadOnlyCells.Depth0Past); - ReadOnlyCellDepth1Past = HexColor.Parse(options.ReadOnlyCells.Depth1Past); - ReadOnlyCellDepth2Past = HexColor.Parse(options.ReadOnlyCells.Depth2Past); - ReadOnlyCellDepth3Past = HexColor.Parse(options.ReadOnlyCells.Depth3Past); - ReadOnlyCellSelected = HexColor.Parse(options.ReadOnlyCells.Selected); - ReadOnlyCellForeground = HexColor.Parse(options.ReadOnlyCells.Foreground); - GridLine = HexColor.Parse(options.Chrome.GridLine); - StatusBarBackground = HexColor.Parse(options.StatusBar.Background); - StatusBarForeground = HexColor.Parse(options.StatusBar.Foreground); - ValidationPanelBackground = HexColor.Parse(options.ValidationPanel.Background); - ValidationPanelForeground = HexColor.Parse(options.ValidationPanel.Foreground); - ValidationPanelError = HexColor.Parse(options.ValidationPanel.ErrorColor); - ValidationPanelWarning = HexColor.Parse(options.ValidationPanel.WarningColor); - ExecutionDepth0 = HexColor.Parse(options.Execution.Depth0); - ExecutionDepth1 = HexColor.Parse(options.Execution.Depth1); - ExecutionDepth2 = HexColor.Parse(options.Execution.Depth2); - ExecutionDepth3 = HexColor.Parse(options.Execution.Depth3); - ExecutionDepth0Past = HexColor.Parse(options.Execution.Depth0Past); - ExecutionDepth1Past = HexColor.Parse(options.Execution.Depth1Past); - ExecutionDepth2Past = HexColor.Parse(options.Execution.Depth2Past); - ExecutionDepth3Past = HexColor.Parse(options.Execution.Depth3Past); - ExecutionCurrentStepMarker = HexColor.Parse(options.Execution.CurrentStepMarker); - Info = HexColor.Parse(options.Chrome.Info); - Connected = HexColor.Parse(options.Chrome.Connected); - Disconnected = HexColor.Parse(options.Chrome.Disconnected); - LocalMode = HexColor.Parse(options.Chrome.LocalMode); - Connecting = HexColor.Parse(options.Chrome.Connecting); - PanelBackground = HexColor.Parse(options.Chrome.PanelBackground); - PanelHeaderBackground = HexColor.Parse(options.Chrome.PanelHeaderBackground); - SubtleBorder = HexColor.Parse(options.Chrome.SubtleBorder); - Separator = HexColor.Parse(options.Chrome.Separator); - SecondaryForeground = HexColor.Parse(options.Chrome.SecondaryForeground); - GridBorder = HexColor.Parse(options.Chrome.GridBorder); - GridBackground = HexColor.Parse(options.Chrome.GridBackground); - HeaderForeground = HexColor.Parse(options.Chrome.HeaderForeground); + SelectionBackground = options.Selection.Background.ToMediaColor(); + SelectionForeground = options.Selection.Foreground.ToMediaColor(); + CellChanged = options.ChangedCells.Changed.ToMediaColor(); + CellChangedSelected = options.ChangedCells.ChangedSelected.ToMediaColor(); + DisabledCellDepth0 = options.DisabledCells.Depth0.ToMediaColor(); + DisabledCellDepth1 = options.DisabledCells.Depth1.ToMediaColor(); + DisabledCellDepth2 = options.DisabledCells.Depth2.ToMediaColor(); + DisabledCellDepth3 = options.DisabledCells.Depth3.ToMediaColor(); + DisabledCellDepth0Past = options.DisabledCells.Depth0Past.ToMediaColor(); + DisabledCellDepth1Past = options.DisabledCells.Depth1Past.ToMediaColor(); + DisabledCellDepth2Past = options.DisabledCells.Depth2Past.ToMediaColor(); + DisabledCellDepth3Past = options.DisabledCells.Depth3Past.ToMediaColor(); + DisabledCellSelected = options.DisabledCells.Selected.ToMediaColor(); + DisabledCellForeground = options.DisabledCells.Foreground.ToMediaColor(); + ReadOnlyCellDepth0 = options.ReadOnlyCells.Depth0.ToMediaColor(); + ReadOnlyCellDepth1 = options.ReadOnlyCells.Depth1.ToMediaColor(); + ReadOnlyCellDepth2 = options.ReadOnlyCells.Depth2.ToMediaColor(); + ReadOnlyCellDepth3 = options.ReadOnlyCells.Depth3.ToMediaColor(); + ReadOnlyCellDepth0Past = options.ReadOnlyCells.Depth0Past.ToMediaColor(); + ReadOnlyCellDepth1Past = options.ReadOnlyCells.Depth1Past.ToMediaColor(); + ReadOnlyCellDepth2Past = options.ReadOnlyCells.Depth2Past.ToMediaColor(); + ReadOnlyCellDepth3Past = options.ReadOnlyCells.Depth3Past.ToMediaColor(); + ReadOnlyCellSelected = options.ReadOnlyCells.Selected.ToMediaColor(); + ReadOnlyCellForeground = options.ReadOnlyCells.Foreground.ToMediaColor(); + GridLine = options.Chrome.GridLine.ToMediaColor(); + StatusBarBackground = options.StatusBar.Background.ToMediaColor(); + StatusBarForeground = options.StatusBar.Foreground.ToMediaColor(); + ValidationPanelBackground = options.ValidationPanel.Background.ToMediaColor(); + ValidationPanelForeground = options.ValidationPanel.Foreground.ToMediaColor(); + ValidationPanelError = options.ValidationPanel.ErrorColor.ToMediaColor(); + ValidationPanelWarning = options.ValidationPanel.WarningColor.ToMediaColor(); + ExecutionDepth0 = options.Execution.Depth0.ToMediaColor(); + ExecutionDepth1 = options.Execution.Depth1.ToMediaColor(); + ExecutionDepth2 = options.Execution.Depth2.ToMediaColor(); + ExecutionDepth3 = options.Execution.Depth3.ToMediaColor(); + ExecutionDepth0Past = options.Execution.Depth0Past.ToMediaColor(); + ExecutionDepth1Past = options.Execution.Depth1Past.ToMediaColor(); + ExecutionDepth2Past = options.Execution.Depth2Past.ToMediaColor(); + ExecutionDepth3Past = options.Execution.Depth3Past.ToMediaColor(); + ExecutionCurrentStepMarker = options.Execution.CurrentStepMarker.ToMediaColor(); + Info = options.Chrome.Info.ToMediaColor(); + Connected = options.Chrome.Connected.ToMediaColor(); + Disconnected = options.Chrome.Disconnected.ToMediaColor(); + LocalMode = options.Chrome.LocalMode.ToMediaColor(); + Connecting = options.Chrome.Connecting.ToMediaColor(); + PanelBackground = options.Chrome.PanelBackground.ToMediaColor(); + PanelHeaderBackground = options.Chrome.PanelHeaderBackground.ToMediaColor(); + SubtleBorder = options.Chrome.SubtleBorder.ToMediaColor(); + Separator = options.Chrome.Separator.ToMediaColor(); + SecondaryForeground = options.Chrome.SecondaryForeground.ToMediaColor(); + GridBorder = options.Chrome.GridBorder.ToMediaColor(); + GridBackground = options.Chrome.GridBackground.ToMediaColor(); + HeaderForeground = options.Chrome.HeaderForeground.ToMediaColor(); _seeding = false; RecomputeCanSave(); diff --git a/SemiStep/SemiStep.UI/StyleEditor/HexColor.cs b/SemiStep/SemiStep.UI/StyleEditor/HexColor.cs deleted file mode 100644 index 65975305..00000000 --- a/SemiStep/SemiStep.UI/StyleEditor/HexColor.cs +++ /dev/null @@ -1,27 +0,0 @@ -using Avalonia.Media; - -namespace SemiStep.UI.StyleEditor; - -/// -/// Converts between the config's #RRGGBB / #AARRGGBB hex strings and Avalonia -/// . is manual rather than -/// because the latter always emits #AARRGGBB, which would inject an #FF alpha -/// prefix onto opaque colors and break the config's #RRGGBB style on round-trip. -/// -internal static class HexColor -{ - public static Color Parse(string hex) - { - return Color.Parse(hex); - } - - public static string ToHex(Color color) - { - if (color.A == byte.MaxValue) - { - return $"#{color.R:X2}{color.G:X2}{color.B:X2}"; - } - - return $"#{color.A:X2}{color.R:X2}{color.G:X2}{color.B:X2}"; - } -} diff --git a/SemiStep/SemiStep.UI/Styles/PaletteBrushFactory.cs b/SemiStep/SemiStep.UI/Styles/PaletteBrushFactory.cs index fb724469..4e65e235 100644 --- a/SemiStep/SemiStep.UI/Styles/PaletteBrushFactory.cs +++ b/SemiStep/SemiStep.UI/Styles/PaletteBrushFactory.cs @@ -1,11 +1,13 @@ using Avalonia.Media; +using SemiStep.Core.Configuration; + namespace SemiStep.UI.Styles; internal static class PaletteBrushFactory { - public static SolidColorBrush From(string hex) + public static SolidColorBrush From(StyleColor color) { - return new SolidColorBrush(Color.Parse(hex)); + return new SolidColorBrush(color.ToMediaColor()); } } diff --git a/SemiStep/SemiStep.UI/Styles/StyleColorConversions.cs b/SemiStep/SemiStep.UI/Styles/StyleColorConversions.cs new file mode 100644 index 00000000..71c2b97f --- /dev/null +++ b/SemiStep/SemiStep.UI/Styles/StyleColorConversions.cs @@ -0,0 +1,23 @@ +using Avalonia.Media; + +using SemiStep.Core.Configuration; + +namespace SemiStep.UI.Styles; + +/// +/// Channel-wise bridge between the Avalonia-free and Avalonia's +/// . Both carry the same A/R/G/B bytes, so the conversion copies channels +/// directly — no hex parse or format on the round-trip. +/// +internal static class StyleColorConversions +{ + public static Color ToMediaColor(this StyleColor color) + { + return Color.FromArgb(color.A, color.R, color.G, color.B); + } + + public static StyleColor ToStyleColor(this Color color) + { + return new StyleColor(color.A, color.R, color.G, color.B); + } +} diff --git a/docs/plans/completed/20260805-grid-style-color-typing.md b/docs/plans/completed/20260805-grid-style-color-typing.md new file mode 100644 index 00000000..2e902c73 --- /dev/null +++ b/docs/plans/completed/20260805-grid-style-color-typing.md @@ -0,0 +1,413 @@ +# Slice 4 — Type colors as StyleColor, fold validation into the load mapper + +## Overview + +Slice 4 of the #118 debloat: replace the 53 raw `string` hex fields (spread over the slice-3 group records) with a +typed `StyleColor` value in Core, move hex parsing and per-key error accumulation into `GridStyleMapper`, and delete +`GridStyleValidator` (243 lines) plus the editor's `HexColor` edge (27 lines). After this slice a color is channels +everywhere; hex exists only at the two I/O edges — the load mapper parses, the save mapper formats — and an invalid +color is **unrepresentable** in `GridStyleOptions`. + +**The YAML format and the DTOs do not change.** Every `GridStyle*Dto` keeps its `string` properties, so the +serialized key set and value shape on disk are identical. One behavior note: hex **case** normalizes to uppercase on +save (`StyleColor` equality is channel equality; `ToString` emits `X2`). Acceptable — the editor already rewrites the +whole file, and the writer preserves the leading comment block, not the value casing. + +**Locked decisions — settled, do not re-open:** +- `StyleColor` is a `readonly record struct` in `SemiStep.Core` (Avalonia-free), channels `A`/`R`/`G`/`B` (`byte` + each), with `Parse`/`TryParse`/`ToString`. `ToString` absorbs `HexColor.ToHex`'s round-trip rule: opaque + (`A == 0xFF`) → `#RRGGBB` uppercase; translucent → `#AARRGGBB` uppercase — never the `#FF`-prefixed form + `Avalonia.Media.Color.ToString()` injects. +- `Parse` accepts ONLY `#RRGGBB` (6 hex digits) and `#AARRGGBB` (8 hex digits) — exactly today's validator regex + `^#([0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$`. It REJECTS `#RGB`/`#ARGB` shorthand. The shipped file headers that advertise + `#RGB`/`#ARGB` are wrong today (the validator already rejects them) and get corrected in Task 4. +- `IGridStyleEditorFacade.Validate` STAYS on the interface as a vacuous pass-through (`Result.Ok()`). Do NOT remove + it — slice 5 trims the interface when it splits the VM. The VM's `RecomputeCanSave` keeps calling it unchanged. + +**The hard part is error-identity fidelity** (see the dedicated section below): the load mapper must aggregate ALL +errors per load — not fail-fast — keyed by the exact same section path + key name the validator emits today, so +`ReasonLocalizer` output and the existing grid-style error tests survive byte-for-byte. + +**Stacking dependency:** this slice builds on the slice-3 end state (branch `grid-style-nest-record`, PR #177, +unmerged). Exec branches `grid-style-color-typing` off `grid-style-nest-record`, NOT off `master`; rebase onto +`origin/master` once #177 merges. + +## The target shape + +### StyleColor (new, Core) + +`SemiStep/SemiStep.Core/Configuration/StyleColor.cs`: + +```csharp +public readonly record struct StyleColor(byte A, byte R, byte G, byte B) +{ + public static StyleColor Parse(string value); // throws FormatException on reject + public static bool TryParse(string? value, out StyleColor color); + public override string ToString() => + A == byte.MaxValue + ? $"#{R:X2}{G:X2}{B:X2}" + : $"#{A:X2}{R:X2}{G:X2}{B:X2}"; +} +``` + +`TryParse` semantics mirror the validator regex exactly: `#` + 6 hex digits (→ `A = 0xFF`) or `#` + 8 hex digits +(`AARRGGBB`), any letter case on input; everything else — including null, whitespace, `#RGB`, `#ARGB`, missing `#`, +wrong length — returns `false`. No allocation beyond the `ToString` string. + +### The 53 fields that flip `string` → `StyleColor` + +| Group record | Color fields flipping | Count | +|---|---|---| +| `SelectionColors` | Background, Foreground | 2 | +| `ChangedCellColors` | Changed, ChangedSelected | 2 | +| `DepthPalette` (used ×2: ReadOnlyCells, DisabledCells) | Depth0–3, Depth0Past–3Past, Selected, Foreground | 10 (×2 uses) | +| `ExecutionPalette` | Depth0–3, Depth0Past–3Past, CurrentStepMarker | 9 | +| `StatusBarStyle` | Background, Foreground (numerics/weights/italics stay) | 2 | +| `ValidationPanelStyle` | Background, Foreground, ErrorColor, WarningColor (MaxHeight stays) | 4 | +| `ChromeColors` | all 14 incl. GridLine | 14 | + +2+2+10+10+9+2+4+14 = 53. `GridStyleFonts`, `GridStyleLayout`, and `Orientation` are untouched. + +### The load mapper's aggregated-validation shape (end state, Task 3) + +`GridStyleMapper.Map` changes signature to `Result Map(GridStyleOptionsDto? dto)` and absorbs the +validator wholesale: + +```csharp +if (dto is null) return Result.Fail(new GridStyleConfigMissingError()); // was: return Default (dead in production — validator gated it) +if (dto.Colors is null) return Result.Fail(new GridStyleSectionMissingError("colors")); // fail-fast HERE only, matching today +var errors = new List(); +// parse phase — per-key helpers accumulate into `errors`, in the validator's emission order: +// execution → readonly → disabled → chrome → selection → cells.changed/changed_selected → +// colors.grid_line → status_bar → validation_panel → orientation +... +if (errors.Count > 0) return Result.Fail(errors); +return Result.Ok(new GridStyleOptions(...)); // positional per-group construction from the parsed locals +``` + +Two helper shapes carry the whole per-key policy: + +- `RequiredColor(string? value, string section, string key, List errors)` — for the three cell palettes. + Null or whitespace → `GridStyleKeyMissingError(section, key)`; `TryParse` failure → `GridStyleHexColorInvalidError + (section, key, value)`; on error returns `default(StyleColor)` (a placeholder — the whole result is discarded when + `errors` is non-empty). The three required sections are tested by INDEPENDENT null-propagating reads — + `dto.Colors.Cells?.Execution`, `?.ReadOnly`, `?.Disabled` — each null read adds its own + `GridStyleSectionMissingError` (`colors.cells.execution` / `.readonly` / `.disabled`), skips that section's keys, + and keeps checking the others. There is NO `colors.cells` section error: a null `Cells` nulls all three reads and + therefore emits exactly THREE section-missing errors, matching the validator (which never checks `Cells` itself — + the `"colors.cells"` path appears only in the optional changed/changed_selected check, which skips silently on a + null `Cells`). +- `OptionalColor(string? value, StyleColor fallback, string section, string key, List errors)` — for + chrome / selection / changed-cells / grid_line / status_bar / validation_panel keys. Null → `fallback` silently + (today's `?? defaults` regime); present-but-whitespace → `GridStyleKeyMissingError`; `TryParse` failure → + `GridStyleHexColorInvalidError`; valid → parsed. An optional section that is null → all its fallbacks, no error. + +The save side is trivial: `GridStyleDtoMapper` writes `options...ToString()` into each string DTO +property — 53 `.ToString()` suffixes, no other change, YAML output shape identical. + +### Everything else that moves + +| Artifact | Change | +|---|---| +| `GridStyleOptions.Default` + group records | color params/args become `StyleColor` (`StyleColor.Parse("#CCE4F7")` literals — valid by construction, exercised by tests) | +| `Validation/GridStyleValidator.cs` | DELETED (Task 3) | +| `Facade/ConfigFacade.cs` | the `GridStyleValidator.Validate(gridStyle)` call at the same pipeline position becomes `GridStyleMapper.Map(gridStyle)`; `MapToDomain` receives the already-mapped `GridStyleOptions` instead of the DTO — grid-style errors keep propagating BEFORE cross-reference validation, exactly as today | +| `GridStyleEditorFacade` | `Load` = loader → `Map` (Result); `Validate` = `Result.Ok()` vacuous with a "slice 5 removes this" comment; `Save` drops its pre-write validation gate (invalid colors are unrepresentable); XML doc updated | +| `Styles/PaletteBrushFactory` | `From(StyleColor)` — `new SolidColorBrush(color.ToMediaColor())` via the shared conversion below, no parse | +| `StyleEditor/HexColor.cs` | DELETED; replaced by `Styles/StyleColorConversions.cs` — channel-wise extensions `ToMediaColor(this StyleColor)` / `ToStyleColor(this Avalonia.Media.Color)`, used by the VM's `Seed`/`BuildRecord` and by `PaletteBrushFactory` | +| `Dto/GridOrientationValues.cs` + `GridStyleMapper` comments | the "validator runs before this" comments are rewritten/removed — the precondition no longer exists | + +## Error-identity fidelity — the central risk + +The validator is the ONLY producer of five typed errors that `ReasonLocalizer` localizes per type + fields and that +three test suites pin. The mapper must reproduce every one with the identical section path, key name, and constructor +arguments — the error `Message` strings are built in the error-type constructors (unchanged files), so identical +arguments ⇒ byte-identical messages ⇒ byte-identical localized output. + +| # | Error emitted today | Validator trigger | Identity carried | Load-mapper reproduction | +|---|---|---|---|---| +| 1 | `GridStyleConfigMissingError` | `dto is null` | — | early `return Fail` — replaces the mapper's current `return GridStyleOptions.Default` null-branch, which was unreachable in production (the validator gated both load paths); `GridStyleMapperTests.Map_NullDto_ReturnsDefaults` retargets to assert this failure | +| 2 | `GridStyleSectionMissingError("colors")` | `dto.Colors is null` | section `colors` | early `return Fail` with ONLY this error — today the validator returns immediately without checking chrome/status_bar/orientation; preserve that fail-fast at this one level | +| 3 | `GridStyleSectionMissingError(path)` | `Cells?.Execution` / `?.ReadOnly` / `?.Disabled` is null — three INDEPENDENT null-propagating reads; a null `Cells` trips all three (three errors, never one `colors.cells`) | `colors.cells.execution` / `colors.cells.readonly` / `colors.cells.disabled` | required-section handling: add the error per null read, skip that section's keys, CONTINUE aggregating the other sections and orientation | +| 4 | `GridStyleKeyMissingError(section, key)` | required cell-palette key null/whitespace; optional-section key present-but-whitespace | e.g. (`colors.cells.readonly`, `depth_0`) — snake_case key names exactly as the validator's tuple lists | `RequiredColor`: null OR whitespace → this error. `OptionalColor`: null → silent fallback (today's `??` regime), whitespace → this error | +| 5 | `GridStyleHexColorInvalidError(section, key, value)` | non-whitespace value fails the 6/8-hex regex | section + key + offending value verbatim | `TryParse` failure on non-whitespace input → same triple | +| 6 | `GridStyleOrientationInvalidError(value, rows, columns)` | orientation non-null and unknown | value + both expected literals | checked in the aggregate BEFORE `GridOrientationValues.Parse` runs (Parse throws on unknown — it must never see an invalid value) | + +Full optional-key inventory the mapper must format-check when present (from the validator's tuple lists — carry the +snake_case names verbatim): `chrome.{info, connected, disconnected, local_mode, connecting, panel_background, +panel_header_background, subtle_border, separator, secondary_foreground, grid_border, grid_background, +header_foreground}`, `colors.selection.{background, foreground}`, `colors.cells.{changed, changed_selected}`, +`colors.grid_line` (record path `Chrome.GridLine` — the error key stays `colors.grid_line`, per the slice-3 doc +note), `status_bar.{background, foreground}`, `validation_panel.{background, foreground, error_color, +warning_color}`. + +**The `!`-chain trap (spelled out).** Today `GridStyleMapper` reads the three cell palettes through a no-fallback +null-forgiving chain — `dto.Colors!.Cells!.ReadOnly!.Depth0!` — with the comment "presence guaranteed by +GridStyleValidator, which runs before this mapper". The moment the validator is deleted, that presumption is FALSE: +a file with a missing `readonly:` section would throw `NullReferenceException` inside the mapper instead of failing +with `GridStyleSectionMissingError`. The mapper itself must now produce the missing-section and missing-key errors +that used to be its own precondition — every `!` in those three palette blocks becomes a real aggregated check +(`Cells?.ReadOnly` null-test + `RequiredColor` per key). No `!` on a color path survives Task 3. Do NOT "fix" this by +adding `?? default` fallbacks — that would silently swap a load failure for a `#000000` placeholder, exactly the +regression the slice-1 guards exist to prevent. + +**Aggregation, not fail-fast.** `Validate_MultipleErrors_AllCollected` pins that three bad keys in one DTO produce +three errors in one result. The mapper's parse phase must therefore run to completion (below the `colors` early-out) +before deciding success. Preserve the validator's emission order (the table order above); no test pins the order, but +the VM joins errors with `"; "` into one user-visible message, so keep it stable rather than re-sorting by group. + +**The end-to-end pin.** `GridStyleEditorViewModelTests.LoadAsync_MalformedHexColor_UnderRussianCulture_ +RendersRussianErrorMessage` loads a real file with `changed_selected: "zzz"` through the real facade and asserts the +full Russian message string. It must pass UNTOUCHED after Task 3 — it is the byte-for-byte proof that file → loader → +mapper-resident validation → `ReasonLocalizer` → VM produces the identical error. + +## Acceptance Evidence + +- `StyleColor` exists in Core with the locked semantics; `SemiStep.Core` still has no Avalonia reference (csproj + untouched); dedicated `StyleColorTests` cover parse/reject/round-trip incl. `#RGB`/`#ARGB` rejection and the + opaque-6/translucent-8 `ToString` rule. +- All 53 color fields are `StyleColor`; `grep -r "string.*Color\|Color.*string"` over the group records finds no + string-typed color; the solution compiles with zero references to `HexColor` or `GridStyleValidator`. +- Slice 1's guard net holds over typed colors: `Seed_PopulatesEverySurfacedProperty…`, + `BuildRecord_PerturbingEachProperty…`, `SaveThenLoad_DistinctFixture…` green with the fixture retargeted to + `StyleColor` (same 53 distinct valid values — distinctness now by channel equality, same hex ⇒ same distinctness). +- The error-identity net is green with assertion bodies unchanged: `GridStyleColorsValidationTests` (entry point + retargeted `GridStyleValidator.Validate` → `GridStyleMapper.Map`, every message assertion byte-identical), + `ConfigFacadeGridStyleValidationTests`, `GridStyleOrientationTests`, and the untouched RU-culture end-to-end + message test. +- The two facade tests whose invalid record is now unrepresentable (`Validate_MalformedColor_ReturnsFail`, + `Save_InvalidRecord_FailsValidationGuardBeforeWriting`) are deleted with a commit-message note — the compiler now + provides the guarantee they tested. +- On-disk YAML: same keys, same shape; shipped configs load unchanged; saving normalizes only value case to + uppercase. The MBE/MOCVD file headers no longer advertise `#RGB`/`#ARGB`. +- `dotnet build SemiStep.slnx` 0 warnings (warnings are errors); full `dotnet test` green; `dotnet format` clean. + +## Task 1: StyleColor value type in Core, with unit tests + +Self-contained — nothing references the type yet, so this commit is trivially green. + +**Files:** +- Create: `SemiStep/SemiStep.Core/Configuration/StyleColor.cs` (BOM, file-scoped namespace + `SemiStep.Core.Configuration`). +- Create: `SemiStep/SemiStep.Tests/Core/Configuration/StyleColorTests.cs` + (`[Trait("Component","Config")] [Trait("Category","Unit")]`). + +- [x] Author `StyleColor` per the sketch: `readonly record struct` with `byte A, R, G, B`; `TryParse` accepting + exactly `#` + 6 or 8 hex digits (case-insensitive input, 6-digit → `A = 0xFF`), false for null/whitespace/ + shorthand/anything else; `Parse` delegating to `TryParse` and throwing `FormatException` naming the input on + reject; `ToString` emitting `#RRGGBB` when opaque, `#AARRGGBB` otherwise, uppercase `X2` — carry over + `HexColor`'s doc-comment rationale (never the `#FF`-injected `Color.ToString()` form). +- [x] Tests: parse 6-digit sets `A=0xFF`; parse 8-digit maps `AARRGGBB` channel order; lowercase input parses and + `ToString` round-trips to uppercase; opaque → 6-digit and translucent → 8-digit `ToString`; rejects `#FFF`, + `#0FFF`, `FFFFFF`, `#12345`, `#FFFFFFFFF`, `""`, `" "`, null (TryParse false, Parse throws); value equality + (`Parse("#aabbcc") == Parse("#AABBCC")`). +- [x] `dotnet build SemiStep.slnx` 0 warnings; full `dotnet test` green; `dotnet format`. One commit. + +## Task 2: Flip the 53 color fields to StyleColor — validator still standing (one atomic commit) + +The type flip ripples until nothing references a string color — production AND tests reach green in the same commit +(like slice 3's rewrite). **The validator is NOT touched here**: it operates on the DTO (still strings) and still +gates both load paths, so the interim mapper may parse with throwing `StyleColor.Parse` under the existing +validator-guarantees. This boundary is deliberate: Task 2 is mechanical typing proven lossless by the slice-1 guards, +while every error test stays untouched-and-green — which then makes those tests the unchanged fidelity net for +Task 3's validation move. A partial state inside this task does not compile; the two-task split states each compile. + +**Files (production):** +- Modify group records: `SelectionColors.cs`, `ChangedCellColors.cs`, `DepthPalette.cs`, `ExecutionPalette.cs`, + `StatusBarStyle.cs` (2 fields), `ValidationPanelStyle.cs` (4 fields), `ChromeColors.cs` — `string` → `StyleColor` + per the table. +- Modify: `GridStyleOptions.cs` (`Default` color literals → `StyleColor.Parse("…")`). +- Modify: `Mapping/GridStyleMapper.cs` — required palettes: `StyleColor.Parse(readOnlyCells.Depth0!)` (keep the + validator-precondition comment for now; Task 3 removes it); optional fields: `dto.X is null ? + defaults.. : StyleColor.Parse(dto.X)` replacing `?? defaults`. Signature still returns plain + `GridStyleOptions`. +- Modify: `Mapping/GridStyleDtoMapper.cs` — append `.ToString()` on all 53 color reads; DTOs untouched. +- Create: `SemiStep/SemiStep.UI/Styles/StyleColorConversions.cs` — `ToMediaColor(this StyleColor)` / + `ToStyleColor(this Avalonia.Media.Color)`, channel-wise. +- Modify: `Styles/PaletteBrushFactory.cs` — `From(StyleColor)` returning + `new SolidColorBrush(color.ToMediaColor())`, no parse. +- Modify: `StyleEditor/GridStyleEditorViewModel.cs` — `Seed`: `HexColor.Parse(options.X)` → + `options.X.ToMediaColor()`; `BuildRecord`: `HexColor.ToHex(Prop)` → `Prop.ToStyleColor()`. Properties, commands, + `RecomputeCanSave`, `NumericsInRange` unchanged. +- Delete: `StyleEditor/HexColor.cs`. + +**Files (tests — starting list; the build gate is the ground truth, grep the solution for the remaining string-color +constructions):** +- `Helpers/GridStyleOptionsTestData.cs` — the 53 fixture colors → `StyleColor.Parse("#010101")` etc., same values. +- `UI/StyleEditor/GridStyleEditorViewModelTests.cs`: + - Seed guard: `HexColor.Parse((string)recordValue!)` → `((StyleColor)recordValue!).ToMediaColor()`. + - **`IsLeaf` must add `type == typeof(StyleColor)`** — without it the perturbation leaf walk recurses into + `A`/`R`/`G`/`B` and reports paths like `Selection.Background.R`, breaking the exact-one-leaf assertion. This is + the guard-net adjustment of this slice; the non-vacuity re-proof is in Task 3. + - `BuildRecord_AfterEditingColorAndFontSize…`: `Background = "#123456"` → `StyleColor.Parse("#123456")`. + - `ToHex_PreservesSixDigitForm…` / `ToHex_EmitsEightDigitForm…`: assert `…Selection.Background.ToString()` equals + the 6/8-digit literal — same intent, the round-trip rule now lives on `StyleColor.ToString`. +- `Core/Configuration/GridStyleMapperTests.cs` — color assertions wrap literals in `StyleColor.Parse(…)`. +- `Core/Configuration/GridStyleEditorFacadeTests.cs` — line-30 style string assertions become + `….ToString().Should().StartWith("#")`; DELETE `Validate_MalformedColor_ReturnsFail` and + `Save_InvalidRecord_FailsValidationGuardBeforeWriting` — a record with `Depth0 = "not-a-color"` no longer compiles; + the compiler now provides this guarantee. +- `Core/Configuration/ConfigFacadeGridStyleValidationTests.cs` — `GridStyle.Execution.Depth0.Should().StartWith("#")` + → `.ToString().Should().StartWith("#")`. +- Other record-constructing tests (compiler-driven): `GridStyleEditorWindowTests`, `CellPaletteInstallerTests`, + `ExecutionPaletteInstallerTests`, `TransposedCellBackgroundConverterTests`, `GridStyleWriterTests`, and any test + the build flags. + +**NOT touched in this task:** `GridStyleValidator.cs`, `GridStyleColorsValidationTests.cs`, +`GridStyleOrientationTests.cs`, `ConfigFacade.cs` (beyond nothing — the validator call stays), facade `Load`/ +`Validate`/`Save` logic, `IGridStyleEditorFacade`. + +- [x] Flip the 7 group-record files' color fields to `StyleColor`; update `GridStyleOptions.Default`. +- [x] Rework both mappers per above (load: parse-under-validator-guarantee; save: `.ToString()`); flip + `PaletteBrushFactory`; add `StyleColorConversions`; rewire the VM's `Seed`/`BuildRecord`; delete `HexColor.cs`. +- [x] Retarget the fixture and all test constructions/assertions; add `StyleColor` to the guard leaf walk; delete + the two unrepresentable facade tests. +- [x] `dotnet build SemiStep.slnx` 0 warnings; full `dotnet test` green — including ALL untouched error tests + (validator still standing) and the slice-1 guards over typed colors; `dotnet format`. One commit. + +## Task 3: Fold validation into the load mapper, delete GridStyleValidator (one atomic commit) + +The highest-risk unit, isolated: only the validation seat moves; the type system is already settled by Task 2, so +every diff line here is about error production and call-site wiring. The untouched-in-Task-2 error tests are the +fidelity net. + +**Files:** +- Modify: `Mapping/GridStyleMapper.cs` — signature → `Result`; implement the aggregated parse + phase per "The target shape" and the error-identity table: null-dto and null-colors early-outs; required-section + handling for the three cell palettes (**every `!` on a color path replaced by real checks** — see the `!`-chain + trap); `RequiredColor`/`OptionalColor` helpers carrying the exact snake_case section/key strings; orientation + checked before `GridOrientationValues.Parse`; emission order preserved. +- Delete: `Validation/GridStyleValidator.cs`. +- Modify: `Facade/ConfigFacade.cs` — `GridStyleValidator.Validate(gridStyle)` → `GridStyleMapper.Map(gridStyle)` at + the SAME pipeline position (grid-style errors must keep propagating before `CrossReferenceValidator`, as today); + `MapToDomain` takes the mapped `GridStyleOptions` instead of the DTO. +- Modify: `GridStyleEditorFacade.cs` — `Load`: loader → `Map` result directly; `Validate`: `return Result.Ok();` + with a comment naming it a deliberate vacuous pass-through until slice 5 trims the interface; `Save`: remove the + pre-write validation gate; update the class XML doc (it currently claims "check color hex format only"). +- Modify comments referencing the dead precondition: `Dto/GridOrientationValues.cs` ("GridStyleValidator rejects + unknown values before this parser runs" — now the mapper does), any residual mapper comment. +- Modify tests: + - `GridStyleColorsValidationTests.cs` — swap the entry point: `GridStyleValidator.Validate(dto)` → + `GridStyleMapper.Map(dto)` (both expose `IsFailed`/`IsSuccess`/`Errors`; every assertion body stays + byte-identical, including all key-name and message-fragment checks). Drop the now-unused + `SemiStep.Core.Configuration.Validation` using. + - `GridStyleOrientationTests.cs` — `Validate_UnknownOrientation_Fails` and the `LoadValidated` helper retarget to + the mapper result (`Map(...).IsFailed` / `.Value`). + - `GridStyleMapperTests.cs` — `Map` now returns a Result: success paths read `.Value`; + `Map_NullDto_ReturnsDefaults` becomes `Map_NullDto_FailsWithConfigMissing` (the Default-on-null branch was + production-dead behind the validator; the pipeline behavior — null → `GridStyleConfigMissingError` — is what + survives). + - `ConfigFacadeGridStyleValidationTests.cs`: no further changes beyond Task 2's compile fix (the + `.ToString().Should().StartWith("#")` rewrite) — its error assertions must pass as-is. `ReasonLocalizerTests`, + `CoreErrorLocalizationCoverageTests`, and the RU-culture VM test: NO changes — they must pass as-is; that is the + point. +- [x] Implement the mapper-resident aggregated validation; delete the validator; rewire `ConfigFacade` and + `GridStyleEditorFacade`; make `Validate` vacuous (interface untouched). +- [x] Retarget the three test files' entry points with assertion bodies unchanged; verify the untouched suites + (`ConfigFacadeGridStyleValidationTests`, localization tests, RU-culture end-to-end message) are green with + zero diff. +- [x] **Error-identity scratch checks (do NOT commit any break):** in a temp config dir, (a) malform one required + hex (`readonly.depth_0: "zzz"`) → `facade.Load` fails with `GridStyleHexColorInvalidError` carrying + `colors.cells.readonly` / `depth_0` / `zzz`; (b) delete the whole `disabled:` block → single + `GridStyleSectionMissingError("colors.cells.disabled")` among otherwise-clean errors; (c) malform three keys + across two sections at once → all three errors in ONE result (aggregation proof); (d) delete the whole + `cells:` block (keeping `colors:` with e.g. `grid_line`) → EXACTLY three `GridStyleSectionMissingError`s with + paths `colors.cells.execution`, `colors.cells.readonly`, `colors.cells.disabled` and no `colors.cells` error — + this identity path is untested by the whole existing suite (the MissingExecution/ReadOnly/Disabled tests all + construct `Cells` non-null with two of three palettes present), so a wrong `if (Cells is null) → one error` + implementation would ship green without this check. Record results in the progress log. +- [x] **Guard non-vacuity re-proof:** temporarily cross-wire two same-type colors in the VM's `BuildRecord` + (e.g. pass `SelectionForeground.ToStyleColor()` into the Background slot) → the perturbation guard goes red + for those leaves (proves the leaf walk treats `StyleColor` as a leaf and still bites). Revert. Record in the + progress log. +- [x] `dotnet build SemiStep.slnx` 0 warnings (zero `GridStyleValidator` references left); full `dotnet test` green; + `dotnet format`. One commit. + +## Task 4: Correct the file-header format claims, doc note + +The shipped file headers advertise `#RGB`/`#ARGB`, which the validator has always rejected for validated keys and +`StyleColor.Parse` now rejects uniformly. `GridStyleWriter` preserves the leading comment block on every save, so the +wrong claim would survive editor rewrites indefinitely — fix the source files. + +**Files:** `ConfigFiles/MBE/ui/grid_style.yaml`, `ConfigFiles/MOCVD/ui/grid_style.yaml` (RIE ships no header; the +test fixture header makes no format claims), `Docs/architecture/grid-style-configuration.md`. + +- [x] Replace the header block in both shipped configs — current lines 2–6: + + ``` + # Color formats supported: + # - #RGB (e.g., #FFF) + # - #RRGGBB (e.g., #FFFFFF) + # - #ARGB (e.g., #0FFF for transparent white) + # - #AARRGGBB (e.g., #00FFFFFF for fully transparent white) + # Alpha: 00 = fully transparent, FF = fully opaque + ``` + + with: + + ``` + # Color formats supported: + # - #RRGGBB (e.g., #FFFFFF) + # - #AARRGGBB (e.g., #00FFFFFF for fully transparent white) + # Alpha: 00 = fully transparent, FF = fully opaque + ``` + +- [x] `Docs/architecture/grid-style-configuration.md`: fix the sentence claiming the header documents + "`#RGB`, `#RRGGBB`, `#ARGB`, `#AARRGGBB`" (currently ~line 44) to the two accepted formats. Also correct the + load-pipeline description this slice invalidated — chosen over a "diagram is stale" disclaimer because leaving + a wrong diagram directly above a correcting note would contradict itself, and the fix is two lines: in the + pipeline diagram (~lines 49–57) replace the `GridStyleValidator.Validate` step with the + `GridStyleMapper.Map` line (now "DTO → record, aggregated per-key validation, applies defaults"), and rewrite + the ~line-59 prose ("loads the DTO, runs `GridStyleValidator`, calls `GridStyleMapper.Map`" → "loads the DTO, + calls `GridStyleMapper.Map`, which validates and maps in one pass"). Add the interim slice-4 note: colors are + typed `StyleColor`, validation lives in the load mapper, the standalone validator and `HexColor` are gone (the + full doc rewrite lands in slice 5, which also deletes the now-obsolete "Known gap" section). +- [x] `dotnet build SemiStep.slnx` 0 warnings; full `dotnet test` green (the shipped-config load tests prove the + edited files still parse); `dotnet format`. One commit. + +## Post-Completion + +**Next:** slice 5 — split the VM into per-group `ReactiveObject` drafts (initializer = seed, positional `Build()` = +compile guard), shrink the parent VM to ~150 lines, group the AXAML binding paths (build-checked via `x:DataType`), +trim `IGridStyleEditorFacade` (the vacuous `Validate` and the per-keystroke call go away — `CanSave` reduces to the +VM-side numeric range checks), and rewrite `Docs/architecture/grid-style-configuration.md` as current-state. Slice 5 +closes #118. Remaining slice-5 fork, still open: AXAML rename scope (big-bang grouped paths vs bridge properties) +and whether the parent exposes drafts as properties or per-card `DataContext`. + +**Stacking:** this slice stacks on `grid-style-nest-record` (PR #177, unmerged) — exec branches +`grid-style-color-typing` off that branch, not `master`. After #177 merges: `git fetch origin && git rebase +origin/master && git push --force-with-lease`. + +**Executed by exec:** +- branch: grid-style-color-typing + +## Verify it yourself + +This slice is a structural refactor with one deliberate behavior change (hex case normalizes to uppercase on +save — the editor already rewrites the whole file). The on-disk YAML shape is unchanged, and the error identity a +user sees for a malformed config is byte-for-byte what the deleted validator produced. The evidence is in the +tests and the diff; there is no manual repro that distinguishes the validation seat move. + +**Stacked-branch note:** all commands below use the slice-3 base `grid-style-nest-record` as the diff base, not +`master` — this branch is stacked on the unmerged #177. + +1. **The validator is gone and nothing dangles.** `git grep -n GridStyleValidator -- '*.cs'` and + `git grep -n 'class HexColor'` both return empty; `dotnet build SemiStep.slnx` → 0 warnings, 0 errors. +2. **Error identity survived the validator→mapper move.** The error tests were retargeted from + `GridStyleValidator.Validate(dto)` to `GridStyleMapper.Map(dto)` with byte-identical assertion bodies, and the + untouched localization + Russian-culture end-to-end message test still pass: + `dotnet test SemiStep/SemiStep.Tests/SemiStep.Tests.csproj --filter "FullyQualifiedName~GridStyleColorsValidation|FullyQualifiedName~GridStyleOrientation|FullyQualifiedName~ReasonLocalizer|FullyQualifiedName~LoadAsync_MalformedHexColor"`. +3. **The null-`Cells`→exactly-three-errors identity is now a committed regression test** (was untested by the + whole prior suite): `dotnet test SemiStep/SemiStep.Tests/SemiStep.Tests.csproj --filter "FullyQualifiedName~Validate_NullCells_FailsWithExactlyThreeSectionErrors"` — green asserts exactly `colors.cells.execution/readonly/disabled` and no bare `colors.cells` error. +4. **Colors round-trip losslessly through the unchanged YAML format.** `StyleColor.ToString` writes into the + still-`string` DTOs, so the file shape is identical: + `dotnet test SemiStep/SemiStep.Tests/SemiStep.Tests.csproj --filter "FullyQualifiedName~SaveThenLoad_DistinctFixture"` (green) plus the full `StyleColor` parse/reject/round-trip suite + (`--filter "FullyQualifiedName~StyleColor"`). +5. **The header lie is fixed at the source.** `ConfigFiles/MBE/ui/grid_style.yaml` and + `ConfigFiles/MOCVD/ui/grid_style.yaml` headers now list only `#RRGGBB`/`#AARRGGBB`; `git diff` shows the change + is comment-only, no key/value touched. +6. **Full gate:** `dotnet build SemiStep.slnx` (0 warnings), full `dotnet test` (1716 passed), `dotnet format` + clean. +7. **Manual smoke (optional):** open the style editor, change a color and orientation, save, reopen — values + persist; a malformed hex hand-edited into the config still produces the same localized error at load. Note the + editor rewrites the whole file on save (hex normalizes to uppercase) — expected.