Skip to content

some fixes and improvements - #21

Merged
TimoKramer merged 49 commits into
mainfrom
0.3
Sep 25, 2026
Merged

TimoKramer merged 49 commits into
mainfrom
0.3

Conversation

@TimoKramer

Copy link
Copy Markdown
Owner
  • Adaptive light/dark colors. adaptive takes a light and a dark variant and
    resolves against the terminal background at render time. run detects the
    background (OSC 11) and the color profile at startup, and reports both to the
    application as an :environment message.
  • Colors written as plain values now apply. :fg 240, :fg :red and
    :fg "#ff8000" were silently dropped before — which also disabled charm's own
    default styling in help, list and text-input, and most sample code.
  • Pin the environment when detection is wrong. :color-profile and
    :dark-background? options on run skip detection, and with it the probe
    timeout a silent terminal costs at every startup.
  • A faster event loop. It used to poll every 10 ms and handle one message per
    iteration, capping throughput near 100 messages a second — a 300-character
    paste took three seconds; it now takes 3 ms. :fps is finally read and acts as
    the redraw ceiling, and command bodies run on virtual threads, so blocking work
    no longer starves the dispatch pool.
  • A faster render path. Each line is parsed once per frame instead of up to
    three times (165 µs → 83 µs over 40 styled lines), viewports no longer copy all
    their content each frame, and CI fails the build on reflection warnings.
  • Untrusted content no longer reaches the terminal as instructions. The view
    is sanitized before it is written, set-window-title escapes its argument,
    signal handlers are restored on exit, and a shutdown hook restores the terminal
    when a program exits from the inside. New public charm.ansi.sanitize.
  • New options and functions: merge-style, :overflow, :bracketed-paste,
    :ctrl-c and :sanitize on run.
  • Fixes worth naming: "esc", "pgup" and "pgdown" never matched anything;
    key-match? ignored modifiers when the pattern named none, so "c" also fired
    on Ctrl+C; truncating a styled line stripped its styling; styling rewrote
    ┌──┐ as +--+; component ids could collide; :strikethrough never reached
    the terminal.
  • JLine 4.3.1 → 4.4.5, including two ColorPalette defects we found and got
    fixed upstream (jline3 #2260, #2261).
  • Potentially breaking: invalid color values now throw instead of rendering
    unstyled ((style :fg "red") is an error), and the internal
    charm.input.keys/key-matches? is gone — use msg/key-match?.
  • Requires JDK 22+. Under babashka, JLine 4.4.5 (currently on master).

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.
4.4.5 carries the two ColorPalette fixes that came out of this branch's
adaptive-color work, both filed upstream from here and fixed by @gnodet:

- #2256 / PR #2260 — the palette was built in AbstractTerminal's constructor,
  before parseInfoCmp() had populated the capabilities, so max_colors read back
  null and every terminal got the full 256-entry table. Fixed with a new
  ColorPalette.reloadFromCapabilities(), called from parseInfoCmp() and setEnv().
- #2257 / PR #2261 — loadPalette() accepted a one-entry all-black palette from a
  terminal that never answered OSC 4. Fixed by returning an empty array when
  nothing was read, plus catching ClosedException.

This is not only future-facing. charm.render.core renders through JLine's
Display, which holds terminal.getPalette() and emits via toAnsiBytes(...,
ColorPalette, ...), so the write-time rounding is live on the current code: on a
TERM=xterm terminal charm's \e[91m now reaches the terminal as \e[31m, and its
256-color output as \e[33m, instead of being passed through unchanged to a
terminal whose terminfo never claimed to support it. 256-color terminals, which
is most of them, are unaffected.

ADR 008's step 5 said the 16-color path had to stay and that loadPalette() must
not be called. Both were consequences of the bugs and are now wrong, so the note
is rewritten: step 5 can delete downgrade-color outright, at the cost of making
JLine >= 4.4.5 a hard floor.

160 tests / 990 assertions pass on 4.4.5.

Claude-Session: https://claude.ai/code/session_01L2xgwaNEV7odwbBZxVws7r
U1 made unrecognised color values throw instead of being silently dropped, but
hex strings never went through that check: coerce-color handed any string to
hex, which sliced it and let Integer/parseInt fail on its own. So the deliberate
path and the accidental one disagreed:

  :mauve  ->  ExceptionInfo : Unrecognised color value: :mauve
  "red"   ->  NumberFormatException : For input string: "re" under radix 16

The second names neither the value the user wrote nor what was expected, and it
is what someone upgrading hits if they had (style :fg "red") — silently unstyled
before U1, a confusing stack trace after it.

hex now validates six hex digits up front and throws the same ex-info shape,
carrying :color in ex-data as coerce-color does, plus a hint about the expected
format. Documented in doc/api/styling.md, since hex is public API.

160 tests / 1001 assertions.

Claude-Session: https://claude.ai/code/session_01L2xgwaNEV7odwbBZxVws7r
The README described a library two of whose facts were no longer true: it
listed JLine 3 as the terminal dependency, where the project has been on JLine 4
for a while and now pins 4.4.5, and its Styling feature line and colour examples
predated adaptive colours, which are the headline of this release. The two new
run options were documented in doc/api/program.md but invisible from the front
page.

Adds CHANGELOG.md, starting at 0.3 — earlier releases are only in the git tags,
and reconstructing them after the fact would be guesswork. It records the
breaking edge explicitly: passing an invalid colour value now throws instead of
rendering unstyled, which includes malformed hex strings such as
(style/style :fg "red").

Claude-Session: https://claude.ai/code/session_01L2xgwaNEV7odwbBZxVws7r
build.clj still carried the 0.2 prefix while the branch carries the 0.3 work,
so a deploy from here would have published 0.2.x over the top of the 0.2 line.
The suffix stays the commit count, so the next release is 0.3.<count>.

Claude-Session: https://claude.ai/code/session_01L2xgwaNEV7odwbBZxVws7r
Key events carry :escape, :page-up and :page-down, so key-match? compared
patterns against "escape", "page-up" and "page-down" and the short spellings
never matched anything. Page Up and Page Down were dead in list, table,
viewport and paginator, and every "esc" binding silently did nothing -
including the ones in this repository's own examples and guides.

Accept the short spellings as aliases, since "esc" is used throughout the
sample code, and rename the components' own page bindings to the canonical
names.

The new keybindings test generates a key-press for every key a real event can
carry, across all eight modifier combinations, and asserts each default binding
matches at least one of them - so a binding that names no real key fails
instead of quietly doing nothing.

While here, key-match? loses a duplicated comparison: its keyword and string
branches were the same test written twice, and the modifier branch's
(= key-part (str msg-key)) fallback was unreachable, :key being always either
a string or a keyword.
:strikethrough was in the docs table and in the todos example, but
style->attributed-style never applied it. Add it, and give the style
constructor the matching default. A test now walks every documented text
attribute and asserts its SGR code reaches the output, so the table and the
cond-> cannot drift apart again.

(style :padding 3) threw UnsupportedOperationException from
expand-box-values: with-padding normalized a bare number, the style
constructor did not. Move that normalization to layout/normalize-box and use
it from all three entry points, so the stored value is a vector whichever one
built it.
A message the application ignored still re-ran view and Display.update. Render
only when update returned a state that is not identical? to the old one.

The event loop had update-then-render written twice, once for window-size and
once for everything else; both now go through handle-msg!. A resize passes
:force-render? true, since the frame changed even when the state did not.
Both the viewport and the renderer called (vec lines) on what str/split-lines
had already returned as a vector before taking a subvec, copying the whole
content every frame - a viewport over a large log was O(total lines) per frame
instead of O(visible).

Type hints for the five reflective call sites: .append takes ^CharSequence
(three times per overlay line), KeyMap.bind resolves to its
(Object, CharSequence) overload and the ArrayList constructor to (Collection).
Padding is built by w/repeat-char with String.repeat rather than a seq of
characters, per padded line per frame.

Three recur args were auto-boxing a primitive loop local; coerce them with
long.
Clojure honours *warn-on-reflection* only while compiling, and neither
clojure -M nor the test runner binds it, so the check cannot live in the test
alias. dev/reflection.clj loads every namespace under src/ with the flag on and
exits non-zero if anything warned; wired up as clojure -M:reflection, a bb task
and a workflow step. Capturing *err* wholesale means auto-boxing warnings fail
the build too.

Also: git config set user.email was given the literal string
GIT_COMMITTER_EMAIL instead of the secret's value.
"esc" turned out to be dead in the same way as "pgup": it is used in ten of the
examples and guides here, so escape handling did nothing throughout the sample
code. The reflection check, added for the type hints, also caught three
auto-boxing recur args.

Deliberately left alone, and worth writing down: key-match? ignores modifiers
unless the pattern names one, so a plain "c" binding also fires on Ctrl+C.
Fixing that changes matching semantics for every existing binding rather than
adding a missing name.
A TUI renders data its user did not author - filenames, log lines, HTTP
responses - and escape sequences in that data are instructions to the terminal,
not characters.

sanitize keeps the one family that only paints characters, SGR, and drops the
rest along with every control character but newline and tab. strip drops the
styling too, leaving the text that will be displayed. strip-controls drops
control characters outright, for strings that go inside a sequence charm writes
itself, such as a window title.

One scan, no regexes, and the argument is returned unchanged when there is
nothing to remove, so plain and already-styled text cost no allocation.
A BEL or an ESC in a title ended charm's OSC early and let the rest of the
argument open one of its own - verified as a clipboard write reachable from any
application that puts untrusted text in its title. Control characters are now
removed from the title.

set-window-title does not go through AttributedString/fromAnsi, so unlike the
render path it was still exposed to this on JLine 4.4.5.

copy-to-clipboard throws an ex-info naming the size when the encoded payload
exceeds max-clipboard-bytes, rather than leaving the user with a clipboard the
terminal truncated without saying so. 74994 is tmux's limit, the smallest of the
common ones.
run captured neither the WINCH nor the INT handler it displaced, so both stayed
installed after the program returned, feeding a closed channel and a closed
terminal. In a REPL, which outlives the program, that swallowed Ctrl+C for the
rest of the session, and a window resize called get-size on a terminal that was
already gone.

charm.terminal/handle-signal and restore-signal! wrap Signals/register and
Signals/unregister, which already return and take the previous handler. JLine 4's
Terminal.handle is the supported route and would do the same, but it needs the
nested Terminal$Signal and Terminal$SignalHandler classes, which babashka cannot
resolve - the library would not load under bb test:bb.

Also register a shutdown hook, removed again in the finally so repeated runs do
not accumulate: finally does not cover System/exit or SIGTERM, and
examples/timer.clj exits from inside a running program, which left raw mode on,
the cursor hidden and the alternate screen active.

Both paths share restore-terminal!, which is also shorter than the cleanup it
replaces - render/stop! already disables the mouse and focus reporting, shows the
cursor and leaves the alternate screen, so the finally had been emitting every
one of those sequences twice.
render! now strips everything but SGR styling from the view, and run exposes
:sanitize for the rare view that writes its own control sequences. strip-ansi -
the function anyone displaying untrusted data reaches for - is sanitize/strip
rather than a fromAnsi round trip that left a private CSI visible as text.

string-width and truncate sanitize before measuring. Sanitizing only in render!
would be too late: every truncate, pad, border, join and overlay decision is made
above it, so a sequence JLine mangles rather than removes threw all of them off.

Note for the threat model this was written against: JLine's fromAnsi drops OSC
and DCS as of 4.4.5, which this project bumped to for the palette fixes, so the
OSC 52 clipboard write was already unreachable through render!. Verified against
both versions. What 4.4.5 still passes through, and this stops, is ESC c
(a full terminal reset), carriage return (content overwrites the line it just
drew, and measures 12 for 6 cells), private CSI (arrives as the visible text
1049h, width 5) and the C1 controls.
All six actions were pinned by tag, including third-party
DeLaGuardo/setup-clojure, and the babashka installer was piped into bash straight
from master. Actions are now pinned by commit SHA with the tag in a trailing
comment, and the installer is fetched from the v1.13.223 tag.

The dev build that installer downloads is still a moving target, which is
unavoidable while charm needs JLine 4.4.5 and babashka carries it only there.
Restoring the signal handlers could not be done through Terminal.handle, the
supported JLine 4 route, because it needs nested classes babashka cannot
resolve; the static Signals helper returns the previous handler just the same.

And the JLine 4.4.5 bump had already closed the OSC and DCS half of the
injection surface, measured against both 4.3.1 and 4.4.5 - so what the sanitizer
still buys is ESC c, carriage return, private CSI and the C1 controls, which
remain live.

Gating the release job on a tag rather than every push to main is left open: it
changes how releases are cut.
The first reading of the load error was wrong. Terminal$SignalHandler does
resolve as a symbol; Terminal$Signal does not, but its enum values are reachable
through getEnumConstants. What actually blocks the migration is reify, which in
babashka works only for the interfaces on its own list, with no Proxy to fall
back on - so exposing the class would not be enough.

Terminal.trackMouse is unaffected, since it takes an enum rather than a callback.
The loop slept 10 ms every iteration and then handled exactly one message, so
every keystroke waited up to 10 ms and throughput was capped near 100 messages a
second - a 300-character paste took three seconds. It now waits on the channel,
handles everything already queued, and renders at most once per :fps. Measured on
the loop alone at 60 fps: 300 messages in 3.0 ms, 5000 in 7.3 ms.

That also makes :fps mean something. It was documented, stored and never read;
every single message triggered a full view and terminal diff. handle-msg! no
longer renders - it reports whether the frame is dirty and the loop owns the
render - which collapses the quit, error and resize branches out of the loop body
as well.

Two hazards come with rendering off the message path, both tested:

  - The frame a program quits on must still be drawn. A state change and the
    quit that follows can land in the same batch, and an inline program's last
    view is what stays on the terminal.
  - A dirty frame must be or-ed across iterations, not replaced, or a state
    change followed by messages that change nothing loses its render.

There is no separate frame-tick channel: alts!! waits against a timeout sized to
the rest of the current frame, which doubles as the poll that notices an
externally flipped running?, since run-async's :quit! only sets that atom.

Commands now run on a/thread rather than in a go block. A command is where a
program does its blocking work, which is exactly what a go block must not do; on
the eight-thread dispatch pool a few concurrent commands starved it and froze the
UI. Twenty commands sleeping 100 ms each now finish in ~100 ms instead of ~300.
a/thread conveys the binding frame, so the color environment still reaches a
command that renders styled text.

The input thread backs off instead of spinning: every exception was caught and
discarded with no backoff, so a reader that failed forever pinned a core. It now
backs off exponentially to 500 ms and after ten consecutive failures reports an
error and stops. The event conversion moved out into event->msg to make room -
it was a 40-line cond nested five deep - and is now unit-tested, which is how the
nil-instead-of-false modifiers surfaced.
Bracketed paste was already built - the enable and disable sequences, and keymap
bindings for the markers - but nothing turned it on, so pastes arrived as
individual key presses and the markers were indistinguishable from typed input.
With :bracketed-paste true a paste is one :paste message carrying the whole text,
and stop! disables it again. Enter and Tab inside a paste are appended as text
rather than delivered as key presses.

Collecting the text needs a bound: waiting for the end marker indefinitely means
a truncated paste sequence keeps the input thread in that loop forever and the
application stops seeing input at all. Three consecutive read timeouts end the
paste - a paste arrives as one contiguous burst, so ~300 ms of silence means the
terminal is not going to finish it. Found by the integration test for the
unterminated case hanging the suite.

Default false, because turning it on changes what an application receives and one
that only handles key presses would stop seeing pastes altogether.

:ctrl-c defaults to :quit, which stops the program before update sees the key, so
a program that does not handle it can still be killed from the keyboard -
verified against an update that handles nothing at all. :message delivers it as
an ordinary "ctrl+c" key press instead and leaves quitting to the application,
which then cannot be interrupted from the keyboard if it never acts on it. All
fourteen examples here use "ctrl+c" only to quit, so the default preserves their
behaviour and their bindings are simply redundant now.
The run options table gains :bracketed-paste and :ctrl-c, with sections on what
:fps now means for redraws, on taking Ctrl+C over, and on the :paste message.
Also states plainly that command bodies may block, which is true as of the loop
rewrite and was the opposite of true before it.
Notes the two hazards that only appeared once the loop stopped rendering per
message - the frame a program quits on, and a dirty frame forgotten by a later
clean batch - and the timeout bound bracketed paste needs to avoid freezing the
input thread on a truncated paste.
a/thread's pool is a cached pool of platform threads, so a burst of commands
created one OS thread each and parked them. a/io-thread dispatches to a virtual
thread where the runtime has one, which is the right fit: commands are arbitrary
user code doing blocking I/O.

  50 concurrent commands: +50 platform threads -> +0
  500:                    +500, 167 ms         -> +0, 111 ms
  2000:                   +2000, 188 ms        -> +0, 146 ms

The trade-off is that a command doing extended computation rather than I/O now
holds a carrier thread where a platform thread would have been time-sliced;
documented, with a/thread-call's :compute workload as the escape hatch.

io-thread conveys the binding frame just as thread did, so the color environment
still reaches a command that renders styled text. It also exists in babashka's
core.async and falls back to ordinary threads there, since a native image has no
virtual threads - hence the thread-count test is guarded on the runtime providing
them, using Thread/activeCount, which counts the main thread group and so
excludes virtual threads by construction.

The other three threads stay platform threads, for reasons now written down: the
input thread blocks in a JLine read through FFM downcalls, which pins a carrier,
and the shutdown hook and run-async's program thread are single and long-lived.
It leaned on an internal working document for several references - a measurement
attribution, where the dynamic vars came from, the skip-render that benefits from
data output, and what the final step resolves. Each now states its substance
inline, so the ADR reads on its own and does not depend on something ephemeral.
Records the decisions behind the runtime performance work, which until now lived
only in commit messages: waiting on the channel instead of polling it, draining a
batch and rendering once per :fps, skipping the render when the state is
identical, and dispatching command bodies to virtual threads.

Includes what a reader would otherwise have to rediscover: why there is no
separate frame-tick channel, the two invariants that are easy to break once
rendering leaves the message path, why every thread other than a command body
stays a platform thread, and the compute-versus-I/O trade-off that virtual
threads bring with them. Measurements and how to reproduce them are in the ADR.

The render path itself stays with ADR 008 - this one changes how often view and
render! are called, that one changes what they cost.
003-testing-strategy.md was headed "ADR 001", so it collided with the
BreakIterator record it sits next to. Every other heading already matches its
filename.

ADR 008 was the only record missing from the cljdoc tree.
…tting

render! measured each line, cut it via a string-returning truncate, then parsed
the result again - up to three AttributedString/fromAnsi passes per line per
frame. It now parses once and cuts with .columnLength / .columnSubSequence.

  40 lines of 80 styled columns, per frame
  fits the width:    165.1 us -> 82.6 us
  needs truncating:  312.1 us -> 116.2 us

The round trip through a string was also losing the styling of every line too
wide for its container. str on an AttributedString returns the plain text, so
w/truncate threw away the styling that columnSubSequence had just preserved: a
red over-long line came back as bare text. That affected the renderer, viewport
lines, table cells and the public style/truncate. w/truncate now serialises with
.toAnsi instead, leaving the tail unstyled as before.

screen/truncate-line had exactly one caller and goes with it.
Records that the line cut was losing styling as well as time: the renderer,
viewport lines, table cells and style/truncate all dropped the color of anything
too wide for its container.
JLine's toAnsi does two jobs: it emits the escape sequences for a style, and it
rewrites characters it thinks the terminal cannot display. With no Terminal to
ask about the character set it assumes the worst, so styling any text containing
Unicode line-drawing characters replaced them with ASCII approximations, while
the same text left unstyled came through intact:

  (style/render (style/style :border normal) "hi")                → ┌──┐
  (style/render (style/style :border normal :border-fg :red) "hi") → +--+
  (style/render (style/style :fg :red) "┌──┐")                     → +--+

Compositing had it too, since every overlaid line goes through an
AttributedStringBuilder: placing a panel on a bordered base turned the base's │
and ─ into | and -. Seven :border-fg call sites across five of the examples were
rendering ASCII corners.

charm.style.color/attributed->ansi emits the same escapes JLine would, one run of
styling at a time, and writes the text itself instead of letting toAnsi rewrite
it. The escapes come from a one-character probe, and a test pins them to JLine's
own output for ASCII so they cannot drift. styled-str, render's text styling and
the overlay all go through it.

Other non-ASCII - accented letters, CJK, arrows, check marks - was never
affected; only the characters in the alternate-character-set table.
Implemented far enough to measure, then reverted. Two assumptions in it were
wrong: re-parsing is not what costs - measuring a line costs about the same
whether or not it holds escapes, because columnLength walks the characters either
way - and the win does not survive realistic content. Blocks are 2-4x faster on
large content with several layout layers and slower on everything small, so a
view of forty labels and one box came out 30% slower.

Reaching parity on small renders needed a memoised escape table, a per-call style
cache, an ASCII width fast path and a second non-block path in render, which is
the tell: the caching was clawing back overhead the representation introduced.

The ADR now records the alternative that does follow from the measurements. The
redundancy is not the currency, it is that one render measures the same text
about seven times per line; every width after the first is arithmetic, so
measuring once and carrying the numbers removes most of it without changing what
flows through the stack.

Its two motivations that never depended on the performance argument - true colour
reaching the terminal, and retiring the dynamic vars - are unaffected, and the
note in ADR 009 pointing at it is updated.
Applying a single style to text needs only the escapes and a str - there is no
run to find. render's text styling was building an AttributedString per line and
walking it for style runs anyway, which always found exactly one.

attributed->ansi is now used only for compositing, where a line genuinely carries
several styles, and style->escapes is the primitive the single-style callers use
directly. Measured on a short styled label, which is the common case: 0.70 us
before the character fix, 0.84 us after it, 0.84 us now - the extra work is one
probe per distinct style, hoisted out of the per-line loop.
…sions

Component ids were (rand-int 1000000). The spinner and the timer route their tick
messages back to themselves by comparing ids, so two components sharing one drive
each other - about a one-in-a-hundred chance once an application holds 150 of
them. A counter cannot collide, and an explicit :id is still honoured.

merge-style returns a copy of a style with the named options changed. Plain
(merge base variant) overwrites the base with the variant's defaults - nil
colours, false attributes - instead of inheriting them, which is the opposite of
what building a variant wants. Naming the options rather than reading them off
another style map also means (merge-style s :bold false) genuinely turns bold off.

:overflow decides which end the renderer drops lines from when a view is taller
than the terminal. :top, the existing behaviour, keeps the last lines and suits an
inline program. :bottom keeps the first ones, which is what a full-screen program
usually wants - otherwise a view one line too tall loses its title with nothing to
say it did.

key-match? no longer ignores modifiers when the pattern names none: "c" was also
firing on Ctrl+C, so an application could not bind the two separately. Verified
that every component's default bindings still fire and that uppercase letters,
which carry no shift flag, still match.

Also removes keys/key-matches?, which duplicated the pattern logic on event maps
and had no callers outside its own tests.
Records the id collision as a correctness fix rather than a tidy-up, since the
spinner and timer compare ids to route their own ticks, and notes the key-match?
change as potentially breaking for anything that was relying on a bare name
matching a modified key.
This is a library, so its public API is used by code we cannot see. Records what
counts as public - every namespace without ^:no-doc - and that a nicer design is
not on its own a reason to change an existing return value or option meaning.
Both shipped without a page, while the other seven components each had one, and
neither appeared in the components overview or the cljdoc tree.

Every claim in the two pages was checked against the code rather than inferred
from the docstrings: that :width makes every visible viewport line exactly that
wide, that short content is padded to :height, that set-dimensions and
set-cursor clamp, that a cell too wide for its column is truncated with an
ellipsis and measured by display width, that :cursor nil makes the navigation
keys a no-op, and that both update functions return their component unchanged
for messages they do not recognise.
The existing integration tests assert on the escape bytes the renderer wrote.
That is the right level for "did this OSC reach the terminal" and the wrong one
for "what does the user see" - those assertions break whenever JLine's Display
changes how it moves the cursor, even when the screen is identical.

charm.test.screen feeds the renderer's output to org.jline.utils.ScreenTerminal,
an in-memory VT emulator, and reads the cells back, so a test can assert that row
0 reads ╭───────╮ and that those cells are red. Twenty-five assertions cover box
drawing through a real terminal, cell attributes, width cutting, :overflow in both
directions, and the table, viewport and overlay on screen.

Two things had to be right for the grid to mean anything. The size must be given
to the TerminalBuilder as well as to the renderer, because Display takes its idea
of the width from the terminal and one that thinks the screen is wider pads with a
cursor-forward, which lands the next line in the wrong column - that was visible
as every row after the first being shifted by one. The cell accessors are static
methods on ScreenTerminal, not instance ones. The existing render-to-bytes helper
had the same sizing mistake; its tests passed only because they assert substrings.

These tests live in test-jvm/ because ScreenTerminal is not in babashka's image
and a namespace that merely names it fails to load there, at analysis time, so no
runtime guard helps. deps.edn's :test alias lists both directories and bb.edn
lists only test, so the babashka run reports fewer assertions than the JVM one.
Nothing auto-sizes to the terminal, but nothing was missing either: resize
already arrives as :window-size at startup and on every WINCH, and the remaining
rows are computable from the rest of the view. The file browser just did not do
it - chrome-height was 5 where its view uses 4, and the comment above it counted
a blank line the view never emitted, so it wasted a row of listing on every
terminal.

It now measures the same header and footer functions the view renders, so the two
cannot disagree. View rows against terminal rows: 22/24 and 28/30 before, 24/24
and 30/30 after. At an odd terminal height it is still one short, since each list
item takes two rows.

doc/api/program.md documents the pattern, including why a constant goes quietly
wrong and that str/split-lines is the wrong way to count a header ending in a
blank line.
org.jline.terminal.KeyParser did not exist when ADR 004 was written and overlaps
heavily with charm.input.keys and charm.input.keymap, so it was worth measuring
against them. Every sequence charm binds went through it, taken from the keymap's
own tables including the generated xterm modifier combinations: 259 sequences, 209
agree, 0 disagree, 50 it cannot parse.

Not adopted, for three independent reasons. The 50 gaps are not obscure - the SS3
arrows that terminals send in application keypad mode, the VT-style home and end
variants with all their modifier forms, F13 to F20, focus reporting and bracketed
paste, the last two being documented run options. KeyParser is hardcoded where
KeyMap binds from terminfo capabilities. And neither KeyParser nor KeyEvent is in
babashka's image while KeyMap is, and input is core rather than test-only, so there
is no way to exclude it there.

The zero disagreements are worth keeping: they are an independent check on a table
charm generates itself. charm.input.keyparser-test asserts the two agree wherever
both understand a sequence, and that the listed gaps are still gaps - so if JLine
closes one, the test says so. It lives under test-jvm, since KeyParser is absent
from babashka.

ADR 004 gains a dated addendum recording the measurement; it confirms that
decision rather than revising it.
@TimoKramer TimoKramer self-assigned this Sep 25, 2026
@TimoKramer
TimoKramer merged commit 201a425 into main Sep 25, 2026
2 checks passed
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