Repository navigation
some fixes and improvements - #21
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.
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.
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.
adaptivetakes a light and a dark variant andresolves against the terminal background at render time.
rundetects thebackground (OSC 11) and the color profile at startup, and reports both to the
application as an
:environmentmessage.:fg 240,:fg :redand:fg "#ff8000"were silently dropped before — which also disabled charm's owndefault styling in
help,listandtext-input, and most sample code.:color-profileand:dark-background?options onrunskip detection, and with it the probetimeout a silent terminal costs at every startup.
iteration, capping throughput near 100 messages a second — a 300-character
paste took three seconds; it now takes 3 ms.
:fpsis finally read and acts asthe redraw ceiling, and command bodies run on virtual threads, so blocking work
no longer starves the dispatch pool.
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.
is sanitized before it is written,
set-window-titleescapes 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.merge-style,:overflow,:bracketed-paste,:ctrl-cand:sanitizeonrun."esc","pgup"and"pgdown"never matched anything;key-match?ignored modifiers when the pattern named none, so"c"also firedon Ctrl+C; truncating a styled line stripped its styling; styling rewrote
┌──┐as+--+; component ids could collide;:strikethroughnever reachedthe terminal.
ColorPalettedefects we found and gotfixed upstream (jline3 #2260, #2261).
unstyled (
(style :fg "red")is an error), and the internalcharm.input.keys/key-matches?is gone — usemsg/key-match?.