Repository navigation
Bold colour, and tell 256-palette colours from the sixteen (additive) - #182
Conversation
ThemeOptions.Bold is a colour spec parsed like the other theme colours. It is carried into ColorPalette and ColorSnapshot, so assigning a theme changes it live and raises ColorChanged(All). ColorSnapshot.ResolveForeground is the one place the bold rules live: the default foreground takes Bold when it is set; palette 0-7 brightens per DrawBoldTextInBrightColors; RGB and other indexes pass through unchanged. OSC 5;0 (colorBD) reports the theme bold colour until a program sets that slot. Setting Bold does not imply palette brightening (unlike Ghostty); the two settings stay independent, and the default DrawBoldTextInBrightColors=true already gives Ghostty's combination. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Drives default, palette-16, palette-256 and RGB through real SGR and asserts the stored colour and mode as well as the resolved colour, including RGB 0x000100, which is numerically the default marker 256. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tomlm
left a comment
There was a problem hiding this comment.
Overview
Adds ThemeOptions.Bold (Ghostty-style bold-color for default-foreground bold text), carries it through ColorPalette / ColorSnapshot, adds ColorSnapshot.ResolveForeground as the one place the bold rules live, and makes OSC 5;0 (colorBD) report the theme bold colour until a program sets that slot. The design is sound: plumbing the colour through every snapshot constructor, the ApplyTheme change check, and the OSC fallback chain are all correct.
Findings
Medium
ResolveForegroundbrightens bold38;5;0..7to 8-15, which the existingDrawBoldTextInBrightColorsdoc says must not happen. The new theory row1;38;5;1 -> 0xFF0000writes that behaviour into a test. This PR turns a per-host quirk into the library's documented single source of truth, so the two docs now contradict each other inside the library (inline on the test row and onColorPalette.cs:447).ResolveForeground(in AttributeData ...)calls non-readonlymethods on aninparameter of a mutable struct, so the C# compiler emits a defensive copy of the 12-byte struct for each of the four calls. That's on the per-cell path the PR documents for hosts (inline onColorPalette.cs:441).
Notes
- Low, already in the parser:
38;5;nis stored unclamped (InputHandler.csHandleExtendedColorpassesGetParam(index + 2)straight toSetFgColor(n, 0)).CSI 1;38;5;256 mor1;38;5;300 mstores mode 0 with colour >= 256, andResolveForegroundsends that to the default-foreground branch, so the text is drawn in the theme bold colour. Not bold, it getsForeground. xterm ignores an out-of-range index. The input is malformed, so this isn't blocking. Clamping or rejectingn > 255inHandleExtendedColorfixes it at the source. - Checked and correct:
- all four
ColorSnapshotconstruction sites carryBoldOrNone(SetColor,ResetAllColors,Set,NewSnapshot), so single-colour changes keep the bold colour (ThemeBold_SurvivesSingleColorChangescovers this). ApplyTheme'schangedcheck includesBoldOrNone, so a theme differing only inBoldraisesColorChanged(All).NoBold = -1can't collide with a parsed 0xRRGGBB.- Indexing is safe:
GetFgColor()masks to 25 bits, socolors[color]is only reached for 0..255, andcolor + 8stays <= 15. - The RGB
0x000100vs default-marker-256 collision is handled by checking mode first, and the theory test exercises it through real SGR. - The OSC 5;0 fallback chain
_specialColors[0] ?? Colors.Bold ?? Colors.Foregroundresets correctly via OSC 105. ThemeOptionscopy constructor carriesBold.- The mutation claim in the description holds: dropping
IsBold()from the default branch failsResolveForeground_NotBold_IsUnaffectedByEitherBoldSetting, and dropping it from the palette branch fails the same test viaAttr(1).
- all four
- Perf gate: nothing on the parse/print path changed.
ResolveForegroundis not called by the library, so the bench corpora are unaffected.
| // palette-256 (SGR 38;5;n): stored exactly like palette-16 -- index n, mode 0 -- so the | ||
| // two cannot be told apart; an index past 15 comes through as itself | ||
| [InlineData("1;38;5;100", 100, 0, 0x878700)] | ||
| [InlineData("1;38;5;1", 1, 0, 0xFF0000)] |
There was a problem hiding this comment.
Medium: this row writes into a test the behaviour the DrawBoldTextInBrightColors doc forbids.
TerminalOptions.cs:430-434 (unchanged by this PR) says:
It does not mean "lighten the colour": a 24-bit colour a program named exactly has no bright counterpart to select and must come through unchanged, and so must a 256-palette index the user chose.
This row asserts bold 38;5;1 resolves to 0xFF0000 (index 9). That's the brightening the doc rules out, and once the test is green the contradiction is locked in. The PR description calls it a known limitation, but before this PR the library contained no resolver: each host decided. Now ResolveForeground is documented as "the one place the bold rules live, so every host applies them alike", so every host that adopts it inherits the violation.
Fix, either way:
- Honour the doc. Give
38;5;nits own mode, the way xterm.js separatesCM_P16andCM_P256(mode 2 fits, sinceSetFgColoralready shifts mode into bit 25+). Then brighten only when mode is 0 and colour < 8. Every reader ofGetFgColorMode()has to be checked:InputHandler.KittyGraphics.cs:122tests== 0to reject palette colours and would need!= 1, andInputHandler.Csi.cs:133(DECRQSS SGR report) would need to emit38;5;nfor mode 2. After that, this row should expectsnapshot[1]. - Or change the contract. Amend the
DrawBoldTextInBrightColorsremarks to say a 256-palette index 0-7 is brightened too, because the cell can't tell it apart. Then keep this row, with a comment pointing at that doc instead of describing it as something the code merely cannot do.
There was a problem hiding this comment.
Fixed in 5e8bbca: we went with "honour the doc."
- This row now expects
snapshot[1](0xCD0000) for bold38;5;1. - New rows pin
38;5;9→ 9 and bold31→ 9 (ResolveForeground_Bold31Brightens_Bold38_5_1DoesNot). - Instead of mode 2,
38;5;nsets a flag bit that the int mode accessors mask off, so hosts readingGetFgColorMode()see no change.
| /// </remarks> | ||
| public int ResolveForeground(in AttributeData attributes, bool drawBoldTextInBrightColors) | ||
| { | ||
| if (attributes.GetFgColorMode() == 1) |
There was a problem hiding this comment.
Medium: four defensive struct copies per call on the per-cell path.
attributes is an in parameter of AttributeData, which is a mutable struct (not readonly struct). GetFgColorMode(), GetFgColor() (called twice) and IsBold() aren't marked readonly (AttributeData.cs:84,129,132). C# can't prove a non-readonly method leaves a readonly ref alone, so it copies the 12-byte struct into a temp before each call. The JIT can sometimes remove those copies after inlining, but nothing guarantees it. This is the "struct copies hiding behind ordinary syntax" case from the repo's CLAUDE.md, on the method whose own comment says the bold field is an int "so the per-cell resolve reads one field".
Fix (one of):
- read the public fields directly:
var fg = attributes.Fg; if ((fg >> 25) == 1) return fg & 0x1FFFFFF; ...and(attributes.Extended & 1) != 0for bold (field access through aninref doesn't copy), or - mark the pure getters on
AttributeDatareadonly(public readonly bool IsBold() => ...,public readonly int GetFgColor() => ...,GetFgColorMode). That removes the copies here and for every otherin/ref readonlycaller.
Either way, hoist attributes.GetFgColor() / Fg into a local before the mode check so it's read once.
There was a problem hiding this comment.
Fixed in 452fdc8. Every pure getter on AttributeData is now readonly: all Is* / Get*, TryGetUnderlineColor, Equals, GetHashCode and the new Get*ColorKind. That removes the copies here and for any other in caller. Both resolvers also read Fg / Bg into a local once.
Adding readonly is binary compatible. AttributeData_PureGetters_AreReadonly pins it, and a mutant that drops readonly from IsBold fails it.
| var color = attributes.GetFgColor(); | ||
| if (color < ColorPalette.Size) | ||
| { | ||
| if (color < 8 && drawBoldTextInBrightColors && attributes.IsBold()) |
There was a problem hiding this comment.
Medium (same issue as the test row at ColorPaletteTests.cs:652): this condition brightens 38;5;0..7 as well as 30-37.
HandleExtendedColor stores 38;5;n as SetFgColor(n, 0), the same as SGR 3n. So for CSI 1;38;5;2 m, color = 2 passes color < 8 and resolves to colors[10]. The <remarks> above say every colour other than palette 0-7 "comes through as the program named it". TerminalOptions.DrawBoldTextInBrightColors says a 256-palette index the user chose must come through unchanged. Both can't be true of this line. See the comment on the test row for the two ways to resolve it. If the contract changes, update these remarks so they say 38;5;0..7 is brightened too.
There was a problem hiding this comment.
Fixed in 5e8bbca. ResolveForeground now switches on the colour's kind, and only Palette16 0-7 brightens, so CSI 1;38;5;2 m resolves to colors[2]. The remarks say so explicitly, and so does the DrawBoldTextInBrightColors doc. A mutant that brightens Palette256 too fails two tests.
…odes SGR 31 and 38;5;1 were stored identically (index 1, mode 0), so bold brightening could not tell them apart and moved a 256-palette index to the bright half, against the DrawBoldTextInBrightColors doc. 38;5 and 48;5 now set a flag in bit 26, above the palette/RGB mode bit. SGR 30-37 and the defaults store exactly what they did, and the parse path only stores a different constant in the branch 38;5 already had. GetFgColorMode/GetBgColorMode mask the flag off, so they still return 0 for any palette colour and 1 for RGB, as does TryGetUnderlineColor. Additive for hosts: - ColorMode keeps Palette256 = 0 and RGB = 1 and adds Palette16 = 2 and Default = 3. - AttributeData.GetFgColorKind/GetBgColorKind return the precise kind. - ResolveForeground brightens only Palette16 indexes 0-7. - ColorSnapshot.ResolveBackground resolves a background to 0xRRGGBB, or null for the default. Two intended fixes change what a host can observe: DECRQSS reports a 256-palette colour as 38;5;n / 48;5;n even below 16, and attributes for 31 and 38;5;1 no longer compare equal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From the review of tomlm#182: - The pure getters on AttributeData are now readonly, so calling them through an `in` reference -- ResolveForeground and ResolveBackground take one -- no longer copies the struct before every call. The resolvers also read Fg / Bg once into a local. Adding readonly to a method is binary compatible. - SGR 38;5;n, 48;5;n and 58;5;n (and the colon forms) with n past 255 are ignored, as xterm ignores them, and still consume their parameter. Stored, the index read as the default colour, so bold text took the theme bold colour. The check sits inside the rare ;5; branch, off the SGR hot path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From the review of tomlm#182: - The pure getters on AttributeData are now readonly, so calling them through an `in` reference -- ResolveForeground and ResolveBackground take one -- no longer copies the struct before every call. The resolvers also read Fg / Bg once into a local. Adding readonly to a method is binary compatible. - SGR 38;5;n, 48;5;n and 58;5;n (and the colon forms) with n past 255 are ignored, as xterm ignores them, and still consume their parameter. Stored, the index read as the default colour, so bold text took the theme bold colour. The check sits inside the rare ;5; branch, off the SGR hot path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks. All addressed in 5e8bbca and 452fdc8, kept additive so it can ship as a minor version.
|
ea948c8 to
452fdc8
Compare
…ent from the underline Kitty's Unicode placeholders name the image in the foreground colour as either 24-bit RGB or an 8-bit 38;5 index, with the third diacritic adding the most significant byte. Only RGB was accepted, because 38;5;n used to be stored the same as SGR 3n. With the palettes told apart, a Palette256 foreground is now an id; Palette16 and the default stay colours. The placement id, carried in the underline colour (58;5;n or 58;2), was not read at all. It now goes on each placeholder run, starts a new rectangle when it changes, and lets a=d,d=i,p= reach placeholder tiles. The existing palette-rejection test stored image 3 under SGR 31 (index 1), so it could not fail; it now stores image 1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
One addition since your review, in de3a58e, because the 16/256 split makes it possible: kitty's Unicode placeholders now take an 8-bit image id from The earlier commits are unchanged in content. Their messages were reworded, so the hashes in the replies above now read 5e8bbca and 452fdc8. The description has a new section on it. |
A placeholder's colour carries the low 24 bits of the image id; the third diacritic supplies the top byte. The cell was resolved from the colour alone and printed as text when that named nothing, so an image stored only under an id above 2^24 never appeared unless its low bits happened to name another image. Clients that choose random 32-bit ids hit that nearly every time, and the spec's own example (38;5;42 with U+030E) did not work. Such a cell now prints as text exactly as before, but is held as pending: its row and column marks are recorded and still appended as text, and the third mark resolves the full id and rewrites the cell as a tile. A cell that never resolves -- too few marks, an unknown full id, or anything else arriving first -- is left exactly as an unknown id always was. The cell that resolves becomes the rectangle's origin at the tile its marks named, and a cell continuing a rectangle now matches on the low 24 bits, so the rest of a run inherits the top byte as kitty's left-neighbour rule says. Print no longer offers a placeholder that fell through as text to the mark handling as a mark for itself, which would have ended the wait at once. Ordinary characters pay the same two checks as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
One more addition, in d24cab7: a pre-existing gap the 8-bit work exposed. A placeholder whose image id needs the third (high-byte) diacritic only showed if its low 24 bits also named some other image, so the spec's own Such a cell now prints as text until its third mark arrives, then becomes a tile. The rest of the run inherits the high byte, and a cell that never resolves is left exactly as before. Ordinary characters pay the same two checks on the print path. Four new tests fail on the previous head. |
3.0.1 adds the APIs #195, #197 and #198 need: ThemeOptions.Bold and ColorSnapshot.ResolveForeground (tomlm/XTerm.NET#182), Terminal.KeyboardLocked and KeyboardActionModeAllowed (#181), and the stateful BufferScan (#183). It also carries the resize fix (#180) that #192 pairs with. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…or) (#195) * Draw bold default-foreground text in the theme's bold colour (BoldColor) Adds BoldColor (Color?, default null) to TerminalView, TerminalControl and TerminalWindow, carried into the emulator as ThemeOptions.Bold (Ghostty's bold-color). Bold text using the DEFAULT foreground draws in it when set; bold palette 0-7 still follows DrawBoldTextInBrightColors, RGB is unchanged. The foreground now resolves through ColorSnapshot.ResolveForeground, so the control no longer reads XTerm.NET's colour-mode encoding for it. The remaining reads (default-fg/bg checks, background and underline) are isolated and documented, with a TODO for ColorSnapshot.ResolveBackground. A live change re-applies the theme with the current brushes re-seeded and purges the line caches. Requires tomlm/XTerm.NET#182. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Tell a 24-bit #100 / #101 apart from the default colours The default-colour checks read the value alone (256 for the foreground, 257 for the background), but the value and mode share one packed int, so 38;2;0;1;0 and 48;2;0;1;1 store the same numbers in RGB mode. The first was drawn in the theme's foreground, the second left unpainted -- and the foreground's answer changed with BoldColor, since ResolveForeground does read the mode. Both checks now require palette mode 0 as well, which stays the default's encoding under XTerm.NET's non-breaking colour-mode plan. Also corrects GetForegroundColor's summary: null means drawn in the default foreground, which a bold default cell is not when a bold colour is set. Review: #195 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Tom Laird-McConnell <thermous@iciclecreek.com>
Adds a theme colour for bold text whose foreground is the default, like Ghostty's
bold-color, and one place where the bold rules are resolved. Following review, it also tells the 256-colour palette apart from the sixteen, so bold38;5;0..7is no longer brightened. All of it is additive: no existing public member changes signature, value or meaning.Bold colour
ThemeOptions.Bold(string?): any formColorSpecparses. Unset, or a value that doesn't parse, keeps today's behaviour.ColorSnapshot.BoldandColorPalette.Bold(int?, 0xRRGGBB): the parsed colour, or null when unset.colorBD) reports the theme's bold colour until a program sets that slot.ColorPalette.ApplyTheme.Setting
Bolddeliberately does not switch palette brightening on, so each option means one thing.DrawBoldTextInBrightColorsalready defaults to true, so the default pairing matches Ghostty's.Resolving a cell's colours
int ColorSnapshot.ResolveForeground(in AttributeData attributes, bool drawBoldTextInBrightColors)Boldwhen the theme sets one;drawBoldTextInBrightColorsis set;38;5;n), even one below 8, and RGB come through as the program named them.int? ColorSnapshot.ResolveBackground(in AttributeData attributes)returns null for the default background, otherwise 0xRRGGBB.Inverse, dim and invisible are left to the host. Before this PR, nothing in the library read
DrawBoldTextInBrightColors; each host applied it on its own.Telling the two palettes apart, without breaking hosts
AttributeDataused to store38;5;nexactly like3n, so nothing could honour theDrawBoldTextInBrightColorsdoc ("a 256-palette index the user chose must come through unchanged").38;5;n,48;5;nand58;5;nnow set a flag in bit 26.GetFgColorMode(),GetBgColorMode()andTryGetUnderlineColorreturn exactly what they did: 0 for any palette colour or the default, 1 for RGB. A host that branches onmode == 0 ? palette : RGBkeeps working. Storing38;5;nas a new mode 2 would have silently broken such hosts.AttributeData.GetFgColorKind()andGetBgColorKind()returnColorMode.ColorModekeepsPalette256 = 0andRGB = 1, and gainsPalette16 = 2andDefault = 3.Other review fixes
AttributeDatais nowreadonly, soin AttributeDatacallers make no defensive copies. Both resolvers readFg/Bgonce. Addingreadonlyis binary compatible.38;5,48;5or58;5(and the colon forms) is ignored, as xterm does, and still consumed so later parameters keep their meaning. Before, it was stored and read as the default colour. The check sits inside the rare;5;branch.Kitty placeholders: 8-bit image ids and placement ids
With the two palettes told apart, kitty's Unicode placeholders get the rest of their id encodings:
38;5;nforeground (an 8-bit id) as well as from38;2(24-bit). The third diacritic adds the high byte on top of either.38;5;42with U+030E gives42 + (2 << 24), the spec's own example. SGR 30-37 and the default foreground stay colours, so red text doesn't summon image 1.58;5;nor58;2), is stored on each placeholder run, starts a new rectangle when it changes, and letsa=d,d=i,i=N,p=Mreach placeholder tiles.38;5;42+ U+030E example, and the usual case for clients that choose random 32-bit ids. Such a cell prints as text until its third mark arrives, then becomes a tile, and the rest of the run inherits the high byte from it (kitty's left-neighbour rule). A cell that never resolves is left exactly as an unknown id was before. Previously the image only appeared if its low 24 bits also happened to name another image.A_palette_colour_is_not_read_as_an_idstored image 3 but wrote SGR 31 (index 1), so it passed either way. It now stores image 1 and is renamedA_sixteen_colour_sgr_is_not_read_as_an_id.Deliberate, observable changes (bug fixes)
38;5;nfaithfully, even below 16, so the reply replays to the same attributes. Before, it reported38;5;1as31.AttributeDatafor31and for38;5;1no longer compare equal, as in xterm.js.Tests and measurements
XTerm.NET.Testshas 2459 passed, 1 skipped, 0 failed. NewSgrColorModeTestscover every SGR colour form on both sides and pin the old int mode values, and a test pins the getters asreadonly.KittyPlaceholderTestshas 29 cases, including 8-bit ids with and without the high byte, and placement ids from both underline forms.Palette256again, acceptingPalette16, accepting the default, dropping the placement id, and continuing a rectangle across placement ids.38;5;nstored without the flag;Get*ColorKindignores the flag;38;5;1as31;IsBoldnotreadonly.🤖 Generated with Claude Code