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}");
+ }
}
}