abstracttui 0.6.0

A reactive, compositor-grade terminal UI engine: fine-grained signals, layered rendering with damage tracking, images (kitty/iTerm2/sixel/mosaic), software-rasterized 3D (GLB), themes and animation.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
//! Text measurement and wrapping over extended grapheme clusters.
//!
//! One width policy, shared by everything. The classic terminal-corruption
//! source is measurement disagreeing with rendering (a widget measures a
//! cluster as 1 column, the surface stores it as 2, the diff emits it and
//! the terminal shifts the rest of the row). `cluster_width` is therefore
//! the *only* width authority in the engine: `Surface::draw_text`, the
//! glyph cache in `render::cell`, wrapping and truncation all consult it.
//!
//! Policy (documented in docs/design/render.md §2.5):
//! - Control clusters (first scalar is a C0/C1 control or DEL) are width 0
//!   and are stripped by drawing/wrapping code, never rendered.
//! - Clusters whose scalar widths sum to 0 (lone combining marks, ZWSP,
//!   a stray variation selector) are width 0: invisible, zero columns.
//! - A cluster containing VS16 (U+FE0F, emoji presentation) is width 2:
//!   modern terminals render emoji presentation sequences double-wide even
//!   when the base character is narrow (e.g. "❤️", keycap sequences).
//! - Everything else is the unicode-width sum capped at 2. The cap folds
//!   multi-scalar clusters (ZWJ families, skin-tone modifiers) whose parts
//!   sum past 2 into the double cell a terminal actually uses. VS15 (text
//!   presentation) is deliberately *not* forced narrow: wide bases keep
//!   their East Asian width, matching the majority of terminals which
//!   ignore VS15 for width purposes.

pub mod data;
pub mod diff;
pub mod highlight;
mod truncate;
mod wrap;

pub use data::{DataKind, JsonLexer, YamlLexer};
pub use diff::{DiffKind, DiffLexer};
pub use highlight::{CLikeLexer, Highlighter, TokenKind};
pub use truncate::truncate_ellipsis;
pub use wrap::wrap;

use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;

/// U+FE0F VARIATION SELECTOR-16: requests emoji presentation.
const VS16: char = '\u{FE0F}';

/// True when the cluster starts with a control scalar (C0, DEL, C1).
/// Grapheme segmentation keeps "\r\n" as one cluster, so checking the
/// first scalar classifies the whole cluster.
pub(crate) fn is_control_cluster(cluster: &str) -> bool {
    cluster.chars().next().is_some_and(char::is_control)
}

/// Display width in terminal columns of one grapheme cluster: 0, 1 or 2.
pub fn cluster_width(cluster: &str) -> i32 {
    if cluster.is_empty() || is_control_cluster(cluster) {
        return 0;
    }
    let base = UnicodeWidthStr::width(cluster) as i32;
    if base <= 0 {
        return 0;
    }
    if cluster.contains(VS16) {
        return 2;
    }
    base.min(2)
}

/// Display width of a string: the sum of its cluster widths. Control
/// clusters (including newlines) measure 0 — wrap first, then measure lines.
pub fn width(s: &str) -> i32 {
    s.graphemes(true).map(cluster_width).sum()
}

/// U+200D ZERO WIDTH JOINER.
const ZWJ: char = '\u{200D}';

/// True when real terminals may disagree with [`cluster_width`] about this
/// cluster (RT1-7): emoji presentation (VS16) and ZWJ sequences render at
/// different widths across terminals (xterm splits families into
/// components; kitty/wezterm render 2), and East-Asian-Ambiguous characters
/// render double-wide under legacy CJK configurations or emoji-font
/// fallback. The presenter invalidates its virtual cursor after emitting
/// one of these, so any width disagreement is confined to the risky
/// cluster itself instead of shifting every glyph after it (the classic
/// mystery-smear).
///
/// Deliberate exception: the TUI-structural blocks U+2500..=U+25FF (box
/// drawing, block elements, geometric shapes) are NOT risky even though
/// UAX #11 classes much of them Ambiguous. Two reasons, both load-bearing:
/// (1) they are the fabric of terminal chrome — flagging them would emit
/// an absolute CUP after nearly every border cell, blowing the byte budget
/// exactly where output is densest; (2) they are native monospace-font
/// glyphs, not subject to the emoji-font fallback that actually widens
/// ambiguous symbols in practice — and a terminal configured ambiguous-wide
/// breaks the CELL LAYOUT of every TUI regardless, which no cursor
/// re-anchoring can repair. Documented in docs/design/render.md §2.4.
///
/// Pure-ASCII clusters are never risky — the fast path costs one scan of
/// bytes the emitter already touched.
///
/// Crate-private [C8 freeze]: this is the presenter's cursor-defense
/// heuristic, not a user-facing width oracle — `width`/`cluster_width`
/// are the public truth; exposing the defense would invite callers to
/// second-guess it.
pub(crate) fn is_risky_cluster(cluster: &str) -> bool {
    if cluster.is_ascii() {
        return false;
    }
    cluster
        .chars()
        .any(|c| c == VS16 || c == ZWJ || is_risky_ambiguous(c))
}

/// East Asian Ambiguous detection without a second width table: the two
/// unicode-width opinions differ exactly on the ambiguous set. The
/// TUI-structural ranges are carved out (see [`is_risky_cluster`]).
fn is_risky_ambiguous(c: char) -> bool {
    use unicode_width::UnicodeWidthChar;
    if ('\u{2500}'..='\u{25FF}').contains(&c) {
        return false;
    }
    c.width() != c.width_cjk()
}

/// One measured grapheme cluster of a string: byte range + display width.
/// The cursor-math currency for input fields (REACT consumes it):
/// caret positions are cluster boundaries, and column <-> byte-offset
/// conversions fold over these segments.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct Segment<'a> {
    /// The cluster text (borrowing the input).
    pub cluster: &'a str,
    /// Byte offset of the cluster within the measured string.
    pub offset: usize,
    /// Display columns (0 for control/zero-width clusters, 1 or 2
    /// otherwise) — the same policy as [`cluster_width`], by construction.
    pub width: i32,
}

/// Iterates `s` as measured grapheme clusters. Nothing is skipped —
/// control clusters appear with width 0 so byte offsets stay exhaustive
/// (an input-field caret must be able to sit after ANY byte boundary the
/// user can reach); rendering paths keep stripping controls themselves.
pub fn segments(s: &str) -> impl Iterator<Item = Segment<'_>> {
    s.grapheme_indices(true).map(|(offset, cluster)| Segment {
        cluster,
        offset,
        width: cluster_width(cluster),
    })
}

/// The cluster boundary strictly after `byte_idx` (caret "step right").
/// Out-of-range or non-boundary inputs behave like the caret they imply:
/// mid-cluster snaps to that cluster's END, `byte_idx >= s.len()` stays
/// at `s.len()`.
pub fn next_boundary(s: &str, byte_idx: usize) -> usize {
    for seg in segments(s) {
        let end = seg.offset + seg.cluster.len();
        if end > byte_idx {
            return end;
        }
    }
    s.len()
}

/// The cluster boundary strictly before `byte_idx` (caret "step left" /
/// backspace target: deleting `prev_boundary(s, i)..i` removes exactly
/// one whole cluster). Mid-cluster inputs snap to that cluster's START;
/// `byte_idx == 0` stays 0.
pub fn prev_boundary(s: &str, byte_idx: usize) -> usize {
    let mut prev = 0;
    for seg in segments(s) {
        if seg.offset >= byte_idx {
            return prev;
        }
        prev = seg.offset;
    }
    prev
}

/// Wrapping window used when the caller passes a non-positive available
/// width ("unconstrained"). Large enough that no real content wraps, small
/// enough that width accumulation can never overflow `i32`.
const UNBOUNDED_WIDTH: i32 = 1 << 24;

/// Wrapping-aware measurement for layout leaves (REACT request 4): the
/// size `s` needs when wrapped into `avail.w` columns.
///
/// - `avail.w <= 0` means "unconstrained": logical lines measure at their
///   natural width.
/// - The returned width can exceed `avail.w` only when a single cluster is
///   wider than the whole window (a CJK glyph at width 1) — truth over
///   comfort; the layout clips.
/// - Height is the wrapped line count, NOT clamped to `avail.h`: the
///   solver decides how much to show. Empty input still occupies one line
///   (a text leaf is never zero-height).
///
/// **`avail.h` is not read, and every `avail.w <= 0` is the same query.**
/// Both are load-bearing for the per-leaf `WidthMemo` in `ui::mount`, which caches this
/// answer per text leaf: the first says the memo may key on width alone,
/// the second says it must fold the non-positive widths together or keep
/// one entry per meaningless distinction. Verified over a 13-string
/// corpus at 12 widths x 6 heights in
/// `measure_ignores_available_height_and_folds_every_unconstrained_width`.
pub fn measure(s: &str, avail: crate::base::Size) -> crate::base::Size {
    #[cfg(test)]
    count_measure_call();
    let window = if avail.w <= 0 {
        UNBOUNDED_WIDTH
    } else {
        avail.w
    };
    let lines = wrap(s, window);
    let w = lines.iter().map(|l| width(l)).max().unwrap_or(0);
    crate::base::Size::new(w, lines.len() as i32)
}

// How many times `measure` has actually run on this thread — the
// instrument the memo guards assert on.
//
// Test builds only, and thread-local on purpose: the counter is
// per-test, so guards asserting exact call counts do not interfere when
// the harness runs them in parallel.
#[cfg(test)]
thread_local! {
    static MEASURE_CALLS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
}

#[cfg(test)]
fn count_measure_call() {
    MEASURE_CALLS.with(|c| c.set(c.get() + 1));
}

/// Read the per-thread [`measure`] call count.
#[cfg(test)]
pub(crate) fn measure_calls() -> u64 {
    MEASURE_CALLS.with(|c| c.get())
}

/// Zero the per-thread [`measure`] call count.
#[cfg(test)]
pub(crate) fn reset_measure_calls() {
    MEASURE_CALLS.with(|c| c.set(0));
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Cycle-7 measurement (integrator ask 5): is grapheme segmentation
    /// worth caching for per-keystroke caret math? Run explicitly:
    /// `cargo test --release --lib text::tests::profile -- --ignored --nocapture`
    /// Measured (release, this box): 1.88 µs per segments() walk of a
    /// mixed 76-byte line, 6.3 µs per measure() of a wrapped paragraph —
    /// hundreds of keystrokes per millisecond of budget, and caret math
    /// runs ONCE per keystroke, not per cell. A cache (with eviction,
    /// invalidation on edit, per-widget ownership) would cost more in
    /// complexity than it saves; declined until a profile shows
    /// segmentation in a real hot path.
    #[test]
    #[ignore = "perf profile: report-only, run explicitly with --nocapture"]
    fn profile_segments_and_measure_per_keystroke_cost() {
        use std::time::Instant;
        let line = "let value = compute(width, \"héllo 世界 👍🏽 done\"); // trailing comment...";
        let para = "The quick brown fox 跳过 the lazy dog 🐕 while counting 一二三 clusters.";
        const N: u32 = 20_000;
        let s = Instant::now();
        let mut sink = 0usize;
        for _ in 0..N {
            sink += segments(line).map(|seg| seg.width as usize).sum::<usize>();
        }
        let seg_us = s.elapsed().as_secs_f64() * 1e6 / N as f64;
        let s = Instant::now();
        for _ in 0..N {
            sink += measure(para, crate::base::Size::new(40, 0)).h as usize;
        }
        let meas_us = s.elapsed().as_secs_f64() * 1e6 / N as f64;
        eprintln!("segments(120-col line): {seg_us:.2} us/call; measure(paragraph @40): {meas_us:.2} us/call; sink {sink}");
    }

    #[test]
    fn ascii_and_cjk() {
        assert_eq!(width("hello"), 5);
        assert_eq!(width("世界"), 4);
        assert_eq!(width("aé"), 2); // combining accent folds into the base
        assert_eq!(width(""), 0);
    }

    #[test]
    fn emoji_presentation_is_wide() {
        assert_eq!(cluster_width("❤\u{FE0F}"), 2); // narrow base + VS16
        assert_eq!(cluster_width("1\u{FE0F}\u{20E3}"), 2); // keycap
        assert_eq!(cluster_width("👍"), 2);
        // ZWJ family: parts sum past 2, cap folds to one double cell.
        assert_eq!(cluster_width("👨\u{200D}👩\u{200D}👧\u{200D}👦"), 2);
        // Skin tone modifier: base 2 + modifier 2, capped.
        assert_eq!(cluster_width("👍🏽"), 2);
    }

    #[test]
    fn controls_and_zero_width_measure_zero() {
        assert_eq!(cluster_width("\t"), 0);
        assert_eq!(cluster_width("\r\n"), 0);
        assert_eq!(cluster_width("\u{200B}"), 0); // ZWSP
        assert_eq!(cluster_width("\u{FE0F}"), 0); // lone VS16: no visible base
        assert_eq!(width("a\nb"), 2);
    }

    #[test]
    fn zwj_between_letters_does_not_inflate() {
        // "a" + ZWJ forms one cluster with "a"; it must stay width 1.
        assert_eq!(width("a\u{200D}b"), 2);
    }

    #[test]
    fn risky_cluster_classification() {
        assert!(!is_risky_cluster("a"));
        assert!(!is_risky_cluster(" "));
        assert!(!is_risky_cluster("世"), "plain CJK is unambiguous wide");
        assert!(!is_risky_cluster("é"), "combining accent is settled narrow");
        assert!(is_risky_cluster("❤\u{FE0F}"), "VS16 presentation");
        assert!(is_risky_cluster("👨\u{200D}👩\u{200D}👧"), "ZWJ sequence");
        // Ambiguous-width symbols (unicode-width's two opinions differ).
        assert!(is_risky_cluster("§"));
        assert!(is_risky_cluster("°"));
        assert!(is_risky_cluster("☆"));
        // TUI-structural carve-out: chrome glyphs must stay cheap.
        assert!(!is_risky_cluster("─"), "box drawing excluded by design");
        assert!(!is_risky_cluster("│"));
        assert!(!is_risky_cluster("█"), "block elements excluded");
        assert!(!is_risky_cluster("▲"), "geometric shapes excluded");
    }

    #[test]
    fn segments_cover_every_byte_with_widths() {
        let s = "a世\t👍🏽é";
        let segs: Vec<_> = segments(s).collect();
        // Exhaustive coverage: offsets tile the string.
        let mut expected_offset = 0;
        for seg in &segs {
            assert_eq!(seg.offset, expected_offset);
            expected_offset += seg.cluster.len();
        }
        assert_eq!(expected_offset, s.len());
        let widths: Vec<i32> = segs.iter().map(|s| s.width).collect();
        assert_eq!(widths, vec![1, 2, 0, 2, 1], "control kept at width 0");
        // Widths agree with the one policy by construction.
        assert_eq!(widths.iter().sum::<i32>(), width(s));
        assert_eq!(segments("").count(), 0);
    }

    #[test]
    fn boundaries_step_whole_clusters() {
        let s = "a👍🏽b"; // 'a' 1B, thumbs+tone 8B, 'b' 1B
        assert_eq!(next_boundary(s, 0), 1);
        assert_eq!(
            next_boundary(s, 1),
            9,
            "steps over the whole ZWJ-ish cluster"
        );
        assert_eq!(next_boundary(s, 9), 10);
        assert_eq!(next_boundary(s, 10), 10, "clamped at the end");
        assert_eq!(prev_boundary(s, 10), 9);
        assert_eq!(
            prev_boundary(s, 9),
            1,
            "backspace target is the cluster start"
        );
        assert_eq!(prev_boundary(s, 1), 0);
        assert_eq!(prev_boundary(s, 0), 0, "floored at 0");
    }

    #[test]
    fn boundaries_snap_mid_cluster_inputs() {
        let s = "x👍🏽y";
        // Byte 3 sits inside the emoji cluster (1..9).
        assert_eq!(next_boundary(s, 3), 9, "mid-cluster snaps to cluster end");
        assert_eq!(prev_boundary(s, 3), 1, "mid-cluster snaps to cluster start");
        // Past-the-end input behaves like a caret at the end.
        assert_eq!(prev_boundary(s, 400), 9);
        assert_eq!(next_boundary(s, 400), s.len());
        // Empty string is inert.
        assert_eq!(next_boundary("", 0), 0);
        assert_eq!(prev_boundary("", 0), 0);
        // Backspace deletes exactly one cluster: the RT3-2 shape.
        let caret = s.len();
        let cut = prev_boundary(s, caret);
        let mut owned = s.to_string();
        owned.replace_range(cut..caret, "");
        assert_eq!(owned, "x👍🏽");
    }

    #[test]
    fn measure_wraps_and_measures() {
        use crate::base::Size;
        assert_eq!(measure("hello", Size::new(10, 5)), Size::new(5, 1));
        assert_eq!(
            measure("the quick brown fox", Size::new(10, 5)),
            Size::new(9, 2)
        );
        // Unconstrained: natural line widths.
        assert_eq!(measure("ab\ncdef", Size::new(0, 0)), Size::new(4, 2));
        // Wide glyphs measured in columns, wrapped by columns.
        assert_eq!(measure("世界人", Size::new(4, 9)), Size::new(4, 2));
        // Empty text still occupies one line.
        assert_eq!(measure("", Size::new(10, 5)), Size::new(0, 1));
        // Oversized single cluster: honest overflow, height not clamped.
        assert_eq!(measure("界", Size::new(1, 1)), Size::new(2, 1));
    }
}