Accepted
charm.clj follows the Elm architecture where:
update function processes events one at a timeview function renders the entire screen on each updateJLine provides several APIs for terminal input handling:
Use NonBlockingReader for raw input and KeyMap for escape sequence lookup. Do not use BindingReader or LineReader.
| JLine API | Usage |
|---|---|
Terminal | Terminal creation, raw mode, size detection |
NonBlockingReader | Character-by-character input with timeout |
KeyMap | Escape sequence → key event mapping |
InfoCmp$Capability | Terminal-aware key sequences |
Display | Efficient screen diffing and rendering |
AttributedString | Unicode width calculation, ANSI parsing |
| JLine API | Reason |
|---|---|
BindingReader | Blocks until complete sequence; incompatible with async event loop |
LineReader | Manages its own display; conflicts with Elm view function |
| JLine mouse API | Requires detecting mouse prefix first; custom parsing is cleaner |
BindingReader.readBinding() blocks until it recognizes a complete key sequence or times out. This is problematic for the Elm architecture:
// BindingReader blocks here until complete sequence
Object binding = bindingReader.readBinding(keyMap);
In contrast, the async event loop needs to:
The custom approach reads characters individually via NonBlockingReader:
(defn read-event [terminal & {:keys [timeout-ms]}]
(let [reader (.reader terminal)
c (.read reader timeout-ms)] ; Returns immediately on timeout
(when (pos? c)
(parse-input c))))
LineReader provides rich line editing (history, completion, syntax highlighting) but:
Displayview renders the entire screencharm.clj's text-input component provides similar functionality within the Elm architecture, where each keystroke is an event that updates state and triggers a full re-render.
We use KeyMap for escape sequence lookup while handling input ourselves:
(defn create-keymap [terminal]
(let [keymap (KeyMap.)]
;; Terminal-aware: uses actual sequences from terminfo
(when terminal
(.bind keymap {:type :up} (KeyMap/key terminal Capability/key_up)))
;; Fallback: standard sequences for terminals without capabilities
(.bind keymap {:type :up} "[A")
(.bind keymap {:type :up} "OA")
keymap))
;; O(1) lookup via trie
(defn lookup [keymap sequence]
(.getBound keymap sequence))
Benefits:
JLine's BindingReader can parse mouse sequences, but:
[M or [<) firstOur custom parser handles X10 and SGR mouse formats directly:
(defn parse-sgr-mouse [s]
(when-let [[_ code x y final] (re-find #"\x1b\[<(\d+);(\d+);(\d+)([Mm])" s)]
{:type :mouse
:button (parse-button code)
:x (parse-long x)
:y (parse-long y)
:action (if (= final "m") :release :press)}))
This decision can be revisited if:
Added after the decision above, which it confirms rather than revises.
org.jline.terminal.KeyParser did not exist when this was written.
KeyParser.parse(String) is static and non-blocking — so none of the
BindingReader objections above apply to it — and returns a structured KeyEvent
carrying a type, an EnumSet of modifiers, arrows, function keys and specials. It
overlaps heavily with charm.input.keys and charm.input.keymap, so it was worth
measuring against them.
Every sequence charm binds was run through it, taken from the keymap's own tables including the generated xterm modifier combinations:
| count | |
|---|---|
| sequences charm binds | 259 |
| KeyParser agrees | 209 |
| KeyParser disagrees | 0 |
| KeyParser cannot parse | 50 |
Not adopted, for three independent reasons.
It cannot parse 19% of what charm needs. The 50 gaps are not obscure:
| gap | count | charm needs it for |
|---|---|---|
ESC O A/B/C/D/F/H | 6 | arrows and home/end in application keypad mode, which real terminals send |
ESC [ 1~, 4~, 7~, 8~ and their modifier forms | 32 | home and end on VT-style terminals |
ESC [ 25~ … 34~ | 8 | F13–F20 |
ESC [ I, ESC [ O | 2 | focus reporting, a documented run option |
ESC [ 200~, 201~ | 2 | bracketed paste, a documented run option |
It is hardcoded where KeyMap is terminal-aware. charm binds from terminfo
capabilities first and falls back to standard sequences, so it follows whatever
the terminal declares. KeyParser has one fixed table.
It is not available on a supported platform. KeyParser and KeyEvent are not
in babashka's image; KeyMap is. Input is core rather than test-only, so there is
no way to exclude it there.
What the spike did buy. Zero disagreements across 209 sequences is an
independent check on a table charm generates itself, and it is now a standing one:
charm.input.keyparser-test (under test-jvm/, since KeyParser is absent from
babashka) asserts that the two agree wherever both understand a sequence, and that
the gaps above are still gaps. If JLine closes one, that test fails and this
addendum should be revisited.
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |