Skip to content

Phase 0: adaptive colors and terminal color environment detection - #18

Open
TimoKramer wants to merge 4 commits into
mainfrom
phase-0-adaptive-colors
Open

Phase 0: adaptive colors and terminal color environment detection#18
TimoKramer wants to merge 4 commits into
mainfrom
phase-0-adaptive-colors

Conversation

@TimoKramer

Copy link
Copy Markdown
Owner

Implements Phase 0 of PLAN.md — background color detection (J1), with
integer-color coercion (U1) as its opening step so adaptive colors aren't built
on a constructor that silently drops ints.

Includes the plan commit that adds PLAN.md, which wasn't yet on main.

U1 — colors were silently dropped

apply-color-fg / apply-color-bg dispatched on (:type color), which is nil
for 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 own
default styling in help, list and text-input, and most of the sample code
across docs and examples all rendered 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 / apply-color-bg rather than in each
constructor, so style, with-fg, with-bg and styled-str all pick it up at
once instead of each entry point needing its own normalization.

J1 — adaptive colors and background detection

  • charm.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
    *color-profile* / *dark-background?* 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.

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-color was not merely dormant, it was wrong. 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 the
function, but wiring it in would have turned every RGB color on a TERM=xterm
terminal 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-hex table,
plus a new ansi256->rgb covering the cube and the grayscale ramp. Orange now
lands 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 collapses
24-bit color to the 256 palette, so charm has never emitted a 38;2;r;g;b
sequence. (rgb 255 0 0) renders as ESC[38;5;196m even under the
:true-color profile, and the existing tests in test/charm/style/color_test.clj
assert exactly that.

Making true color actually reach the terminal needs the toAnsi overload that
takes 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. cljfmt clean; clj-kondo at 0
errors with only the 10 pre-existing warnings.

Verified end to end that :fg 240 and the other shorthands emit escapes, that
adaptive colors flip with the background, and that each profile downgrades as
intended (:ansi orange → bright yellow, :ascii → no escapes).

Note for later

*color-profile* and *dark-background?* are thread-local. The current
single-threaded render path is fine, but a view rendering parts on other
threads wouldn't see them — a constraint on the Phase 3/4 loop rewrite, noted in
the plan.

https://claude.ai/code/session_01CQeoheU7AxEmE5ZdATzg9q

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant