diff --git a/CLAUDE.md b/CLAUDE.md index 91c04c38..4e53eb65 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -272,7 +272,12 @@ python3 tools/ansi_frame_to_image.py frame.ansi frame.html # or .svg a single tinted pane cannot answer "whose pane is this", and the one that shows the two cues are on separate channels: identity in hue, focus in luminance. It writes the colours onto the real `CharacterDefinition`s; the **demo config carries none**, because `PaneTint.None` is the default and a - tinted demo would make every other frame in the gallery show a state most clients are not in), + tinted demo would make every other frame in the gallery show a state most clients are not in) and + `tint-input`/`tint-input-moved` (the same scene with **both command lines up**, before and after a real + ⌃→ — the only geometry that can show the *bar* wearing the focused character's colour beside a pane + wearing it, an armed tinted band over an idle tinted one, and the colour travelling with the focus. + The moved frame is also the one that shows a bar wearing a character's hue while its prompt reads + `no connection ›`, which is the composition rule stated in paint: hue says whose, not whether), `deletions`, `mssp`/`mssp-none`/`mssp-never` (the **three** states of the F5 ▸ `i` server-information report — a report, a server that answered and publishes none, and a world nothing has dialled; all three @@ -626,34 +631,67 @@ markup (`[bold #rrggbb on #rrggbb]…[/]`, `[[`/`]]` escaping, `[link=url]…[/] `▌` in the tab *title* — all zero-cost. `FocusIndicationTests.MovingFocusDoesNotMoveAnyPaneRectangle` is the test that stops this being "improved" into a border. Colours live in `WorkspacePalette`, whose constants are all derived from a `ScreenPalette` pair so the workspace and the settings screens share - one idea of what focus looks like; the focus step is `CursorBg ÷ EditBg`. -- **A pane's plane says two things, and they are kept on separate channels: identity is *hue*, focus is + one idea of what focus looks like; the focus step is `CursorBg ÷ EditBg`. The **command line** is the + fourth cue and the one that follows you between panes: it wears the armed band when ⏎ sends from it, + and — when the character behind the focused window has chosen a colour — that character's hue as well + (see the tint entry below). +- **A plane says two things, and they are kept on separate channels: identity is *hue*, focus is *luminance*.** A character may be given a colour (`CharacterDefinition.Tint` → `PaneTint`, Core; `WorkspacePalette.Tint`, Tui; F5's `tint` row), that colour becomes the pane's base plane, and the same focus multiplication is applied on top of it — `PaneSurfaceTone` is two lines and the composition is - the whole design. Four things about it are load-bearing. **The tint preserves the surface's luminance - exactly**: the anchor is re-lit to the surface's own luma before it is mixed (`AtLuma`), and luma is - linear in the channels, so the blend is luminance-neutral by construction rather than by a tuned - constant. That is not tidiness — this is the plane the *game's* text is read against, the server picks - that text's colours, and a tint that moved the brightness would change every contrast ratio the theme - was designed around, on a palette we cannot test. It also means the focus step lands the same distance - above a tinted plane as above a plain one, so a colour cannot make one character's pane look more - focused than another's. **It therefore says nothing on a monochrome terminal, deliberately** — the cue - that must survive a lost hue is focus, and the rail and the tab title still name the character in - words. **It is a truecolor cue**: at the surface's luminance the tints are a few points per channel - apart, and a 256-colour terminal quantises them onto the untinted entry — which degrades to exactly the - pane there would otherwise be. That is unacceptable for focus, which is why + the whole design. Load-bearing, in order. **All six tints sit at exactly one luminance**: the plane is + re-lit to the target before the anchor is mixed into it (`AtLuma`), and luma is linear in the channels, + so both ends of the blend share that luma and so does every point between them — by construction, on + any theme, for any `TintStrength`. No character's pane is brighter than another's, and the focus step — + a *multiplication* — therefore lands the same **ratio** above each. **That one luminance is `TintDepth` + below the untinted surface, not level with it**, and the change is deliberate: the first cut held the + tinted plane at the surface's own luma to preserve every contrast ratio the theme was designed around, + and MU\* servers are written for **black** terminals, so the plane their bright ANSI is read on wants + to be darker than the client's chrome rather than level with it. **The depth is bounded and the bound + is arithmetic**: a client may hold tinted and untinted characters at once, so a depth reaching + `1 ÷ FocusScale` would leave a *focused* tinted pane no brighter than an *unfocused* untinted one — + the focus cue reporting the wrong fact. `TintDepth` is the geometric mean of that floor and no + darkening at all, so the untinted surface sits midway in ratio (√FocusScale ≈ 1.26 either way); + `EveryFocusedPaneOutshinesEveryUnfocusedOneAcrossTheWholePalette` is the pin, and it is why "make them + darker still" is a change that has to move `FocusScale` too. **The anchors are saturated for the same + reason**: chroma is bounded by luminance, so at the darker target a muted anchor has nothing left — + the first set's two closest colours were ΔE 8.2 apart and its nearest was ΔE 7.7 from the untinted + plane; these are ΔE 14.4 and ΔE 12.1. **It says nothing on a monochrome terminal, deliberately** — the + cue that must survive a lost hue is focus, and the rail and the tab title still name the character in + words — and it is a **truecolor** cue, which is unacceptable for focus and is why `FocusSurvivesA256ColourTerminal` exists and has no tint counterpart. And **it costs no cells**, for the same NAWS reason the focus cue does not: `PaneTintTests.TintingACharacterMovesNoPaneRectangle` is that - pin, and it commits through `SaveConfiguration`, which is also what makes an F5 edit reach the panes - *now* rather than at the user's next focus move. A pane wears the colour of the window **in front of - it** — a pane can host several characters' windows as tabs and paints one rectangle — resolved through - the workspace's ownership record and never through `_active`, because a background pane wearing the - focused character's colour would say the opposite of what it means. The palette is a **closed set of - six names** and not a hex: a free colour cannot be validated against a theme the user may change - tomorrow, and a name survives that change where a hex picked against a dark theme becomes a hole. - `PaneTint.None` is the default and **no migration marks anybody** — the same reasoning as - `ConnectAtStartup`, and the reason the schema version did not move for it. + pin (rectangles *and* `LaidOutRows`, since the command line is a sticky band and one that grew a row + would take that row off every pane), and it commits through `SaveConfiguration`, which is also what + makes an F5 edit reach the panes *now* rather than at the user's next focus move. A pane wears the + colour of the window **in front of it** — a pane can host several characters' windows as tabs and + paints one rectangle — resolved through the workspace's ownership record and never through `_active`, + because a background pane wearing the focused character's colour would say the opposite of what it + means. The palette is a **closed set of six names** and not a hex: a free colour cannot be validated + against a theme the user may change tomorrow, and a name survives that change where a hex picked + against a dark theme becomes a hole. `PaneTint.None` is the default and **no migration marks anybody** + — the same reasoning as `ConnectAtStartup`, and the reason the schema version did not move for it. + - **The command line wears it too, and takes the hue without the depth** (`PaintInputBands`, + `WorkspacePalette.IdleBand(theme, tint)` / `ArmedBand(theme, tint)`). On a pane, luminance carries + focus as a ratio that a step applied equally to all six leaves intact; on the input row luminance is + *already* spoken for — it is the whole armed-versus-idle cue — so a colour that moved it would put a + second fact on a channel that carries one. Hue-only means the armed bar stays exactly the step above + the idle one that it always was, in every colour and on every theme, and that `IdleInk` (measured + against the untinted band, and shared with the tab chips) keeps the contrast it was picked with. + The armed band is derived **from the tinted idle band** rather than tinted itself, so the lean toward + `Theme.Prompt` survives on top of the character's hue and the pair still differ in brightness *and* + colour. The one theme where the step narrows is **Light**, where the lift clamps against white before + the prompt lean is applied — it predates tints, its untinted band is already the narrowest of the + three, and `ATintedCommandLineIsStillObviouslyArmedOrIdle` holds it to `Visible` there and to + `Obvious` everywhere else. + - **Whose colour the bar wears is `SendTarget`'s answer, falling back to the focused window's recorded + owner** (`InputTint`) — never `_active`, which is the misdelivery bug in every shape it has had. The + fallback is the second arm `WindowSession` already walks and the same record `PaneTintOf` reads, so + the bar and the pane above it are *one* answer rather than two that agree most of the time. + **The colour says whose, never whether**: a focused window whose owner has no session this run wears + that owner's colour while the prompt reads `no connection ›` and ⏎ refuses out loud — identity and + reachability are two facts, and the row already states the second twice. A window nobody owns (the + web view) leaves both bands exactly as they were. - **The Ctrl+arrows move pane *selection*, not keyboard focus — but selection carries the session.** The pin (`FocusChanged → PinFocusToArmedBar`) is untouched: typing always lands in the armed command line wherever you have navigated to. That is a fact about which *control* gets a keystroke, and it says diff --git a/docs/design/README.md b/docs/design/README.md index 9a729445..80998c7b 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -295,9 +295,12 @@ top to bottom: plaintext*), the connect-line template (`connect %CHARACTER% %PASSWORD%`), on-connect, `at start`, `login` (derived, read-only), session state, log format + folder, `restore`, and `tint` — the colour this character's panes are painted in, chosen from a closed list of six names (plus `None`, the - default, which leaves the pane on the theme's own surface). A tint changes the plane's hue and not its - brightness, so the game's text keeps every contrast the theme was designed with and the focused pane is - still the brighter one. Right: the trigger-set checklist — each row is + default, which leaves the pane on the theme's own surface). It colours the **command line** as well, so + a glance at the bar says whose connection `⏎` is aimed at. All six tints sit at one luminance — no + character's pane is brighter than another's — one step below the untinted surface, because a MU\*'s own + bright ANSI is read on that plane and the games are written for black terminals; the bar takes the hue + without the step, since brightness there already says which of the two command lines is armed. The + focused pane is still the brighter one, whatever colours are in play. Right: the trigger-set checklist — each row is `[x] ▪ Comms — channel + page routing 2 rules`. Toggling assigns/unassigns live. Footer: `[Esc] Close` and, on the right, whatever `⏎` does on the row the cursor is on (`Edit`, `Add`, diff --git a/src/SharpMUTerm.Tui/SharpMUTermApp.cs b/src/SharpMUTerm.Tui/SharpMUTermApp.cs index 84e82555..41256272 100644 --- a/src/SharpMUTerm.Tui/SharpMUTermApp.cs +++ b/src/SharpMUTerm.Tui/SharpMUTermApp.cs @@ -1124,7 +1124,13 @@ public string RenderSnapshot(string? view = null) // shipping path. The *demo config* carries none, deliberately: PaneTint.None is the default, so a // demo that tinted its characters would make every other frame in the gallery show a state most // clients are not in, and would quietly become the thing the palette is checked against. - if (string.Equals(view, "tint", StringComparison.OrdinalIgnoreCase)) + // + // `tint-input` is the same scene with both command lines up, which is the only geometry that can + // show the *bar* wearing the focused character's colour beside a pane wearing it — and + // `tint-input-moved` is that frame after a real ⌃→, so the pair is the before and after of the + // keystroke rather than two hand-posed states. It is the same construction `focus`/`focus-moved` + // use, for the same reason: a colour that follows the focus is a claim about two frames. + if (view is not null && view.StartsWith("tint", StringComparison.OrdinalIgnoreCase)) { // Guarded the way `characters` above is: --demo-config is the caller's choice, so a machine // with no worlds — or a world nobody has put a character in — reaches this line. @@ -1147,6 +1153,22 @@ public string RenderSnapshot(string? view = null) PaneCommands.Apply(_workspace.Layout, PaneCommand.SplitRight); RebuildPaneArea(); + + if (view.StartsWith("tint-input", StringComparison.OrdinalIgnoreCase)) + { + ToggleSecondBar(); + ArmBar(_input); + + if (view.EndsWith("-moved", StringComparison.OrdinalIgnoreCase)) + { + RenderFrame(); // a directional move reads the panes' arranged bounds + SimulateKey(Stroke('\0', ConsoleKey.RightArrow, ctrl: true)); + + // That frame left the driver's front buffer populated, so the closing render would + // emit only the cells that changed — see the `drag` and `focus-moved` views. + ReArmWholeFrame(); + } + } } // The deletion review, reached the only way a user can reach it: open F5, take the selected world @@ -3061,8 +3083,12 @@ private void SetUpBar(InputBarControl bar, InputBar kind) // same tone as the focused pane and the idle one sits on the same recessed plane as the rest of // the chrome. They used to be two hardcoded blue-greys thirteen points apart per channel, which // is what "make it super obvious which input window is selected" was reported about. - bar.BandColor = ToColor(WorkspacePalette.ArmedBand(_theme)); - bar.IdleBandColor = ToColor(WorkspacePalette.IdleBand(_theme)); + // + // These are the untinted pair, and they are only the starting value: PaintInputBands owns the + // live one, because the bands follow the character ⏎ is aimed at and that changes without the + // bar being rebuilt. Setting them here anyway means a bar is never unpainted between construction + // and the first chrome refresh. + PaintInputBands(PaneTint.None); bar.TextColor = ToColor(_theme.Resolve(TerminalColor.Default, isBackground: false)); bar.IdleTextColor = ToColor(WorkspacePalette.IdleInk(_theme)); bar.HasSibling = () => _second.Visible; @@ -4050,12 +4076,77 @@ private void RefreshStatusRow() /// private void UpdateInputChrome() { + // The bands first, because the prompt cells are painted to match them (PromptMarkup reads the + // pair this leaves behind). One order, one refresh, so the bar's fill and the prompt on top of it + // cannot be a frame apart. + PaintInputBands(InputTint()); var label = PromptLabel(); _input.Prompt = PromptMarkup(label, _input.Armed); _second.Prompt = PromptMarkup(SecondPromptLabel(label), _second.Armed); RefreshStatusBar(); } + /// + /// The two bands the command lines are currently painted in. Held rather than recomputed so that + /// paints its cells in the tone the bar was actually given: the prompt and + /// the bar make one continuous row, and deriving that colour twice from two expressions is how the + /// two hardcoded hexes this replaced came to need a "keep in sync" comment. + /// + private (Rgb Armed, Rgb Idle) _inputBands; + + /// + /// Repaints both command lines in 's colour. The bar wears the tint of the + /// character it is talking to, so "which pane am I in", "who does ⏎ reach" and "which line is + /// armed" are answered by one row without reading a word of it. + /// + /// Tint and armed-ness compose the way hue and luminance do everywhere else here. The colour + /// moves both bands' hue and neither band's brightness (see + /// ), so the armed bar stays exactly the same + /// step above the idle one in every colour — plus the lean toward the theme's prompt hue, the bold + /// versus dim prompt and the bright versus dim ink, none of which a tint touches. A tint can + /// therefore never make the two bars harder to tell apart, which is the property the whole input-band + /// design was rebuilt around. + /// + /// + private void PaintInputBands(PaneTint tint) + { + _inputBands = (WorkspacePalette.ArmedBand(_theme, tint), WorkspacePalette.IdleBand(_theme, tint)); + foreach (var bar in new[] { _input, _second }) + { + bar.BandColor = ToColor(_inputBands.Armed); + bar.IdleBandColor = ToColor(_inputBands.Idle); + } + } + + /// + /// Whose colour the command line wears: the character behind the focused window, resolved + /// through — the session ⏎ actually reaches — and falling back to the owner + /// the workspace records for that window when no session is open for it. Never _active: that + /// fallback is the misdelivery bug in every shape it has had, and a bar wearing the colour of a + /// character whose pane you are not in would be the loudest shape yet. + /// + /// The fallback is the second arm already walks, which is what makes the + /// bar and the pane above it one answer rather than two that agree most of the time: a pane + /// is painted from the same record (), and a command line sitting under a + /// tinted pane in a different colour would say the two belong to different characters. + /// + /// + /// The colour says whose, never whether. A focused window whose owner has no session this run + /// still wears that owner's colour while the prompt reads no connection › and ⏎ refuses out + /// loud () — identity and reachability are two facts, the tint carries + /// the first everywhere else in this client, and the row already states the second twice. A window + /// nobody owns (the web view) and a character who has chosen no colour both leave the bands exactly + /// as they were before any of this existed. + /// + /// + private PaneTint InputTint() => + TintOf(SendTarget()?.SessionKey ?? _workspace.FindWindow(ActiveWindowId())?.SessionKey); + + /// The colour a world.character key has chosen, or none when the key names nobody + /// this configuration still holds. + private PaneTint TintOf(string? sessionKey) => + sessionKey is { Length: > 0 } key ? CharacterFor(key)?.Tint ?? PaneTint.None : PaneTint.None; + /// /// What the command line calls itself: the character ⏎ will reach, and nothing else. It reads /// rather than _active, because those two differ in exactly one state @@ -4097,14 +4188,15 @@ private static string SecondPromptLabel(string label) => /// /// The input band's background hex — shared by the bar's own fill () /// and the prompt cells () so the input row reads as one solid full-width - /// band. It reads the tone out of rather than restating it: these two - /// were a pair of hardcoded hexes carrying a "keep in sync with " comment, - /// which is a colour the theme should have owned and two places to forget. + /// band. It reads the tone the bar was given () rather than + /// re-deriving it: these two were a pair of hardcoded hexes carrying a "keep in sync with + /// " comment, and now that the band follows the character ⏎ is aimed at, a + /// second derivation would be a second place for the prompt cells to disagree with the row they sit on. /// - private string InputBandHex => Hex(WorkspacePalette.ArmedBand(_theme)); + private string InputBandHex => Hex(_inputBands.Armed); /// The band behind the bar ⏎ will not send from. - private string IdleBandHex => Hex(WorkspacePalette.IdleBand(_theme)); + private string IdleBandHex => Hex(_inputBands.Idle); /// /// Wraps a prompt label so its cells carry the band background and its styling says whether this is @@ -6663,9 +6755,7 @@ private PaneTint PaneTintOf(string? paneId) return PaneTint.None; } - return _workspace.FindWindow(windowId)?.SessionKey is { Length: > 0 } owner - ? CharacterFor(owner)?.Tint ?? PaneTint.None - : PaneTint.None; + return TintOf(_workspace.FindWindow(windowId)?.SessionKey); } /// diff --git a/src/SharpMUTerm.Tui/WorkspacePalette.cs b/src/SharpMUTerm.Tui/WorkspacePalette.cs index 7e55b49f..d315e4de 100644 --- a/src/SharpMUTerm.Tui/WorkspacePalette.cs +++ b/src/SharpMUTerm.Tui/WorkspacePalette.cs @@ -27,9 +27,17 @@ namespace SharpMUTerm.Tui; /// /// A pane's plane can also carry whose pane it is (), and the two facts a /// plane states are kept on separate channels so neither can be mistaken for the other: identity is -/// hue and focus is luminance. moves the surface's colour without moving its -/// brightness, moves its brightness without moving its colour, and the focus -/// step lands the same distance above a tinted plane as above an untinted one. +/// hue and focus is luminance. moves the surface's colour, +/// multiplies its brightness, and because the focus step is a ratio it lands the same relative +/// distance above every plane it is applied to. +/// +/// +/// The command line wears the same hue ( / +/// ), so the bar under a pane says whose connection ⏎ is aimed +/// at. It takes the hue and not the step the pane takes: on the input +/// band luminance is already spoken for — it is the whole armed-versus-idle cue — and a colour that +/// moved it too would put a second fact on a channel that already carries one. On the pane, luminance +/// carries focus as a ratio, which a step applied equally to all six tints leaves untouched. /// /// internal static class WorkspacePalette @@ -131,71 +139,110 @@ internal static Rgb Surface(Theme theme) internal static Rgb Focus(Rgb plane) => Scale(plane, FocusScale); /// - /// The plane a character's pane is painted on: pushed toward the tint's hue at - /// the surface's own luminance, so a workspace holding several characters says whose pane is - /// whose. returns the surface itself, byte for byte — an untinted client - /// is painted exactly as it was before this existed. + /// The plane a character's pane is painted on: taken one + /// step down and pushed toward the tint's hue at that luminance, so a workspace holding + /// several characters says whose pane is whose. returns the surface + /// itself, byte for byte — an untinted client is painted exactly as it was before this existed. + /// + /// All six tints sit at exactly one luminance, and it is not the surface's. The first half is + /// the legibility guarantee and is by construction: the plane is re-lit to the target + /// () before the anchor is mixed into it, and luma is linear in the + /// channels, so both ends of the blend share that luma and so does every point between them — for + /// any and on any theme. No character's pane is brighter than another's, + /// and the focus step therefore lands the same ratio above each. + /// /// - /// The luminance is preserved deliberately, and that is the whole legibility argument. A MU* - /// server chooses its own text colours and this is the plane they are read against, so a tint that - /// darkened or lightened the pane would change every contrast ratio the theme was designed around — - /// on a palette the client does not control and cannot test. Matching the anchor to the surface's - /// luminance before mixing () makes the mix luminance-neutral by - /// construction rather than by a constant somebody tuned: luma is linear in the channels, so a blend - /// of two colours of equal luma has that luma too, for any and on any - /// theme. + /// The second half is a deliberate departure from the first cut of this feature, which held the + /// tinted plane at the untinted surface's exact luma. MU* servers are written for black + /// terminals and their own bright ANSI is what is read on this plane, so the pane wants to be + /// darker than the client's chrome rather than level with it. What is given up is the claim that a + /// tint changes no contrast ratio the theme was designed around; what is kept is the reason that + /// claim was made — the game's text sits on a plane one step away from it on a dark theme, + /// which raises contrast rather than lowering it. The step is bounded and the bound is not a + /// preference: see . /// /// /// Two consequences worth stating rather than discovering. The tint carries no information a /// monochrome terminal can show, and it must not: the cue that has to survive a lost hue is /// focus, and focus is luminance. A reader who cannot use the tint loses nothing they did not /// already have — the sidebar and the tab title still name the character in words. And it is a - /// truecolor cue: at the surface's luminance the tints are a few points apart per channel, which - /// a 256-colour terminal will quantise onto the same entry as the untinted plane. That degrades to - /// exactly the pane there would otherwise be, which is why it is acceptable here and would - /// not be for focus (see WorkspacePaletteTests.FocusSurvivesA256ColourTerminal). + /// truecolor cue: a 256-colour terminal quantises these planes onto a handful of entries, which + /// degrades to a pane that is merely dark rather than to a wrong answer. That is why + /// WorkspacePaletteTests.FocusSurvivesA256ColourTerminal exists and has no tint counterpart. /// /// internal static Rgb Tint(Theme theme, PaneTint tint) { ArgumentNullException.ThrowIfNull(theme); + return Tinted(Surface(theme), tint, TintDepth); + } + + /// + /// One plane wearing one character's colour: re-lit to + /// of its own luminance, then mixed toward the tint's hue at that same + /// luminance. The two callers hand it two different depths and the difference is the whole + /// composition rule — see the type's own summary. + /// + private static Rgb Tinted(Rgb plane, PaneTint tint, double depth) + { + if (Anchor(tint) is not { } anchor) + { + return plane; + } - var surface = Surface(theme); - return Anchor(tint) is { } anchor - ? Mix(surface, AtLuma(anchor, Luma(surface)), TintStrength) - : surface; + var lit = AtLuma(plane, Luma(plane) * depth); + return Mix(lit, AtLuma(anchor, Luma(lit)), TintStrength); } /// - /// How far a tinted plane travels from the theme's surface toward its hue. It is a pure chroma + /// How far below the untinted surface a tinted pane sits, as a fraction of its luminance. It is + /// bounded from below by the focus step and the bound is arithmetic, not taste. A client may + /// hold tinted and untinted characters at once, so the four planes on screen are + /// tinted, surface, tinted·, surface·FocusScale — and if the depth + /// ever reached 1 ÷ FocusScale a focused tinted pane would be no brighter than an + /// unfocused untinted one, which is the focus cue reporting the wrong fact for the reason + /// the tint work exists to prevent. The value is the geometric mean of that floor and no darkening + /// at all, so the untinted surface sits exactly midway — in ratio, √FocusScale ≈ 1.26 either way — + /// between a tinted pane and a focused tinted one. Every focused pane on the screen is then brighter + /// than every unfocused one, whatever colours are in play. + /// + private static readonly double TintDepth = 1.0 / Math.Sqrt(FocusScale); + + /// + /// How far a tinted plane travels from its own tone toward the tint's hue. It is a pure chroma /// control — has already taken the brightness question away — so this is the - /// answer to "how coloured", and nothing else. Short of the whole way, because the anchor at the - /// surface's luminance is as saturated as that luminance allows and a pane painted in it reads as a - /// coloured panel rather than as a client with a quiet mark on it. The thing being identified is a - /// character, not an alarm. + /// answer to "how coloured", and nothing else. It went up a little with , and + /// only a little: chroma is bounded by luminance, so the same fraction of a darker plane is a fainter + /// colour, but the work of keeping the six apart is the s' and not this constant's. + /// Still well short of the whole way, because this is the plane the game's own colours are read + /// against and the thing being identified is a character, not an alarm — pushed to the anchor itself + /// the pane reads as a coloured panel, which is a client shouting a fact nobody asked it to repeat. /// - private const double TintStrength = 0.7; + private const double TintStrength = 0.75; /// - /// The hue each named tint stands for, as a mid-luminance reference colour. These are never painted: - /// re-lights each one to the active theme's surface before it is used, so what is - /// fixed here is the hue and the modest saturation, and the brightness is the theme's. + /// The hue each named tint stands for, as a reference colour. These are never painted: + /// re-lights each one to the plane it is going onto, so what is fixed here is + /// the hue and the saturation, and the brightness is the theme's. /// /// Six, spread around the wheel at roughly even spacing (blue → blue-green → green → amber → /// red-orange → violet), because the failure this feature has is two characters whose colours a - /// reader has to compare rather than recognise. They are muted rather than primary for the same - /// reason the anchors exist at all: re-lit to a dark theme's surface a primary would come out as - /// nearly the maximum chroma that luminance can hold, and the pane would shout. + /// reader has to compare rather than recognise. They are saturated, and that is what the darker + /// target bought: re-lit down to a dark theme's pane a muted anchor has almost no chroma left, and + /// the first set — muted, at the surface's own brightness — left the two closest of the six ΔE 8.2 + /// apart on the default theme, with the nearest only ΔE 7.7 from the untinted plane. Measured the + /// same way, these are ΔE 14.4 from each other and ΔE 12.1 from the untinted plane, on a plane that + /// is also a fifth darker. /// /// private static Rgb? Anchor(PaneTint tint) => tint switch { - PaneTint.Slate => new Rgb(0x4a, 0x6f, 0xa5), - PaneTint.Teal => new Rgb(0x2a, 0x8c, 0x84), - PaneTint.Moss => new Rgb(0x5a, 0x8a, 0x42), - PaneTint.Ochre => new Rgb(0xa8, 0x7c, 0x2e), - PaneTint.Ember => new Rgb(0xa8, 0x54, 0x40), - PaneTint.Plum => new Rgb(0x84, 0x54, 0xa0), + PaneTint.Slate => new Rgb(0x1e, 0x5c, 0xe0), + PaneTint.Teal => new Rgb(0x00, 0xa0, 0x94), + PaneTint.Moss => new Rgb(0x3f, 0xa8, 0x18), + PaneTint.Ochre => new Rgb(0xd4, 0x96, 0x00), + PaneTint.Ember => new Rgb(0xd8, 0x33, 0x1c), + PaneTint.Plum => new Rgb(0xb0, 0x2c, 0xd4), _ => null, }; @@ -240,10 +287,24 @@ private static Rgb AtLuma(Rgb rgb, double target) /// takes the same focus step everything else does, and picks up the theme's prompt hue on the way. /// /// - internal static Rgb IdleBand(Theme theme) + internal static Rgb IdleBand(Theme theme) => IdleBand(theme, PaneTint.None); + + /// + /// The same band wearing a character's colour — the bar under a tinted pane, so a glance at the + /// command line says whose connection ⏎ is aimed at without reading the prompt. + /// + /// Hue only: the band keeps its luminance exactly. On a pane the tint takes a + /// step down as well, and it must not here, because luminance on this row is + /// already the armed-versus-idle cue — the one thing the input area says with brightness. Leaving it + /// alone means the step between the two bands is the step it has always been, in every colour and on + /// every theme, and that the ink chosen to be read on these bands () keeps the + /// contrast it was picked with. + /// + /// + internal static Rgb IdleBand(Theme theme, PaneTint tint) { ArgumentNullException.ThrowIfNull(theme); - return Scale(theme.StatusBackground, IdleBandScale); + return Tinted(Scale(theme.StatusBackground, IdleBandScale), tint, depth: 1.0); } /// @@ -258,15 +319,31 @@ internal static Rgb IdleBand(Theme theme) /// three survive a terminal that cannot render the fourth. /// /// - internal static Rgb ArmedBand(Theme theme) + internal static Rgb ArmedBand(Theme theme) => ArmedBand(theme, PaneTint.None); + + /// + /// The armed band over a tinted idle one. Derived from rather + /// than tinted in its own right, and that ordering is the point: the lift and the lean toward + /// are applied after the character's hue, so both cues survive a + /// tint — the armed bar is brighter than the idle one by the step it always was, and still bluer than + /// it by the theme's own prompt colour. Tinting the armed band directly would have overwritten that + /// lean with the character's hue and left the pair differing in brightness alone. + /// + internal static Rgb ArmedBand(Theme theme, PaneTint tint) { ArgumentNullException.ThrowIfNull(theme); - return Mix(Scale(IdleBand(theme), FocusScale), theme.Prompt, PromptTint); + return Mix(Scale(IdleBand(theme, tint), FocusScale), theme.Prompt, PromptTint); } /// /// Text on an idle band: the theme's foreground pulled most of the way down to that band. Dimmer /// than the armed bar's ink, so the pair still reads apart if a terminal flattens both backgrounds. + /// + /// Measured against the untinted band, and safely so: a tint moves that band's hue and not + /// its luminance (), so this ink keeps the contrast it was + /// picked with whatever colour the bar is wearing. One ink for every tint also keeps the tab chips, + /// which share it, from acquiring a per-character text colour nobody asked for. + /// /// internal static Rgb IdleInk(Theme theme) { diff --git a/tests/SharpMUTerm.Tui.Tests/PaneTintTests.cs b/tests/SharpMUTerm.Tui.Tests/PaneTintTests.cs index 47607628..29dbe2ef 100644 --- a/tests/SharpMUTerm.Tui.Tests/PaneTintTests.cs +++ b/tests/SharpMUTerm.Tui.Tests/PaneTintTests.cs @@ -219,6 +219,146 @@ await Assert.That(app.PaneSurfaceColors[pane!]) .IsEqualTo(pane == app.FocusedPaneId ? focused : unfocused); } + // --- the command line wears it too ------------------------------------------------------------ + + /// The colour the character behind the focused window has chosen, read off the same two + /// records the app reads — the pane's front window, and the owner the workspace has for it. + private static PaneTint FocusedCharactersTint(SharpMUTermApp app, AppConfiguration config) + { + var key = app.PaneActiveTab(app.FocusedPaneId) is { } window ? app.WindowOwnerOf(window) : null; + return config.Worlds + .SelectMany(world => world.Characters.Select(c => (Key: $"{world.Name}.{c.Name}", Character: c))) + .FirstOrDefault(pair => pair.Key == key) + .Character?.Tint ?? PaneTint.None; + } + + /// + /// The command line wears the colour of the character ⏎ is aimed at. The bar sits directly + /// under the pane and is the other half of the same question — a glance at one row now says which + /// pane you are in, whose connection this is, and which line is armed — so a bar in a different + /// colour from the pane above it would say the two belong to different characters. + /// + [Test] + public async Task TheCommandLineWearsTheFocusedCharactersColour() + { + var config = DemoScene.Build(); + var app = App(config); + + var ansi = app.RenderSnapshot("tint"); + var tint = FocusedCharactersTint(app, config); + var (armed, idle) = app.InputBandColors; + + await Assert.That(tint).IsNotEqualTo(PaneTint.None); // the view really did colour the focused one + await Assert.That(armed).IsEqualTo(Colour(WorkspacePalette.ArmedBand(config.Theme, tint))); + await Assert.That(idle).IsEqualTo(Colour(WorkspacePalette.IdleBand(config.Theme, tint))); + + // And it is on the frame, not merely on the control: the band is the bar's own fill. + await Assert.That(FrameGrid.CellsPainted(ansi, armed)).IsGreaterThan(0); + await Assert.That(armed).IsNotEqualTo(Colour(WorkspacePalette.ArmedBand(config.Theme, PaneTint.None))); + } + + /// + /// And it follows the focus, through the real ⌃→. This is the property the feature is for: + /// navigating to another character's pane repaints the row you type into, so the client cannot be in + /// the state the send path's own resolver exists to prevent — attention on one pane, keystrokes to + /// another — without saying so in colour. + /// + [Test] + public async Task MovingTheFocusMovesTheBarsColourWithIt() + { + var config = DemoScene.Build(); + var app = App(config); + app.RenderSnapshot("tint"); + + var before = app.InputBandColors; + var wasTint = FocusedCharactersTint(app, config); + + app.SimulateKey(new ConsoleKeyInfo('\0', ConsoleKey.RightArrow, false, false, true)); + var ansi = app.RenderWholeFrame(); + + var nowTint = FocusedCharactersTint(app, config); + var (armed, idle) = app.InputBandColors; + + await Assert.That(nowTint).IsNotEqualTo(wasTint); // the move landed on the other character + await Assert.That(armed).IsEqualTo(Colour(WorkspacePalette.ArmedBand(config.Theme, nowTint))); + await Assert.That(idle).IsEqualTo(Colour(WorkspacePalette.IdleBand(config.Theme, nowTint))); + await Assert.That(armed).IsNotEqualTo(before.Armed); + await Assert.That(FrameGrid.CellsPainted(ansi, armed)).IsGreaterThan(0); + + // The bar and the pane it sits under are one answer, not two that happen to agree. + await Assert.That(app.PaneSurfaceColors[app.FocusedPaneId]) + .IsEqualTo(Colour(WorkspacePalette.Focus(WorkspacePalette.Tint(config.Theme, nowTint)))); + } + + /// + /// Armed and idle still read apart under a tint, which is the constraint the composition rule + /// exists to satisfy: the colour moves both bands' hue and neither band's brightness, so the step + /// that says which line ⏎ sends from is the step it has always been. Read off a frame carrying both + /// bars at once, because a pair of properties can differ while one of them is never painted. + /// + [Test] + public async Task ATintedPairOfCommandLinesStillSaysWhichOneIsArmed() + { + var config = DemoScene.Build(); + var app = App(config); + + var ansi = app.RenderSnapshot("tint-input"); + var (armed, idle) = app.InputBandColors; + + await Assert.That(app.SecondBarShown).IsTrue(); + await Assert.That(FocusedCharactersTint(app, config)).IsNotEqualTo(PaneTint.None); + await Assert.That(FrameGrid.Sgr(armed)).IsNotEqualTo(FrameGrid.Sgr(idle)); + await Assert.That(FrameGrid.CellsPainted(ansi, armed)).IsGreaterThan(0); + await Assert.That(FrameGrid.CellsPainted(ansi, idle)).IsGreaterThan(0); + + // The armed prompt is bold and the idle one dim, whatever colour they are wearing — the cue that + // survives a terminal flattening two backgrounds together. + await Assert.That(app.PrimaryPromptMarkup).StartsWith("[bold on "); + await Assert.That(app.SecondPromptMarkup).StartsWith("[dim on "); + } + + /// + /// A client whose characters have chosen no colour has the command line it always had. + /// is the default and there is no migration marking anybody, so this is + /// the pin that makes the bar's half of the feature safe to ship — the same claim + /// makes about the panes. + /// + [Test] + public async Task AnUntintedClientsCommandLineIsUnchanged() + { + var config = DemoScene.Build(); + var app = App(config); + + app.RenderSnapshot(); + var (armed, idle) = app.InputBandColors; + + await Assert.That(armed).IsEqualTo(Colour(WorkspacePalette.ArmedBand(config.Theme))); + await Assert.That(idle).IsEqualTo(Colour(WorkspacePalette.IdleBand(config.Theme))); + } + + /// + /// A focused window nobody owns leaves the bands exactly where they were, even on a client whose + /// characters have colours. There is nobody for the bar to be, and wearing the last character's + /// colour would be the _active fallback in a new place — the misdelivery bug this client keeps + /// finding, said in paint. + /// + [Test] + public async Task AFocusedWindowWithNoOwnerLeavesTheBandsAlone() + { + var config = DemoScene.Build(); + Corvid(config).Tint = PaneTint.Plum; + var app = App(config); + app.RenderSnapshot("web"); // the web view belongs to no character and is brought to the front + + var focusedWindow = app.PaneActiveTab(app.FocusedPaneId); + var (armed, idle) = app.InputBandColors; + + await Assert.That(focusedWindow).IsNotNull(); + await Assert.That(app.WindowOwnerOf(focusedWindow!)).IsNull(); + await Assert.That(armed).IsEqualTo(Colour(WorkspacePalette.ArmedBand(config.Theme))); + await Assert.That(idle).IsEqualTo(Colour(WorkspacePalette.IdleBand(config.Theme))); + } + // --- the NAWS trap ---------------------------------------------------------------------------- /// @@ -249,17 +389,24 @@ public async Task TintingACharacterMovesNoPaneRectangle(int width, int height) var before = app.PaneOutputRects().ToDictionary(p => p.Key, p => p.Value, StringComparer.Ordinal); var planesBefore = app.PaneSurfaceColors.ToDictionary(p => p.Key, p => p.Value, StringComparer.Ordinal); var railBefore = app.RailColumnWidth; + var rowsBefore = app.LaidOutRows; + var bandsBefore = app.InputBandColors; Corvid(config).Tint = PaneTint.Ochre; app.SaveConfiguration(); app.RenderNextFrame(); - // The colour really did arrive, without anything else being touched… + // The colour really did arrive, on the panes and on the command line, without anything else + // being touched… await Assert.That(app.PaneSurfaceColors.Values.Distinct()) .IsNotEquivalentTo(planesBefore.Values.Distinct()); + await Assert.That(app.InputBandColors).IsNotEqualTo(bandsBefore); - // …and it cost nothing: same sidebar, same rectangles, so the same NAWS size. + // …and it cost nothing: same sidebar, same rectangles, same bands of the window, so the same + // NAWS size. The input area is checked as well as the panes because a bar is a sticky band — + // one that grew a row would take that row off every pane on the screen. await Assert.That(app.RailColumnWidth).IsEqualTo(railBefore); + await Assert.That(app.LaidOutRows).IsEqualTo(rowsBefore); var after = app.PaneOutputRects(); await Assert.That(after.Count).IsEqualTo(before.Count); foreach (var (paneId, rect) in before) diff --git a/tests/SharpMUTerm.Tui.Tests/SnapshotViewEmptyConfigTests.cs b/tests/SharpMUTerm.Tui.Tests/SnapshotViewEmptyConfigTests.cs index 9b45ac43..7b189082 100644 --- a/tests/SharpMUTerm.Tui.Tests/SnapshotViewEmptyConfigTests.cs +++ b/tests/SharpMUTerm.Tui.Tests/SnapshotViewEmptyConfigTests.cs @@ -41,7 +41,7 @@ public class SnapshotViewEmptyConfigTests "focus", "focus-moved", "freeze", "freeze-scrollback", "move", "drag", "scrollback", "scrollback-up", "away", "away-scrollback", "web", "rail-long", "history", "history-search", "history-search-filter", "draft", "draft2", "menu", "menu-split", "messages", "quit", - "connections", "characters", "tint", "deletions", "textansi", "input", "keypad", "password", + "connections", "characters", "tint", "tint-input", "tint-input-moved", "deletions", "textansi", "input", "keypad", "password", "startup", "logging", "set", "triggers", "route", "highlight", "worlds", "settings", "mssp", "mssp-none", "mssp-never", ]; diff --git a/tests/SharpMUTerm.Tui.Tests/WorkspacePaletteTests.cs b/tests/SharpMUTerm.Tui.Tests/WorkspacePaletteTests.cs index 2bf986f4..7cc5482d 100644 --- a/tests/SharpMUTerm.Tui.Tests/WorkspacePaletteTests.cs +++ b/tests/SharpMUTerm.Tui.Tests/WorkspacePaletteTests.cs @@ -251,27 +251,61 @@ private static IEnumerable Colours() => Enum.GetValues().Where(t => t != PaneTint.None); /// - /// A tint changes the plane's hue and not its brightness. This is the legibility guarantee the - /// whole design rests on: a MU* server chooses the colours of the text drawn on this plane, so a tint - /// that lightened or darkened the pane would change every contrast ratio the theme was designed - /// against — on a palette the client neither controls nor can test. Held to a point of luma, which is - /// rounding to bytes and nothing else. + /// All six tints sit at exactly one luminance — and it is one step below the untinted surface, + /// not level with it. Two claims, and the first is the one everything else rests on: no + /// character's pane may be brighter than another's, or the colour would be answering the question + /// focus answers. It holds by construction (the plane is re-lit before the anchor is mixed in, and + /// luma is linear in the channels), so what is left is rounding to bytes and nothing else — two of + /// them, the re-light and the mix, which is worth about a point of luma at any brightness. + /// + /// The second claim is a deliberate change from the first cut, which pinned the tinted plane to the + /// surface's own luma. MU* servers are written for black terminals and their bright ANSI is + /// what is read on this plane, so it wants to be darker than the client's chrome. The step is the + /// same for every colour, which is what leaves the focus cue — a ratio — untouched. + /// /// [Test] [Arguments("Dark")] [Arguments("Light")] [Arguments("Solarized Dark")] - public async Task ATintedPaneIsExactlyAsBrightAsAnUntintedOne(string themeName) + public async Task EveryTintedPaneSitsAtOneLuminanceAStepBelowTheSurface(string themeName) { var theme = ThemeLibrary.Get(themeName); var surface = Luma(WorkspacePalette.Surface(theme)); + var planes = Colours().Select(t => Luma(WorkspacePalette.Tint(theme, t))).ToList(); - foreach (var tint in Colours()) - { - await Assert.That(Math.Abs(Luma(WorkspacePalette.Tint(theme, tint)) - surface)) - .IsLessThanOrEqualTo(1.0) - .Because($"{tint} on {themeName}"); - } + // One luminance, whatever the colour. + await Assert.That(planes.Max() - planes.Min()).IsLessThanOrEqualTo(1.5).Because(themeName); + + // And below the untinted plane. As a *fraction* of it, not by a fixed number of luma: on a dark + // theme the whole workspace lives in the low thirties, where a step of Visible would be a third + // of the surface's entire brightness, and the eye reads a dark plane's depth in ratio anyway. + await Assert.That(planes.Max()).IsLessThan(surface * 0.9).Because(themeName); + } + + /// + /// Every focused pane on the screen is brighter than every unfocused one, whatever colours are in + /// play. This is what bounds the darkening, and it is the reason the depth is derived rather than + /// chosen: a client may hold tinted and untinted characters at once, so the planes on screen are the + /// tinted one, the surface, and each of those lifted — and a darkening deep enough to drop a + /// focused tinted pane to an unfocused untinted one would have the focus cue + /// reporting the wrong fact, which is exactly what the two-channel design exists to prevent. + /// + [Test] + [Arguments("Dark")] + [Arguments("Light")] + [Arguments("Solarized Dark")] + public async Task EveryFocusedPaneOutshinesEveryUnfocusedOneAcrossTheWholePalette(string themeName) + { + var theme = ThemeLibrary.Get(themeName); + var planes = Colours().Select(t => WorkspacePalette.Tint(theme, t)) + .Append(WorkspacePalette.Surface(theme)) + .ToList(); + + var dimmestFocused = planes.Min(p => Luma(WorkspacePalette.Focus(p))); + var brightestUnfocused = planes.Max(Luma); + + await Assert.That(dimmestFocused).IsGreaterThan(brightestUnfocused).Because(themeName); } /// @@ -333,10 +367,14 @@ await Assert.That(Luma(WorkspacePalette.Focus(plane)) - Luma(plane)) } /// - /// And it is the same lift, not merely some lift: the focus step is a multiplication, so it - /// lands the same distance above a tinted plane as above the plain surface. Without this a tint could - /// quietly make one character's pane look more focused than another's, which would be the cue - /// reporting the wrong fact. + /// And it is the same lift, measured the way the lift is actually applied: focus is a + /// multiplication, so what has to be equal above every plane is the ratio, and it is equal + /// above all six tints and above the plain surface alike. Without this a colour could quietly make + /// one character's pane look more focused than another's, which would be the cue reporting the wrong + /// fact. (The absolute step is smaller above a tinted plane than above the untinted one, and + /// necessarily so — the plane is darker. That is the trade the depth makes, and the ordering it may + /// not break is pinned by + /// .) /// /// The light theme is left out on purpose and not by oversight: its surface is already so bright that /// the focus step clamps at white (this is true of the untinted plane too, and predates tints), so @@ -346,16 +384,134 @@ await Assert.That(Luma(WorkspacePalette.Focus(plane)) - Luma(plane)) [Test] [Arguments("Dark")] [Arguments("Solarized Dark")] - public async Task TheFocusStepIsTheSameAboveATintedPlaneAsAboveThePlainOne(string themeName) + public async Task TheFocusStepIsTheSameRatioAboveATintedPlaneAsAboveThePlainOne(string themeName) { var theme = ThemeLibrary.Get(themeName); - var plain = Luma(WorkspacePalette.Focus(theme)) - Luma(WorkspacePalette.Surface(theme)); + var plain = Luma(WorkspacePalette.Focus(theme)) / Luma(WorkspacePalette.Surface(theme)); foreach (var tint in Colours()) { var plane = WorkspacePalette.Tint(theme, tint); - var step = Luma(WorkspacePalette.Focus(plane)) - Luma(plane); - await Assert.That(Math.Abs(step - plain)).IsLessThan(2.0).Because($"{tint} on {themeName}"); + var step = Luma(WorkspacePalette.Focus(plane)) / Luma(plane); + await Assert.That(Math.Abs(step - plain)).IsLessThan(0.05).Because($"{tint} on {themeName}"); + } + } + + // --- the tints on the command line ------------------------------------------------------------- + + /// + /// A tinted command line keeps the whole armed-versus-idle step. The bar wears the character's + /// hue, and only the hue: luminance on this row is already the one cue that says which line ⏎ sends + /// from, so the tint leaves it alone. Held to the same bar the untinted pair is + /// held to — a colour must never make the two bars harder to tell apart than no colour at all. + /// + /// The light theme is measured against instead, and the reason is arithmetic + /// rather than tolerance: its idle band lifted by the focus step clamps against white before the lean + /// toward the prompt colour is applied, so how much of the step survives depends on which channels a + /// given hue has left to give. It predates tints — the untinted light band is already the narrowest + /// of the three — and every other cue on the row is untouched. + /// + /// + [Test] + [Arguments("Dark")] + [Arguments("Light")] + [Arguments("Solarized Dark")] + public async Task ATintedCommandLineIsStillObviouslyArmedOrIdle(string themeName) + { + var theme = ThemeLibrary.Get(themeName); + var floor = themeName == "Light" ? Visible : Obvious; + + foreach (var tint in Colours()) + { + var armed = Luma(WorkspacePalette.ArmedBand(theme, tint)); + var idle = Luma(WorkspacePalette.IdleBand(theme, tint)); + + await Assert.That(armed - idle).IsGreaterThan(floor).Because($"{tint} on {themeName}"); + } + } + + /// + /// The band moves in colour and not in brightness — the other half of the composition rule, and what + /// makes the claim above hold by construction rather than by luck. It also keeps + /// , which is measured against the untinted band and shared with + /// the tab chips, at the contrast it was picked with. + /// + [Test] + [Arguments("Dark")] + [Arguments("Light")] + [Arguments("Solarized Dark")] + public async Task ATintedBandKeepsTheUntintedBandsBrightness(string themeName) + { + var theme = ThemeLibrary.Get(themeName); + var plain = Luma(WorkspacePalette.IdleBand(theme)); + + foreach (var tint in Colours()) + { + var band = WorkspacePalette.IdleBand(theme, tint); + await Assert.That(Math.Abs(Luma(band) - plain)).IsLessThanOrEqualTo(1.0).Because($"{tint} on {themeName}"); + await Assert.That(band).IsNotEqualTo(WorkspacePalette.IdleBand(theme)).Because($"{tint} on {themeName}"); + } + } + + /// + /// And the armed band still leans toward the theme's prompt colour on top of the character's hue, so + /// a tinted pair differs in brightness and in hue exactly as the untinted pair does. This is + /// the reason the armed band is derived from the tinted idle one rather than tinted in its own right: + /// tinting it directly would have overwritten the lean with the character's colour. + /// + [Test] + [Arguments("Dark")] + [Arguments("Solarized Dark")] + public async Task ATintedArmedBandStillLeansTowardThePromptColour(string themeName) + { + var theme = ThemeLibrary.Get(themeName); + + foreach (var tint in Colours()) + { + var armed = WorkspacePalette.ArmedBand(theme, tint); + var idle = WorkspacePalette.IdleBand(theme, tint); + + await Assert.That(armed.B - idle.B).IsGreaterThan(armed.R - idle.R).Because($"{tint} on {themeName}"); + } + } + + /// + /// leaves both bands byte for byte where they were. The command line of a + /// client whose characters have chosen no colour — and of one whose focused window belongs to nobody + /// at all — is painted exactly as it was before any of this existed. + /// + [Test] + [Arguments("Dark")] + [Arguments("Light")] + [Arguments("Solarized Dark")] + public async Task NoTintLeavesBothBandsUnchanged(string themeName) + { + var theme = ThemeLibrary.Get(themeName); + + await Assert.That(WorkspacePalette.IdleBand(theme, PaneTint.None)).IsEqualTo(WorkspacePalette.IdleBand(theme)); + await Assert.That(WorkspacePalette.ArmedBand(theme, PaneTint.None)).IsEqualTo(WorkspacePalette.ArmedBand(theme)); + } + + /// + /// Two characters' command lines are told apart by colour, the same way their panes are. Measured + /// with brightness discounted, because these bands are all at one luminance by construction. + /// + [Test] + [Arguments("Dark")] + [Arguments("Solarized Dark")] + public async Task TwoCharactersCommandLinesDifferInHue(string themeName) + { + var theme = ThemeLibrary.Get(themeName); + var bands = Colours().Select(t => (Tint: t, Band: WorkspacePalette.IdleBand(theme, t))).ToList(); + + foreach (var a in bands) + { + foreach (var b in bands.Where(p => p.Tint != a.Tint)) + { + await Assert.That(ChromaDistance(a.Band, b.Band)) + .IsGreaterThan(6.0) + .Because($"{a.Tint} vs {b.Tint} on {themeName}"); + } } }