Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion bb.edn
Original file line number Diff line number Diff line change
@@ -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")}
Expand Down
42 changes: 20 additions & 22 deletions deps.edn
Original file line number Diff line number Diff line change
@@ -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 "3.30.6"
:exclusions [org.jline/jline-native]}
org.jline/jline-reader {:mvn/version "3.30.6"}}
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"]}}}
File renamed without changes.
85 changes: 85 additions & 0 deletions docs/adr/006-grapheme-cluster-width.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# ADR 006: Grapheme Cluster Width via JLine 4 Mode 2027

## Status

Accepted

## Context

### 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. 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, 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()` 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

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:

- **#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

Since JLine 4.0.10, the no-arg `columnLength()` correctly handles grapheme clusters, so no `Terminal` parameter needs to be threaded through application code.

## Decision

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.

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

### 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
- No need to thread a `Terminal` reference through the call stack
- 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)
3 changes: 3 additions & 0 deletions docs/examples/bb.edn
Original file line number Diff line number Diff line change
Expand Up @@ -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)}

Expand Down
99 changes: 99 additions & 0 deletions docs/examples/src/examples/emoji_width.clj
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
(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]
[charm.ansi.width :as w]))

;; ---------------------------------------------------------------------------
;; 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}
{: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]
(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]
(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
;; ---------------------------------------------------------------------------

(defn -main [& _args]
(charm/run {:init init
:update update-fn
:view view
:alt-screen true}))
17 changes: 14 additions & 3 deletions src/charm/ansi/width.clj
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,23 @@
""
(.toString (AttributedString/fromAnsi s))))

(defn column-length
"Get the display width of an AttributedString."
[^AttributedString attr-s]
(.columnLength attr-s))

(defn column-sub-sequence
"Get a column-based subsequence of an AttributedString."
[^AttributedString attr-s start end]
(.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

Example:
(string-width \"hello\") ; => 5
Expand All @@ -30,7 +41,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.
Expand All @@ -47,13 +58,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."
Expand Down
7 changes: 5 additions & 2 deletions src/charm/input/handler.clj
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
[charm.input.mouse :as mouse]
[clojure.string :as str])
(:import
[java.io IOException]
[org.jline.keymap KeyMap]
[org.jline.terminal Terminal]
[org.jline.utils NonBlockingReader]))
Expand All @@ -19,9 +20,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 IOException _ -1)))

(defn- read-char-blocking
"Read a single character from the terminal, blocking."
Expand Down
13 changes: 5 additions & 8 deletions src/charm/program.clj
Original file line number Diff line number Diff line change
Expand Up @@ -237,29 +237,26 @@
;; 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 [_ (a/<!! (a/timeout 10))]
;; Timeout - just continue
;; Timeout - just continue
nil)

(when-let [m (a/poll! msg-chan)]
(cond
;; Quit message
;; Quit message
(msg/quit? m)
(reset! running? false)

;; Error message
;; Error message
(= :error (:type m))
(do
(reset! running? false)
(throw (:error m)))

;; Window size
;; Window size
(= :window-size (:type m))
(do
(render/update-size! renderer (:width m) (:height m))
Expand All @@ -268,7 +265,7 @@
(execute-cmd! cmd msg-chan)
(render/render! renderer (view new-state))))

;; Regular message
;; Regular message
:else
(let [[new-state cmd] (update @state m)]
(reset! state new-state)
Expand Down
4 changes: 2 additions & 2 deletions src/charm/style/overlay.clj
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
;; Part before overlay
(when (pos? x)
(let [before-width (min x base-width)]
(.append builder (.columnSubSequence base-attr 0 before-width))
(.append builder (w/column-sub-sequence base-attr 0 before-width))
;; Pad if base is shorter than x
(when (< base-width x)
(.append builder (apply str (repeat (- x base-width) " "))))))
Expand All @@ -54,7 +54,7 @@
;; Part after overlay
(let [after-start (+ x overlay-width)]
(when (< after-start base-width)
(.append builder (.columnSubSequence base-attr after-start base-width))))
(.append builder (w/column-sub-sequence base-attr after-start base-width))))
(.toAnsi (.toAttributedString builder))))

;; ---------------------------------------------------------------------------
Expand Down
Loading
Loading