From 31cbdd5a7fc9e7ccf9cc2e525468c605ac7e12d5 Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Mon, 10 Aug 2026 23:59:35 -0500 Subject: [PATCH 1/2] feat(tui): darker pane tints, spread further apart, and a band that can wear one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MU* servers are written for black terminals and their own bright ANSI is read on the pane's plane, so the plane wants to be darker than the client's chrome rather than level with it. The first cut held a tinted pane at the untinted surface's exact luma; it now sits one TintDepth step below it, and the six anchors are re-picked saturated, because chroma is bounded by luminance and a muted anchor re-lit down to a dark theme's pane has nothing left. Measured on the default theme (BT.601 luma, and dE on CIE76): untinted surface #222226 L 34.46 focused #36363d L 54.80 Slate #0e1c3b L 27.35 Teal #072423 L 27.21 Moss #11230b L 26.88 Ochre #231b08 L 27.23 Ember #34110e L 27.12 Plum #2a1032 L 27.65 focused, in the same order: #162d5e L 43.71 #0b3938 L 43.13 #1b3812 L 43.00 #382b0d L 43.47 #531b16 L 43.17 #431a50 L 44.41 closest pair dE 8.20 -> 14.43 closest to the surface dE 7.68 -> 12.05 pane luminance 34.4 -> 27.2, a fifth darker, one value for all six What is kept of luminance neutrality is the half that carries the design: all six sit at exactly one luma (the plane is re-lit before the anchor is mixed in, and luma is linear in the channels), so no character's pane is brighter than another's and the focus step lands the same ratio above each. What is given up is equality with the untinted surface, which is stated in the doc comment rather than dropped quietly. The depth is bounded and the bound is arithmetic, not taste: 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 between a tinted pane and a focused tinted one. Every focused pane on the screen (43.00 at worst) outshines every unfocused one (34.46 at best), whatever colours are in play. IdleBand/ArmedBand gain a PaneTint overload, unused until the command line is wired to it. It takes the hue and not the depth: on the input row luminance is already the armed-versus-idle cue, and a colour that moved it would put a second fact on a channel that carries one. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015nuKnWthnELNkrd86q5KWN --- src/SharpMUTerm.Tui/WorkspacePalette.cs | 167 +++++++++++---- .../WorkspacePaletteTests.cs | 196 ++++++++++++++++-- 2 files changed, 298 insertions(+), 65 deletions(-) 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/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}"); + } } } From 21e4261a8c052a193a8cab421e92394ecf4f39ac Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Mon, 10 Aug 2026 23:59:54 -0500 Subject: [PATCH 2/2] =?UTF-8?q?feat(tui):=20the=20command=20line=20wears?= =?UTF-8?q?=20the=20colour=20of=20the=20character=20=E2=8F=8E=20reaches?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A glance at the bar now says who Enter will reach. The bands follow the focused window's character through SendTarget — the resolver the send path itself uses — falling back to the owner the workspace records when no session is open for that window, which is the second arm WindowSession already walks and the same record PaneTintOf reads. The bar and the pane above it are therefore one answer rather than two that agree most of the time. 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. 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, so the armed bar stays exactly the step above the idle one that it always was. Measured on the default theme at 120x32, off the rendered frames: untinted armed #435676 L 83.97 idle #252533 L 38.60 step 45.37 Moss armed #336552 L 83.88 idle #183213 L 38.69 step 45.19 Slate armed #2e589f L 83.54 idle #0f1e44 L 38.6* step ~45 (* the moved frame shows one bar; the pair is in tint-input.) The armed band is derived from the tinted idle one 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 in colour, on top of the bold-versus-dim prompt and the bright-versus-dim ink. The prompt cells read the pair the bar was given rather than re-deriving it, so the fill and the label on it cannot disagree. 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 leaves both bands exactly as they were, as does a character who has chosen no colour — an untinted client's command line is byte-identical to the one it had before this existed. Costs no cells: TintingACharacterMovesNoPaneRectangle now pins LaidOutRows as well as the rectangles, because a command line is a sticky band and one that grew a row would take that row off every pane on the screen and re-announce a new terminal size to every connected server. New views tint-input / tint-input-moved: the tint scene with both command lines up, before and after a real ⌃→ — the only geometry that shows an armed tinted band over an idle one, and the colour travelling with the focus. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015nuKnWthnELNkrd86q5KWN --- CLAUDE.md | 86 +++++++--- docs/design/README.md | 9 +- src/SharpMUTerm.Tui/SharpMUTermApp.cs | 112 +++++++++++-- tests/SharpMUTerm.Tui.Tests/PaneTintTests.cs | 151 +++++++++++++++++- .../SnapshotViewEmptyConfigTests.cs | 2 +- 5 files changed, 319 insertions(+), 41 deletions(-) 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/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", ];