From a3b5e19be59bb1bded56c9c8cefe1a8c16192847 Mon Sep 17 00:00:00 2001 From: Timo Kramer Date: Mon, 23 Mar 2026 17:08:12 +0100 Subject: [PATCH 1/7] feat: update jline and use mode 2027 of it --- deps.edn | 4 +- ...-005-draft.md => 005-layout-primitives.md} | 0 docs/adr/006-grapheme-cluster-width.md | 52 +++++++ src/charm/ansi/width.clj | 32 ++++- src/charm/input/handler.clj | 8 +- src/charm/program.clj | 136 +++++++++--------- src/charm/style/overlay.clj | 8 +- test/charm/integration/input_test.clj | 11 +- 8 files changed, 168 insertions(+), 83 deletions(-) rename docs/adr/{adr-005-draft.md => 005-layout-primitives.md} (100%) create mode 100644 docs/adr/006-grapheme-cluster-width.md diff --git a/deps.edn b/deps.edn index 667de8c..0780864 100644 --- a/deps.edn +++ b/deps.edn @@ -1,9 +1,9 @@ {:paths ["src"] :deps {org.clojure/clojure {:mvn/version "1.12.4"} org.clojure/core.async {:mvn/version "1.8.741"} - org.jline/jline-terminal-ffm {:mvn/version "3.30.6" + org.jline/jline-terminal-ffm {:mvn/version "4.0.8" :exclusions [org.jline/jline-native]} - org.jline/jline-reader {:mvn/version "3.30.6"}} + org.jline/jline-reader {:mvn/version "4.0.8"}} :aliases diff --git a/docs/adr/adr-005-draft.md b/docs/adr/005-layout-primitives.md similarity index 100% rename from docs/adr/adr-005-draft.md rename to docs/adr/005-layout-primitives.md diff --git a/docs/adr/006-grapheme-cluster-width.md b/docs/adr/006-grapheme-cluster-width.md new file mode 100644 index 0000000..bd5e163 --- /dev/null +++ b/docs/adr/006-grapheme-cluster-width.md @@ -0,0 +1,52 @@ +# ADR 006: Grapheme Cluster Width via JLine 4 Mode 2027 + +## Status + +Accepted + +## Context + +Multi-codepoint emoji (ZWJ sequences like πŸ‘¨β€πŸ‘©β€πŸ‘§, flags like πŸ‡©πŸ‡ͺ, skin tone modifiers) are measured incorrectly by JLine 3's `columnLength()`, which sums per-codepoint widths. A family emoji reports as 6 cells instead of 2, breaking borders, padding, and truncation. + +### Mode 2027 + +[Mode 2027](https://mitchellh.com/writing/grapheme-clusters-in-terminals) (`CSI ?2027h`) is a terminal protocol that enables grapheme clustering for cursor movement. Supported by Ghostty, Contour, Foot, WezTerm, kitty, and others. Terminals that don't recognize it silently ignore the sequence (standard VT behavior for unknown private modes). + +### JLine 4 + +JLine 4.0.0 added built-in Mode 2027 support: + +- `TerminalBuilder.build()` automatically probes for Mode 2027 via DECRQM and enables it if supported +- `columnLength(terminal)` / `columnSubSequence(start, end, terminal)` use grapheme clustering when Mode 2027 is active, fall back to per-codepoint wcwidth otherwise +- `Display.update()` uses the terminal's grapheme mode for its internal screen diffing +- `AbstractTerminal.doClose()` automatically disables Mode 2027 on cleanup + +No custom implementation needed β€” JLine handles probing, enabling, width calculation, and cleanup. + +## Decision + +Upgrade from JLine 3.30.6 to JLine 4.x and use its built-in Mode 2027 support. + +### Threading the terminal + +JLine 4's grapheme-aware methods require a `Terminal` reference. We use a dynamic var `charm.ansi.width/*terminal*` to thread it through the width calculation functions: + +- `column-length` and `column-sub-sequence` delegate to JLine's terminal-aware overloads when `*terminal*` is bound +- `string-width` and `truncate` use these helpers +- `charm.program/run` binds `*terminal*` for the duration of the event loop + +When `*terminal*` is unbound (tests, REPL usage outside a program), the functions fall back to JLine's no-arg `columnLength()` (per-codepoint wcwidth). + +## Consequences + +### Pros + +- Correct width for all emoji types on terminals that support Mode 2027 +- Graceful fallback to wcwidth on terminals that don't +- No custom width tables or grapheme clustering logic β€” all JLine +- JLine's `Display` also benefits from grapheme mode, fixing screen diffing artifacts + +### Cons + +- JLine 4 requires Java 11+ (not an issue β€” charm.clj already requires Java 21+ for FFM) +- JLine 4 removed JNA/Jansi providers (not an issue β€” charm.clj uses FFM) diff --git a/src/charm/ansi/width.clj b/src/charm/ansi/width.clj index 5a971c6..5d9b947 100644 --- a/src/charm/ansi/width.clj +++ b/src/charm/ansi/width.clj @@ -5,10 +5,17 @@ - ANSI escape sequences (zero width) - Wide characters (CJK, emojis = 2 cells) - Combining characters (zero width) - - Grapheme clusters (emoji sequences)" + - Grapheme clusters (emoji sequences, via JLine 4 Mode 2027)" (:import + [org.jline.terminal Terminal] [org.jline.utils AttributedString])) +(def ^:dynamic *terminal* + "Bound terminal for grapheme-cluster-aware width calculation. + When bound, JLine uses Mode 2027 grapheme clustering if the terminal + supports it. When nil, falls back to per-codepoint wcwidth." + nil) + (defn strip-ansi "Remove ANSI escape sequences from a string." [s] @@ -16,12 +23,29 @@ "" (.toString (AttributedString/fromAnsi s)))) +(defn column-length + "Get the display width of an AttributedString. + Uses grapheme clustering when *terminal* is bound and supports Mode 2027." + [^AttributedString attr-s] + (if *terminal* + (.columnLength attr-s ^Terminal *terminal*) + (.columnLength attr-s))) + +(defn column-sub-sequence + "Get a column-based subsequence of an AttributedString. + Uses grapheme clustering when *terminal* is bound and supports Mode 2027." + [^AttributedString attr-s start end] + (if *terminal* + (.columnSubSequence attr-s (int start) (int end) ^Terminal *terminal*) + (.columnSubSequence attr-s (int start) (int end)))) + (defn string-width "Measure the display width of a string in terminal cells. - ANSI escape sequences have zero width - Wide characters (CJK, emojis) count as 2 cells - Combining characters count as 0 cells + - Grapheme clusters (ZWJ emoji) count as 2 cells when *terminal* is bound Example: (string-width \"hello\") ; => 5 @@ -30,7 +54,7 @@ [s] (if (or (nil? s) (empty? s)) 0 - (.columnLength (AttributedString/fromAnsi s)))) + (column-length (AttributedString/fromAnsi s)))) (defn truncate "Truncate a string to fit within a given display width. @@ -47,13 +71,13 @@ (if (nil? s) s (let [attr-s (AttributedString/fromAnsi s)] - (if (<= (.columnLength attr-s) width) + (if (<= (column-length attr-s) width) s (let [tail-width (string-width tail) target-width (- width tail-width)] (if (neg? target-width) "" - (str (.columnSubSequence attr-s 0 target-width) tail))))))) + (str (column-sub-sequence attr-s 0 target-width) tail))))))) (defn pad-right "Pad a string on the right to reach a target display width." diff --git a/src/charm/input/handler.clj b/src/charm/input/handler.clj index 2f66b24..430bec2 100644 --- a/src/charm/input/handler.clj +++ b/src/charm/input/handler.clj @@ -11,7 +11,7 @@ (:import [org.jline.keymap KeyMap] [org.jline.terminal Terminal] - [org.jline.utils NonBlockingReader])) + [org.jline.utils ClosedException NonBlockingReader])) ;; --------------------------------------------------------------------------- ;; Input Reading @@ -19,9 +19,11 @@ (defn- read-char "Read a single character from the terminal with timeout. - Returns the character code or -1 if timeout/EOF." + Returns the character code or -1 if timeout/EOF/closed." [^NonBlockingReader reader ^long timeout-ms] - (.read reader timeout-ms)) + (try + (.read reader timeout-ms) + (catch ClosedException _ -1))) (defn- read-char-blocking "Read a single character from the terminal, blocking." diff --git a/src/charm/program.clj b/src/charm/program.clj index 8bf6336..3bab2b9 100644 --- a/src/charm/program.clj +++ b/src/charm/program.clj @@ -8,6 +8,7 @@ Commands are functions that produce messages asynchronously." (:require + [charm.ansi.width :as w] [charm.input.handler :as input] [charm.input.keymap :as km] [charm.message :as msg] @@ -203,80 +204,81 @@ state (atom initial-state)] (try - ;; Setup renderer - (render/start! renderer) - (when alt-screen - (render/enter-alt-screen! renderer)) - - ;; Setup mouse - (when mouse - (render/enable-mouse! renderer mouse)) - - ;; Setup focus reporting - (when focus-reporting - (render/enable-focus-reporting! renderer)) - - ;; Handle window resize signal - (Signals/register "WINCH" - (reify Runnable - (run [_] - (check-window-size! terminal msg-chan last-size)))) - - ;; Check initial window size - (check-window-size! terminal msg-chan last-size) - - ;; Start input loop (returns thread) - (let [^Thread input-thread (start-input-loop! terminal msg-chan running?)] - - ;; Execute init command - (execute-cmd! init-cmd msg-chan) - - ;; Render initial view - (render/render! renderer (view @state)) - - ;; Drain any initial window-size message to avoid double render - ;(a/poll! msg-chan) - - ;; Main event loop - (loop [] - (when @running? - (when-let [m (a/ (TerminalBuilder/builder) - (.streams input output) + (.streams pis output) (.system false) (.type "dumb") (.build)) From 5e8b6425c3047cc0cb6ac2fcdc2c6141406b88c2 Mon Sep 17 00:00:00 2001 From: Timo Kramer Date: Tue, 24 Mar 2026 13:41:43 +0100 Subject: [PATCH 2/7] update jline to 4.0.9 and remove ClosedException --- deps.edn | 4 +- docs/adr/006-grapheme-cluster-width.md | 69 +++++++++++++++++++++----- src/charm/input/handler.clj | 5 +- 3 files changed, 62 insertions(+), 16 deletions(-) diff --git a/deps.edn b/deps.edn index 0780864..733bd22 100644 --- a/deps.edn +++ b/deps.edn @@ -1,9 +1,9 @@ {:paths ["src"] :deps {org.clojure/clojure {:mvn/version "1.12.4"} org.clojure/core.async {:mvn/version "1.8.741"} - org.jline/jline-terminal-ffm {:mvn/version "4.0.8" + org.jline/jline-terminal-ffm {:mvn/version "4.0.9" :exclusions [org.jline/jline-native]} - org.jline/jline-reader {:mvn/version "4.0.8"}} + org.jline/jline-reader {:mvn/version "4.0.9"}} :aliases diff --git a/docs/adr/006-grapheme-cluster-width.md b/docs/adr/006-grapheme-cluster-width.md index bd5e163..953f0f6 100644 --- a/docs/adr/006-grapheme-cluster-width.md +++ b/docs/adr/006-grapheme-cluster-width.md @@ -6,20 +6,54 @@ Accepted ## Context -Multi-codepoint emoji (ZWJ sequences like πŸ‘¨β€πŸ‘©β€πŸ‘§, flags like πŸ‡©πŸ‡ͺ, skin tone modifiers) are measured incorrectly by JLine 3's `columnLength()`, which sums per-codepoint widths. A family emoji reports as 6 cells instead of 2, breaking borders, padding, and truncation. +### The problem + +Unicode text that looks like a single character on screen can be made up of multiple codepoints. The classic `wcwidth` approach (assigning a width to each codepoint and summing) breaks for these multi-codepoint sequences because it doesn't understand that the terminal renders them as one grapheme cluster. + +Examples: + +| Sequence | Codepoints | wcwidth sum | Actual terminal width | +|---|---|---|---| +| πŸ‘¨β€πŸ‘©β€πŸ‘§ (family) | `πŸ‘¨` + ZWJ + `πŸ‘©` + ZWJ + `πŸ‘§` (5 codepoints) | 2+0+2+0+2 = **6** | **2** | +| πŸ‡©πŸ‡ͺ (flag) | `πŸ‡©` + `πŸ‡ͺ` (2 regional indicators) | 2+2 = **4** | **2** | +| πŸ‘‹πŸ½ (wave + skin tone) | `πŸ‘‹` + `🏽` (2 codepoints) | 2+2 = **4** | **2** | + +When TUI code uses the wrong width, everything that depends on character measurement breaks: + +- **Borders and boxes** β€” right edges are misaligned because the emoji occupies fewer cells than calculated +- **Padding** β€” `pad-right` adds too few spaces (it thinks the string is wider than it is) +- **Truncation** β€” `truncate` cuts too early, clipping visible text to fit a phantom width +- **Screen diffing** β€” JLine's `Display.update()` miscalculates cursor positions, causing render artifacts (ghost characters, misplaced redraws) + +JLine 3's `columnLength()` uses per-codepoint wcwidth, so it produces the wrong values for all the cases above. + +### Why this is hard + +The fundamental issue is that the **terminal**, not the application, decides how to cluster codepoints into graphemes. Different terminals use different Unicode versions and clustering rules. An application that hardcodes a width table will always be wrong on some terminals. + +Mode 2027 solves this by letting the terminal tell the application "I support grapheme clustering"β€”meaning the terminal itself treats each grapheme cluster as a single unit for cursor movement. Once the application knows the terminal clusters correctly, it can count each cluster as width 2 (for emoji) or 1 (for text), rather than summing per-codepoint widths. ### Mode 2027 -[Mode 2027](https://mitchellh.com/writing/grapheme-clusters-in-terminals) (`CSI ?2027h`) is a terminal protocol that enables grapheme clustering for cursor movement. Supported by Ghostty, Contour, Foot, WezTerm, kitty, and others. Terminals that don't recognize it silently ignore the sequence (standard VT behavior for unknown private modes). +[Mode 2027](https://mitchellh.com/writing/grapheme-clusters-in-terminals) (`CSI ?2027h`) is a terminal protocol that enables grapheme clustering for cursor movement. The protocol works as follows: + +1. **Probe**: the application sends `CSI ?2027$p` (DECRQM β€” request mode) to ask the terminal if it supports grapheme clustering +2. **Response**: a supporting terminal replies with `CSI ?2027;1$y` (mode set) or `CSI ?2027;2$y` (mode reset but recognized). A non-supporting terminal either doesn't reply or returns an unrecognized-mode response +3. **Enable**: if supported, the application sends `CSI ?2027h` to activate grapheme clustering +4. **Effect**: the terminal now moves the cursor in grapheme-cluster units. A family emoji `πŸ‘¨β€πŸ‘©β€πŸ‘§` occupies 2 cells and the cursor advances by 2, not by 6 +5. **Disable**: on exit, the application sends `CSI ?2027l` to restore default behavior + +Supported by Ghostty, Contour, Foot, WezTerm, kitty, and others. Terminals that don't recognize it silently ignore the sequence (standard VT behavior for unknown private modes), so enabling it is always safe. ### JLine 4 -JLine 4.0.0 added built-in Mode 2027 support: +JLine 4.0.0 added built-in Mode 2027 support, handling the entire lifecycle: -- `TerminalBuilder.build()` automatically probes for Mode 2027 via DECRQM and enables it if supported -- `columnLength(terminal)` / `columnSubSequence(start, end, terminal)` use grapheme clustering when Mode 2027 is active, fall back to per-codepoint wcwidth otherwise -- `Display.update()` uses the terminal's grapheme mode for its internal screen diffing -- `AbstractTerminal.doClose()` automatically disables Mode 2027 on cleanup +- **Probing**: `TerminalBuilder.build()` automatically sends the DECRQM query and parses the response to detect Mode 2027 support +- **Enabling**: if the terminal supports it, JLine enables Mode 2027 automatically during terminal construction +- **Width calculation**: `columnLength(terminal)` and `columnSubSequence(start, end, terminal)` are new overloads that accept a `Terminal` parameter. When the terminal has Mode 2027 active, these methods use grapheme clustering (each cluster = 1 or 2 cells). When it doesn't, they fall back to per-codepoint wcwidth β€” same as JLine 3 +- **Screen diffing**: `Display.update()` uses the terminal's grapheme mode for its internal cursor position tracking, fixing render artifacts with emoji +- **Cleanup**: `AbstractTerminal.doClose()` automatically sends `CSI ?2027l` to disable the mode, so the terminal is left in a clean state No custom implementation needed β€” JLine handles probing, enabling, width calculation, and cleanup. @@ -29,13 +63,24 @@ Upgrade from JLine 3.30.6 to JLine 4.x and use its built-in Mode 2027 support. ### Threading the terminal -JLine 4's grapheme-aware methods require a `Terminal` reference. We use a dynamic var `charm.ansi.width/*terminal*` to thread it through the width calculation functions: +JLine 4's grapheme-aware `columnLength(terminal)` and `columnSubSequence(start, end, terminal)` require a `Terminal` reference to check whether Mode 2027 is active. But width functions like `string-width` and `truncate` are called deep in the call stackβ€”inside `view` functions, inside component rendering, inside layout helpersβ€”where passing a terminal explicitly would thread it through every function signature. + +We use a dynamic var `charm.ansi.width/*terminal*` to avoid this: + +```clojure +(def ^:dynamic *terminal* nil) + +(defn column-length [^AttributedString attr-s] + (if *terminal* + (.columnLength attr-s ^Terminal *terminal*) ; grapheme-aware + (.columnLength attr-s))) ; wcwidth fallback +``` -- `column-length` and `column-sub-sequence` delegate to JLine's terminal-aware overloads when `*terminal*` is bound -- `string-width` and `truncate` use these helpers -- `charm.program/run` binds `*terminal*` for the duration of the event loop +- `charm.program/run` binds `*terminal*` once at the top of the event loop via `binding` +- All width calculations within the event loop automatically use the grapheme-aware overload +- `column-length`, `column-sub-sequence`, `string-width`, `truncate`, `pad-right`, `pad-left` all benefit without any signature changes -When `*terminal*` is unbound (tests, REPL usage outside a program), the functions fall back to JLine's no-arg `columnLength()` (per-codepoint wcwidth). +When `*terminal*` is unbound (unit tests, REPL usage outside a running program), the functions fall back to JLine's no-arg `columnLength()` which uses per-codepoint wcwidth. This means tests don't need a terminal, and the library degrades gracefully. ## Consequences diff --git a/src/charm/input/handler.clj b/src/charm/input/handler.clj index 430bec2..290b91e 100644 --- a/src/charm/input/handler.clj +++ b/src/charm/input/handler.clj @@ -9,9 +9,10 @@ [charm.input.mouse :as mouse] [clojure.string :as str]) (:import + [java.io IOException] [org.jline.keymap KeyMap] [org.jline.terminal Terminal] - [org.jline.utils ClosedException NonBlockingReader])) + [org.jline.utils NonBlockingReader])) ;; --------------------------------------------------------------------------- ;; Input Reading @@ -23,7 +24,7 @@ [^NonBlockingReader reader ^long timeout-ms] (try (.read reader timeout-ms) - (catch ClosedException _ -1))) + (catch IOException _ -1))) (defn- read-char-blocking "Read a single character from the terminal, blocking." From 29638277c3d0019f742f444b199e83c595cecdbf Mon Sep 17 00:00:00 2001 From: Timo Kramer Date: Thu, 26 Mar 2026 16:02:59 +0100 Subject: [PATCH 3/7] docs: example for emoji widht added --- docs/adr/006-grapheme-cluster-width.md | 2 +- docs/examples/bb.edn | 3 + docs/examples/src/examples/emoji_width.clj | 89 ++++++++++++++++++++++ 3 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 docs/examples/src/examples/emoji_width.clj diff --git a/docs/adr/006-grapheme-cluster-width.md b/docs/adr/006-grapheme-cluster-width.md index 953f0f6..6eb40a1 100644 --- a/docs/adr/006-grapheme-cluster-width.md +++ b/docs/adr/006-grapheme-cluster-width.md @@ -63,7 +63,7 @@ Upgrade from JLine 3.30.6 to JLine 4.x and use its built-in Mode 2027 support. ### Threading the terminal -JLine 4's grapheme-aware `columnLength(terminal)` and `columnSubSequence(start, end, terminal)` require a `Terminal` reference to check whether Mode 2027 is active. But width functions like `string-width` and `truncate` are called deep in the call stackβ€”inside `view` functions, inside component rendering, inside layout helpersβ€”where passing a terminal explicitly would thread it through every function signature. +JLine 4's grapheme-aware `columnLength(terminal)` and `columnSubSequence(start, end, terminal)` require a `Terminal` reference to check whether Mode 2027 is active. But width functions like `string-width` and `truncate` are called deep in the call stack, inside `view` functions, inside component rendering, inside layout helpers, where passing a terminal explicitly would thread it through every function signature. We use a dynamic var `charm.ansi.width/*terminal*` to avoid this: diff --git a/docs/examples/bb.edn b/docs/examples/bb.edn index a476ef0..1d9711c 100644 --- a/docs/examples/bb.edn +++ b/docs/examples/bb.edn @@ -9,6 +9,9 @@ counter {:doc "Run counter example" :task (exec 'examples.counter/-main)} + emoji-width {:doc "Run emoji width demo" + :task (exec 'examples.emoji-width/-main)} + download {:doc "Run download example" :task (exec 'examples.download/-main)} diff --git a/docs/examples/src/examples/emoji_width.clj b/docs/examples/src/examples/emoji_width.clj new file mode 100644 index 0000000..7e2ce34 --- /dev/null +++ b/docs/examples/src/examples/emoji_width.clj @@ -0,0 +1,89 @@ +(ns examples.emoji-width + "Demonstrates grapheme cluster width handling with emoji in tables and borders. + + Shows that ZWJ sequences, flags, and skin-tone emoji are measured correctly + when JLine 4 Mode 2027 is active (see ADR 006)." + (:require [charm.core :as charm])) + +;; --------------------------------------------------------------------------- +;; Data +;; --------------------------------------------------------------------------- + +(def emoji-rows + [;; ZWJ sequences + ["πŸ‘¨β€πŸ‘©β€πŸ‘§" "Family (ZWJ)" "3 codepoints joined by ZWJ"] + ["πŸ‘©β€πŸ’»" "Woman Technologist" "ZWJ sequence"] + ["πŸ³οΈβ€πŸŒˆ" "Rainbow Flag" "Flag + VS16 + ZWJ + Rainbow"] + ;; Flags (regional indicators) + ["πŸ‡©πŸ‡ͺ" "Germany" "2 regional indicator symbols"] + ["πŸ‡―πŸ‡΅" "Japan" "2 regional indicator symbols"] + ["πŸ‡§πŸ‡·" "Brazil" "2 regional indicator symbols"] + ;; Skin tone modifiers + ["πŸ‘‹πŸ½" "Wave (medium skin)" "Base + Fitzpatrick modifier"] + ["πŸ‘πŸΏ" "Thumbs Up (dark)" "Base + Fitzpatrick modifier"] + ["πŸ§‘πŸ»β€πŸ”¬" "Scientist (light)" "Skin tone + ZWJ + microscope"] + ;; Simple wide emoji + ["πŸŽ‰" "Party Popper" "Single codepoint, width 2"] + ["πŸ¦€" "Crab" "Ferris! Single codepoint"] + ["⚑" "Lightning" "Misc symbol, width 1 or 2"]]) + +;; --------------------------------------------------------------------------- +;; Styles +;; --------------------------------------------------------------------------- + +(def title-style + (charm/style :bold true :fg charm/magenta)) + +(def subtitle-style + (charm/style :fg charm/cyan :italic true)) + +(def box-style + (charm/style :border charm/rounded-border + :padding [0 1] + :border-fg charm/cyan)) + +(def header-style + (charm/style :bold true :fg charm/yellow)) + +(def cursor-style + (charm/style :bold true :fg charm/green)) + +;; --------------------------------------------------------------------------- +;; Init / Update / View +;; --------------------------------------------------------------------------- + +(defn init [] + (let [tbl (charm/table [{:title "Emoji" :width 6} + {:title "Name" :width 22} + {:title "Type" :width 34}] + emoji-rows + :cursor 0 + :header-style header-style + :cursor-style cursor-style)] + [tbl nil])) + +(defn update-fn [tbl msg] + (cond + (or (charm/key-match? msg "q") + (charm/key-match? msg "ctrl+c")) + [tbl charm/quit-cmd] + + :else + (charm/table-update tbl msg))) + +(defn view [tbl] + (str (charm/render title-style "Grapheme Cluster Width Demo") "\n" + (charm/render subtitle-style "Emoji should align neatly if Mode 2027 is active") "\n\n" + (charm/render box-style (charm/table-view tbl {:separator " β”‚ "})) + "\n\n" + "j/k navigate q quit")) + +;; --------------------------------------------------------------------------- +;; Main +;; --------------------------------------------------------------------------- + +(defn -main [& _args] + (charm/run {:init init + :update update-fn + :view view + :alt-screen true})) From 4ea6ecaaddaef904bc8a88162892a58ef0f4983a Mon Sep 17 00:00:00 2001 From: Timo Kramer Date: Tue, 7 Apr 2026 13:58:10 +0200 Subject: [PATCH 4/7] update jline to 4.0.10 --- bb.edn | 2 +- deps.edn | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bb.edn b/bb.edn index f186945..bbb9add 100644 --- a/bb.edn +++ b/bb.edn @@ -1,7 +1,7 @@ {:paths ["src" "test"] :tasks {test:bb {:extra-deps {io.github.cognitect-labs/test-runner {:git/tag "v0.5.1" :git/sha "dfb30dd"}} - :task cognitect.test-runner/-main} + :task cognitect.test-runner/-main} format {:doc "Check formatting" :task (clojure "-M:format")} diff --git a/deps.edn b/deps.edn index 733bd22..16a119b 100644 --- a/deps.edn +++ b/deps.edn @@ -1,9 +1,9 @@ {:paths ["src"] :deps {org.clojure/clojure {:mvn/version "1.12.4"} org.clojure/core.async {:mvn/version "1.8.741"} - org.jline/jline-terminal-ffm {:mvn/version "4.0.9" + org.jline/jline-terminal-ffm {:mvn/version "4.0.10" :exclusions [org.jline/jline-native]} - org.jline/jline-reader {:mvn/version "4.0.9"}} + org.jline/jline-reader {:mvn/version "4.0.10"}} :aliases From a6d4e30719c56e1fa21494c5cb5117b800b6a775 Mon Sep 17 00:00:00 2001 From: Timo Kramer Date: Wed, 8 Apr 2026 11:44:03 +0200 Subject: [PATCH 5/7] refactor: remove *terminal* dynamic var for grapheme width JLine 4.0.10 fixed columnLength() to handle grapheme clusters without requiring a Terminal parameter (issues #1727, #1729, #1754). The dynamic var and terminal threading are no longer needed. --- docs/adr/006-grapheme-cluster-width.md | 32 ++---- src/charm/ansi/width.clj | 25 ++--- src/charm/program.clj | 133 ++++++++++++------------- src/charm/style/overlay.clj | 4 +- 4 files changed, 82 insertions(+), 112 deletions(-) diff --git a/docs/adr/006-grapheme-cluster-width.md b/docs/adr/006-grapheme-cluster-width.md index 6eb40a1..a9c7887 100644 --- a/docs/adr/006-grapheme-cluster-width.md +++ b/docs/adr/006-grapheme-cluster-width.md @@ -51,36 +51,23 @@ JLine 4.0.0 added built-in Mode 2027 support, handling the entire lifecycle: - **Probing**: `TerminalBuilder.build()` automatically sends the DECRQM query and parses the response to detect Mode 2027 support - **Enabling**: if the terminal supports it, JLine enables Mode 2027 automatically during terminal construction -- **Width calculation**: `columnLength(terminal)` and `columnSubSequence(start, end, terminal)` are new overloads that accept a `Terminal` parameter. When the terminal has Mode 2027 active, these methods use grapheme clustering (each cluster = 1 or 2 cells). When it doesn't, they fall back to per-codepoint wcwidth β€” same as JLine 3 +- **Width calculation**: `columnLength()` and `columnSubSequence()` use grapheme clustering internally - **Screen diffing**: `Display.update()` uses the terminal's grapheme mode for its internal cursor position tracking, fixing render artifacts with emoji - **Cleanup**: `AbstractTerminal.doClose()` automatically sends `CSI ?2027l` to disable the mode, so the terminal is left in a clean state -No custom implementation needed β€” JLine handles probing, enabling, width calculation, and cleanup. +Initially (JLine 4.0.0–4.0.8), the no-arg `columnLength()` still used per-codepoint wcwidth and grapheme-aware width required passing a `Terminal` instance via `columnLength(terminal)`. This was fixed in JLine 4.0.9–4.0.10 through several issues: -## Decision - -Upgrade from JLine 3.30.6 to JLine 4.x and use its built-in Mode 2027 support. - -### Threading the terminal +- **#1727**: Use grapheme cluster width instead of wcwidth when Mode 2027 is active +- **#1726 / #1729**: Improve DECRQM grapheme cluster probe robustness +- **#1753 / #1754**: Grapheme-cluster-aware width with per-category emoji detection -JLine 4's grapheme-aware `columnLength(terminal)` and `columnSubSequence(start, end, terminal)` require a `Terminal` reference to check whether Mode 2027 is active. But width functions like `string-width` and `truncate` are called deep in the call stack, inside `view` functions, inside component rendering, inside layout helpers, where passing a terminal explicitly would thread it through every function signature. +Since JLine 4.0.10, the no-arg `columnLength()` correctly handles grapheme clusters, so no `Terminal` parameter needs to be threaded through application code. -We use a dynamic var `charm.ansi.width/*terminal*` to avoid this: - -```clojure -(def ^:dynamic *terminal* nil) - -(defn column-length [^AttributedString attr-s] - (if *terminal* - (.columnLength attr-s ^Terminal *terminal*) ; grapheme-aware - (.columnLength attr-s))) ; wcwidth fallback -``` +## Decision -- `charm.program/run` binds `*terminal*` once at the top of the event loop via `binding` -- All width calculations within the event loop automatically use the grapheme-aware overload -- `column-length`, `column-sub-sequence`, `string-width`, `truncate`, `pad-right`, `pad-left` all benefit without any signature changes +Upgrade from JLine 3.30.6 to JLine 4.0.10 and use its built-in Mode 2027 support. No custom width logic or terminal threading is needed β€” JLine handles probing, enabling, width calculation, and cleanup transparently. -When `*terminal*` is unbound (unit tests, REPL usage outside a running program), the functions fall back to JLine's no-arg `columnLength()` which uses per-codepoint wcwidth. This means tests don't need a terminal, and the library degrades gracefully. +The `charm.ansi.width` namespace provides thin wrappers (`column-length`, `column-sub-sequence`) around JLine's `AttributedString` methods for type hints and int coercion, keeping call sites clean. ## Consequences @@ -89,6 +76,7 @@ When `*terminal*` is unbound (unit tests, REPL usage outside a running program), - Correct width for all emoji types on terminals that support Mode 2027 - Graceful fallback to wcwidth on terminals that don't - No custom width tables or grapheme clustering logic β€” all JLine +- No need to thread a `Terminal` reference through the call stack - JLine's `Display` also benefits from grapheme mode, fixing screen diffing artifacts ### Cons diff --git a/src/charm/ansi/width.clj b/src/charm/ansi/width.clj index 5d9b947..3260ecc 100644 --- a/src/charm/ansi/width.clj +++ b/src/charm/ansi/width.clj @@ -5,17 +5,10 @@ - ANSI escape sequences (zero width) - Wide characters (CJK, emojis = 2 cells) - Combining characters (zero width) - - Grapheme clusters (emoji sequences, via JLine 4 Mode 2027)" + - Grapheme clusters (emoji sequences)" (:import - [org.jline.terminal Terminal] [org.jline.utils AttributedString])) -(def ^:dynamic *terminal* - "Bound terminal for grapheme-cluster-aware width calculation. - When bound, JLine uses Mode 2027 grapheme clustering if the terminal - supports it. When nil, falls back to per-codepoint wcwidth." - nil) - (defn strip-ansi "Remove ANSI escape sequences from a string." [s] @@ -24,20 +17,14 @@ (.toString (AttributedString/fromAnsi s)))) (defn column-length - "Get the display width of an AttributedString. - Uses grapheme clustering when *terminal* is bound and supports Mode 2027." + "Get the display width of an AttributedString." [^AttributedString attr-s] - (if *terminal* - (.columnLength attr-s ^Terminal *terminal*) - (.columnLength attr-s))) + (.columnLength attr-s)) (defn column-sub-sequence - "Get a column-based subsequence of an AttributedString. - Uses grapheme clustering when *terminal* is bound and supports Mode 2027." + "Get a column-based subsequence of an AttributedString." [^AttributedString attr-s start end] - (if *terminal* - (.columnSubSequence attr-s (int start) (int end) ^Terminal *terminal*) - (.columnSubSequence attr-s (int start) (int end)))) + (.columnSubSequence attr-s (int start) (int end))) (defn string-width "Measure the display width of a string in terminal cells. @@ -45,7 +32,7 @@ - ANSI escape sequences have zero width - Wide characters (CJK, emojis) count as 2 cells - Combining characters count as 0 cells - - Grapheme clusters (ZWJ emoji) count as 2 cells when *terminal* is bound + - Grapheme clusters (ZWJ emoji) count as 2 cells Example: (string-width \"hello\") ; => 5 diff --git a/src/charm/program.clj b/src/charm/program.clj index 3bab2b9..c0723d5 100644 --- a/src/charm/program.clj +++ b/src/charm/program.clj @@ -8,7 +8,6 @@ Commands are functions that produce messages asynchronously." (:require - [charm.ansi.width :as w] [charm.input.handler :as input] [charm.input.keymap :as km] [charm.message :as msg] @@ -204,81 +203,77 @@ state (atom initial-state)] (try - ;; Bind terminal for grapheme-cluster-aware width calculation - ;; JLine 4 auto-detects Mode 2027 during terminal creation - (binding [w/*terminal* terminal] - - ;; Setup renderer - (render/start! renderer) - (when alt-screen - (render/enter-alt-screen! renderer)) - - ;; Setup mouse - (when mouse - (render/enable-mouse! renderer mouse)) - - ;; Setup focus reporting - (when focus-reporting - (render/enable-focus-reporting! renderer)) - - ;; Handle window resize signal - (Signals/register "WINCH" - (reify Runnable - (run [_] - (check-window-size! terminal msg-chan last-size)))) - - ;; Check initial window size - (check-window-size! terminal msg-chan last-size) - - ;; Start input loop (returns thread) - (let [^Thread input-thread (start-input-loop! terminal msg-chan running?)] - - ;; Execute init command - (execute-cmd! init-cmd msg-chan) - - ;; Render initial view - (render/render! renderer (view @state)) - - ;; Main event loop - (loop [] - (when @running? - (when-let [m (a/ Date: Tue, 31 Mar 2026 15:56:40 +0200 Subject: [PATCH 6/7] docs: emoji-width example with actual width of emojis --- docs/examples/src/examples/emoji_width.clj | 34 ++++++++++++++-------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/docs/examples/src/examples/emoji_width.clj b/docs/examples/src/examples/emoji_width.clj index 7e2ce34..cc0d024 100644 --- a/docs/examples/src/examples/emoji_width.clj +++ b/docs/examples/src/examples/emoji_width.clj @@ -3,7 +3,9 @@ Shows that ZWJ sequences, flags, and skin-tone emoji are measured correctly when JLine 4 Mode 2027 is active (see ADR 006)." - (:require [charm.core :as charm])) + (:require + [charm.core :as charm] + [charm.ansi.width :as w])) ;; --------------------------------------------------------------------------- ;; Data @@ -54,12 +56,16 @@ (defn init [] (let [tbl (charm/table [{:title "Emoji" :width 6} - {:title "Name" :width 22} - {:title "Type" :width 34}] - emoji-rows - :cursor 0 - :header-style header-style - :cursor-style cursor-style)] + {:title "Name" :width 22} + {:title "Type" :width 34} + {:title "Expect" :width 6} + {:title "Actual" :width 6}] + (mapv (fn [[emoji name desc]] + [emoji name desc "2" "-"]) + emoji-rows) + :cursor 0 + :header-style header-style + :cursor-style cursor-style)] [tbl nil])) (defn update-fn [tbl msg] @@ -72,11 +78,15 @@ (charm/table-update tbl msg))) (defn view [tbl] - (str (charm/render title-style "Grapheme Cluster Width Demo") "\n" - (charm/render subtitle-style "Emoji should align neatly if Mode 2027 is active") "\n\n" - (charm/render box-style (charm/table-view tbl {:separator " β”‚ "})) - "\n\n" - "j/k navigate q quit")) + (let [rows (mapv (fn [[emoji name desc]] + [emoji name desc "2" (str (w/string-width emoji))]) + emoji-rows) + tbl (assoc tbl :rows rows)] + (str (charm/render title-style "Grapheme Cluster Width Demo") "\n" + (charm/render subtitle-style "Emoji should align neatly if Mode 2027 is active") "\n\n" + (charm/render box-style (charm/table-view tbl {:separator " β”‚ "})) + "\n\n" + "j/k navigate q quit"))) ;; --------------------------------------------------------------------------- ;; Main From f4757548ca1eb7341d6b71b7bd65327032dc344b Mon Sep 17 00:00:00 2001 From: Timo Kramer Date: Tue, 31 Mar 2026 17:16:03 +0200 Subject: [PATCH 7/7] chore: update deps --- deps.edn | 42 ++++++++++++++++++++---------------------- 1 file changed, 20 insertions(+), 22 deletions(-) diff --git a/deps.edn b/deps.edn index 16a119b..5cf609f 100644 --- a/deps.edn +++ b/deps.edn @@ -1,31 +1,29 @@ {:paths ["src"] :deps {org.clojure/clojure {:mvn/version "1.12.4"} - org.clojure/core.async {:mvn/version "1.8.741"} - org.jline/jline-terminal-ffm {:mvn/version "4.0.10" - :exclusions [org.jline/jline-native]} - org.jline/jline-reader {:mvn/version "4.0.10"}} + org.clojure/core.async {:mvn/version "1.8.741"} + org.jline/jline-terminal-ffm {:mvn/version "4.0.10" + :exclusions [org.jline/jline-native]} + org.jline/jline-reader {:mvn/version "4.0.10"}} - :aliases + :aliases {:test {:extra-paths ["test"] + :extra-deps {io.github.cognitect-labs/test-runner {:git/tag "v0.5.1" :git/sha "dfb30dd"}} + :main-opts ["-m" "cognitect.test-runner"] + :jvm-opts ["--enable-native-access=ALL-UNNAMED"]} - {:test {:extra-paths ["test"] - :extra-deps {io.github.cognitect-labs/test-runner {:git/tag "v0.5.1" :git/sha "dfb30dd"}} - :main-opts ["-m" "cognitect.test-runner"] - :jvm-opts ["--enable-native-access=ALL-UNNAMED"]} + :repl {:extra-deps {nrepl/nrepl {:mvn/version "1.6.0"} + cider/cider-nrepl {:mvn/version "0.58.0"}} + :main-opts ["-m" "nrepl.cmdline" "--middleware" "[cider.nrepl/cider-middleware]"]} - :repl {:extra-deps {nrepl/nrepl {:mvn/version "1.1.1"} - cider/cider-nrepl {:mvn/version "0.47.1"}} - :main-opts ["-m" "nrepl.cmdline" "--middleware" "[cider.nrepl/cider-middleware]"]} + :examples {:extra-paths ["docs"]} - :examples {:extra-paths ["docs"]} + :user {:extra-paths ["user"]} - :user {:extra-paths ["user"]} + :build {:deps {io.github.clojure/tools.build {:mvn/version "0.10.7"} + slipset/deps-deploy {:mvn/version "0.2.2"}} + :ns-default build} - :build {:deps {io.github.clojure/tools.build {:mvn/version "0.10.7"} - slipset/deps-deploy {:mvn/version "0.2.2"}} - :ns-default build} + :format {:extra-deps {dev.weavejester/cljfmt {:mvn/version "0.16.3"}} + :main-opts ["-m" "cljfmt.main" "check" "src" "test"]} - :format {:extra-deps {dev.weavejester/cljfmt {:mvn/version "0.16.0"}} - :main-opts ["-m" "cljfmt.main" "check" "src" "test"]} - - :lint {:extra-deps {clj-kondo/clj-kondo {:mvn/version "2026.01.19"}} - :main-opts ["-m" "clj-kondo.main" "--fail-level" "error" "--lint" "src" "test"]}}} + :lint {:extra-deps {clj-kondo/clj-kondo {:mvn/version "2026.01.19"}} + :main-opts ["-m" "clj-kondo.main" "--fail-level" "error" "--lint" "src" "test"]}}}