Repository navigation
Phase 0: adaptive colors and terminal color environment detection - #18
Merged
Merged
Conversation
Wires up light/dark-adaptive styling (J1) with integer-color coercion (U1)
as its opening step, so adaptive colors aren't built on a constructor that
silently drops ints.
U1 — colors given as integers, keywords or hex strings were silently
dropped, because apply-color-fg/bg dispatched on (:type color), which is
nil for anything but a color map. This made a documented feature, the
library's own default styling (help, list, text-input) and most sample
code render as plain text. coerce-color now accepts integers as ANSI 256,
keywords as ANSI 16 names and strings as hex, and throws an ex-info naming
the offending value otherwise. Coercion lives in apply-color-fg/bg rather
than in each constructor, so style, with-fg, with-bg and styled-str all
pick it up at once.
J1 — terminal/dark-background? queries JLine's getDefaultBackgroundColor
(OSC 11) and computes luminance, defaulting to dark when the terminal
doesn't answer. Adaptive colors are {:type :adaptive :light … :dark …},
built with style/adaptive and resolved at render time. run detects the
profile and background once at startup, binds both dynamic vars around the
event loop, and sends an :environment message so apps can branch
themselves. The previously dormant detect-color-profile and
downgrade-color are now live in the render path.
Fixing downgrade-color was a prerequisite for that last part: its :ansi
branch did (mod code 16) on a 256-cube index, which is colorimetrically
meaningless — orange (rgb 255 128 0) came out cyan. Harmless while nothing
called it, but wiring it in would have turned every RGB color on a
TERM=xterm terminal into an effectively random one of 16. Replaced with a
nearest-neighbour match against the existing ansi-hex table, plus a new
ansi256->rgb covering the cube and grayscale ramp.
The background probe is bounded by JLine's org.jline.terminal.probe.timeout
(200ms default), so a terminal that ignores the query adds that much to
startup once.
Claude-Session: https://claude.ai/code/session_01CQeoheU7AxEmE5ZdATzg9q
Covers the API added alongside J1 and U1: style/adaptive and how it resolves against the detected background, the shorthand color forms (integer, keyword, hex string) that U1 made work, a table of the four color profiles and what each downgrades to, and the :environment message for apps that want to branch on the terminal themselves. Claude-Session: https://claude.ai/code/session_01CQeoheU7AxEmE5ZdATzg9q
Marks J1 and U1 done and records two premises the original review got wrong. First, charm never emitted raw true-color escapes for ANSI-only terminals to be spared from — AttributedString.toAnsi() without a terminal argument already collapses 24-bit color to the 256 palette, so making true color actually reach the terminal is separate Phase 4 work that will touch existing test assertions. Second, downgrade-color was not merely dormant but wrong, and wiring it in would have regressed TERM=xterm terminals had the nearest-color mapping not been fixed alongside. Also notes that the new dynamic vars are thread-local, which constrains the Phase 3/4 loop rewrite. Claude-Session: https://claude.ai/code/session_01CQeoheU7AxEmE5ZdATzg9q
:color-profile and :dark-background? override detection; nil, the default, keeps detecting. Useful for a terminal that misreports itself and for tests that need a fixed environment, and pinning :dark-background? also skips the OSC 11 query, so a terminal that never answers stops costing the probe timeout at every startup. An unknown profile throws an ex-info naming the value rather than falling through downgrade-color's case default and silently rendering undowngraded — the U1 lesson. The check lives in its own private fn so it is testable without a terminal. Also records in PLAN.md why the environment is ambient rather than part of the state: resolve-color needs it four frames below anything holding the state, because style/render returns a finished String with escapes already in it. The exit is styles-as-data resolved by the renderer, which is the same change real 38;2;r;g;b output needs, so the two should land together in Phase 4. Two things left alone until then, both invisible today: init runs before the binding opens (no component's init styles anything), and the vars are thread-local (core.async conveys the frame, so commands are fine).
org.jline.utils.Colors already did every conversion color.clj had hand-rolled, and did it better — matching in CIE Lab over the full 256-entry table via Colors$Distance, where ours measured Euclidean distance in RGB space over a partial one. rgb->ansi256 was the one that mattered: strictly worse than JLine in 73 of 125 probes, because it searched only the 16-231 cube and the 232-255 ramp — so an exact match among the first 16 entries was unreachable, and olive (128,128,0), which *is* entry 3, came back as 142 — and because it quantised as (v*5/255) as if the cube levels were evenly spaced when they are 0/95/135/175/215/255, rounding 128 up to 175 instead of down to 95. a80d88c made that reachable, and in doing so took the job away from an implementation that was already correct: before the wiring, apply-color-fg handed raw RGB to .foreground and JLine's toAnsi chose 208 for orange; after it, our matcher chose 214 and JLine only printed the index we had picked. downgrade-color now calls Colors/roundColor and Colors/roundRgbColor, and rgb->ansi256, rgb->ansi16, ansi256->rgb, ansi-hex and cube-levels are deleted — 22 insertions against 103 deletions. The :ansi profile keeps what the wiring genuinely gained: basic SGR (ESC[91m) rather than a 256-color code, which no-terminal toAnsi will not emit. coerce-color and adaptive stay ours; JLine has no equivalent for either.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements Phase 0 of
PLAN.md— background color detection (J1), withinteger-color coercion (U1) as its opening step so adaptive colors aren't built
on a constructor that silently drops ints.
Includes the
plancommit that addsPLAN.md, which wasn't yet onmain.U1 — colors were silently dropped
apply-color-fg/apply-color-bgdispatched on(:type color), which isnilfor anything that isn't already a color map. So
(style/render (style/style :fg 240) "hi")returned
"hi"with no escapes at all — a documented feature, the library's owndefault styling in
help,listandtext-input, and most of the sample codeacross docs and examples all rendered as plain text.
coerce-colornow accepts integers as ANSI 256, keywords as ANSI 16 names andstrings as hex, and throws an
ex-infonaming the offending value otherwise.Coercion lives in
apply-color-fg/apply-color-bgrather than in eachconstructor, so
style,with-fg,with-bgandstyled-strall pick it up atonce instead of each entry point needing its own normalization.
J1 — adaptive colors and background detection
charm.terminal/dark-background?queries JLine'sgetDefaultBackgroundColor(OSC 11) and computes luminance, defaulting to dark when the terminal doesn't
answer.
{:type :adaptive :light … :dark …}, built withstyle/adaptiveand resolved at render time.rundetects the profile and background once at startup, binds*color-profile*/*dark-background?*around the event loop, and sends an:environmentmessage so apps can branch themselves.detect-color-profileanddowngrade-colorare nowlive in the render path.
The background probe is bounded by JLine's
org.jline.terminal.probe.timeout(200ms default), so a terminal that ignores the query adds that much to startup
once.
A bug found while wiring that in
downgrade-colorwas not merely dormant, it was wrong. Its:ansibranch did(mod code 16)on a 256-cube index, which is colorimetrically meaningless —orange
(rgb 255 128 0)came out cyan. Harmless while nothing called thefunction, but wiring it in would have turned every RGB color on a
TERM=xtermterminal into an effectively random one of 16, which is worse than the previous
pass-everything-through behavior.
Replaced with a nearest-neighbour match against the existing
ansi-hextable,plus a new
ansi256->rgbcovering the cube and the grayscale ramp. Orange nowlands on bright yellow, with a regression test pinning it.
A premise in the plan that turned out to be wrong
J1 was written on the assumption that ANSI-only terminals currently receive raw
true-color escapes. They don't, and neither does anything else:
AttributedString.toAnsi()called without a terminal argument already collapses24-bit color to the 256 palette, so charm has never emitted a
38;2;r;g;bsequence.
(rgb 255 0 0)renders asESC[38;5;196meven under the:true-colorprofile, and the existing tests intest/charm/style/color_test.cljassert exactly that.
Making true color actually reach the terminal needs the
toAnsioverload thattakes a terminal, and changes those assertions — so it's left for the Phase 4
render work rather than smuggled in here. Recorded in
PLAN.md.Testing
162 tests / 994 assertions, up from 152 / 940.
cljfmtclean;clj-kondoat 0errors with only the 10 pre-existing warnings.
Verified end to end that
:fg 240and the other shorthands emit escapes, thatadaptive colors flip with the background, and that each profile downgrades as
intended (
:ansiorange → bright yellow,:ascii→ no escapes).Note for later
*color-profile*and*dark-background?*are thread-local. The currentsingle-threaded render path is fine, but a
viewrendering parts on otherthreads wouldn't see them — a constraint on the Phase 3/4 loop rewrite, noted in
the plan.
https://claude.ai/code/session_01CQeoheU7AxEmE5ZdATzg9q