abstracttui 0.3.7

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
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
//! Document-vocabulary markdown: the core [`Block`] set extended with
//! GFM tables (0142), block images (0144) and task-list items — parsed
//! by [`parse_doc`] into [`DocBlock`].
//!
//! WHY A SECOND ENUM: `Block` shipped in 0.2.x as an exhaustive public
//! enum; adding variants would break every downstream exhaustive match
//! (semver). `DocBlock` is `#[non_exhaustive]` from birth, wraps the
//! core vocabulary verbatim in [`DocBlock::Core`], and is where new
//! block kinds land from now on. For sources containing none of the
//! extended constructs, `parse_doc` is exactly `parse` wrapped in
//! `Core` (test-pinned).
//!
//! ## The honest subset (exactly)
//!
//! - TABLE: a plain-text line containing at least one unescaped `|`
//!   (the header), immediately followed by a delimiter row (cells of
//!   `:?-+:?` separated by `|`, at least one unescaped `|`, same cell
//!   count as the header). Body rows are the following plain-text lines
//!   containing an unescaped `|`; the first line without one (blank,
//!   block start, or plain prose) CLOSES the table. `\|` inside a cell
//!   is a literal pipe. Extra body cells are dropped, missing ones pad
//!   empty (GFM behavior). Deviations from full GFM, deliberately:
//!   body rows REQUIRE a pipe (GFM would absorb pipe-less lines as
//!   one-cell rows — hostile to streaming cut-safety), and tables
//!   inside lists/quotes are not recognized.
//! - IMAGE: a line that is exactly `![alt](src)` (whole line, after
//!   trim) becomes [`ImageBlock`]. Inline images inside paragraphs stay
//!   literal text, as in the core parser. Empty `src` stays literal.
//! - TASK: `- [ ] text` / `- [x] text` (any core bullet, `x` or `X`)
//!   becomes [`TaskBlock`]. Numbered task items are not recognized
//!   (they stay ordered list items).
//!
//! ## Streaming open/close semantics (the 0142 contract)
//!
//! A table OPENS when its header line and delimiter line are both
//! complete, accumulates a row per complete pipe line, and CLOSES at
//! the first non-pipe line (or end of input — EOF closes an open
//! table with the rows received, mirroring the fence recovery rule).
//! [`DocStreamSession`](super::DocStreamSession) seals nothing from
//! the header line onward while the table is open or while a header
//! CANDIDATE (a complete pipe line whose successor has not arrived)
//! is unresolved — a cut between header and delimiter, or between two
//! body rows, would split one table into text + a smaller table and
//! break streamed-vs-batch equivalence. Images and tasks are
//! single-line blocks: complete line = closed block.

use crate::render::rich::RichLine;

use super::{find_close, list_marker, parse, parse_inline, Block, Marker, MdStyles};

/// Per-column alignment from the delimiter row (`:--` left, `:-:`
/// center, `--:` right; bare `---` defaults left). Exhaustive on
/// purpose: the alignment vocabulary is complete.
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
pub enum CellAlign {
    /// `---` or `:--`.
    #[default]
    Left,
    /// `:-:`.
    Center,
    /// `--:`.
    Right,
}

/// A parsed GFM table. Invariant (enforced by [`TableBlock::new`]):
/// `header.len() == align.len() == declared.len()`, and every row in
/// `rows` has exactly `align.len()` cells. Cell content carries inline
/// styles (bold/code/links) like any other line.
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub struct TableBlock {
    /// Per-column alignment, from the delimiter row.
    pub align: Vec<CellAlign>,
    /// Was the alignment WRITTEN (`:--`/`:-:`/`--:`) or defaulted
    /// (bare `---`)? `CellAlign` is exhaustive-by-contract and folds
    /// both spellings of left into [`CellAlign::Left`], so this bit
    /// carries the author's intent beside it (wave 13): typesetters
    /// may auto-right-align NUMERIC columns only where nothing was
    /// declared — an explicit `:--` is never overridden.
    /// [`TableBlock::new`] derives it (`Left` = undeclared);
    /// [`parse_doc`] records the delimiter's truth.
    pub declared: Vec<bool>,
    /// Header cells (one per column).
    pub header: Vec<RichLine>,
    /// Body rows, each padded/truncated to the column count.
    pub rows: Vec<Vec<RichLine>>,
}

impl TableBlock {
    /// Builds a table, normalizing `header` and every row to
    /// `align.len()` cells (missing cells pad empty, extra cells drop —
    /// the GFM row rule, applied uniformly so consumers never index
    /// out of bounds). `declared` derives from the alignments (`Left`
    /// counts as undeclared — the bare-`---` reading); use
    /// [`TableBlock::with_declared`] to state explicit-left columns.
    pub fn new(align: Vec<CellAlign>, header: Vec<RichLine>, rows: Vec<Vec<RichLine>>) -> Self {
        let n = align.len();
        let fit = |mut cells: Vec<RichLine>| -> Vec<RichLine> {
            cells.truncate(n);
            while cells.len() < n {
                cells.push(RichLine::new());
            }
            cells
        };
        let declared = align.iter().map(|a| *a != CellAlign::Left).collect();
        TableBlock {
            align,
            declared,
            header: fit(header),
            rows: rows.into_iter().map(fit).collect(),
        }
    }

    /// Replaces the declared-alignment bits (padded/truncated to the
    /// column count; missing entries count as undeclared).
    pub fn with_declared(mut self, mut declared: Vec<bool>) -> Self {
        declared.truncate(self.align.len());
        declared.resize(self.align.len(), false);
        self.declared = declared;
        self
    }

    /// Column count.
    pub fn columns(&self) -> usize {
        self.align.len()
    }
}

/// A block-level image reference (`![alt](src)` alone on its line).
/// The parser carries the reference only — decoding is the consumer's
/// move (widgets decode lazily on first draw, 0144).
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub struct ImageBlock {
    /// Alt text (may be empty) — the caption and the decode-failure
    /// fallback, verbatim from the source.
    pub alt: String,
    /// The image source as written (a path for file-backed readers).
    pub src: String,
}

impl ImageBlock {
    pub fn new(alt: impl Into<String>, src: impl Into<String>) -> Self {
        ImageBlock {
            alt: alt.into(),
            src: src.into(),
        }
    }
}

/// A task-list item (`- [ ]` / `- [x]`).
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub struct TaskBlock {
    /// `[x]` / `[X]` checked, `[ ]` not.
    pub checked: bool,
    /// Nesting depth from 2-space indent steps (0 = top level), same
    /// rule as [`Block::ListItem`].
    pub depth: u8,
    /// Item text with inline styles applied.
    pub content: RichLine,
}

impl TaskBlock {
    pub fn new(checked: bool, depth: u8, content: RichLine) -> Self {
        TaskBlock {
            checked,
            depth,
            content,
        }
    }
}

/// One parsed document block: the core vocabulary plus the extended
/// kinds. `#[non_exhaustive]` — future block kinds are additive here;
/// always keep a wildcard arm.
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub enum DocBlock {
    /// The core vocabulary (headings, paragraphs, lists, quotes,
    /// fences, rules), unchanged.
    Core(Block),
    /// A GFM table (0142).
    Table(TableBlock),
    /// A block image (0144).
    Image(ImageBlock),
    /// A task-list item.
    Task(TaskBlock),
}

/// Parses a document into the extended vocabulary. Every input parses —
/// degradation is always "treat as core markdown", never an error.
/// Sources without tables/images/tasks yield exactly
/// `parse(src).map(DocBlock::Core)` (test-pinned).
///
/// ```
/// use abstracttui::render::md::{self, DocBlock, MdStyles};
///
/// let styles = MdStyles::default();
/// let blocks = md::parse_doc("| a | b |\n|---|--:|\n| 1 | 2 |", &styles);
/// assert!(matches!(blocks[0], DocBlock::Table(_)));
/// ```
pub fn parse_doc(src: &str, styles: &MdStyles) -> Vec<DocBlock> {
    let lines: Vec<&str> = src.lines().collect();
    let mut out: Vec<DocBlock> = Vec::new();
    // Pending core-source lines, flushed through `parse` at extended
    // block boundaries: ONE core parser, never a re-implementation.
    let mut core: Vec<&str> = Vec::new();
    let mut in_fence = false;
    let mut i = 0;

    let flush = |core: &mut Vec<&str>, out: &mut Vec<DocBlock>, styles: &MdStyles| {
        if core.is_empty() {
            return;
        }
        let seg = core.join("\n");
        out.extend(parse(&seg, styles).into_iter().map(DocBlock::Core));
        core.clear();
    };

    while i < lines.len() {
        let raw = lines[i];
        let trimmed = raw.trim_end().trim_start();
        if in_fence {
            core.push(raw);
            if trimmed.starts_with("```") {
                in_fence = false;
            }
            i += 1;
            continue;
        }
        match doc_line_class(raw) {
            DocLineClass::FenceOpen => {
                in_fence = true;
                core.push(raw);
                i += 1;
            }
            DocLineClass::Image => {
                // Class guarantees the shape; parse is infallible here.
                let (alt, src_ref) = image_line(trimmed).expect("classified image line");
                flush(&mut core, &mut out, styles);
                out.push(DocBlock::Image(ImageBlock::new(alt, src_ref)));
                i += 1;
            }
            DocLineClass::Task => {
                let line = raw.trim_end();
                let indent = line.len() - line.trim_start().len();
                let (checked, rest) = task_item(trimmed).expect("classified task line");
                flush(&mut core, &mut out, styles);
                out.push(DocBlock::Task(TaskBlock::new(
                    checked,
                    (indent / 2).min(8) as u8,
                    parse_inline(rest, styles, styles.base),
                )));
                i += 1;
            }
            DocLineClass::PipeText
                if lines
                    .get(i + 1)
                    .is_some_and(|next| table_opens(trimmed, next)) =>
            {
                flush(&mut core, &mut out, styles);
                let spec = delimiter_alignments(lines[i + 1].trim_end().trim_start())
                    .expect("table_opens verified the delimiter");
                let (align, declared): (Vec<CellAlign>, Vec<bool>) = spec.into_iter().unzip();
                let header = split_row_cells(trimmed)
                    .into_iter()
                    .map(|c| parse_inline(&c, styles, styles.base))
                    .collect();
                i += 2; // header + delimiter consumed
                let mut rows = Vec::new();
                while i < lines.len() && doc_line_class(lines[i]) == DocLineClass::PipeText {
                    rows.push(
                        split_row_cells(lines[i].trim_end().trim_start())
                            .into_iter()
                            .map(|c| parse_inline(&c, styles, styles.base))
                            .collect(),
                    );
                    i += 1;
                }
                out.push(DocBlock::Table(
                    TableBlock::new(align, header, rows).with_declared(declared),
                ));
            }
            // Pipe line without a delimiter next, or any core line:
            // core markdown.
            _ => {
                core.push(raw);
                i += 1;
            }
        }
    }
    flush(&mut core, &mut out, styles);
    out
}

/// Line classification for the DOC dispatch — shared verbatim between
/// [`parse_doc`] and the streaming seal (`DocStreamSession`), so batch
/// and stream can never disagree on what a line is. Order matters and
/// mirrors the dispatch: fence, image, task, core boundary, pipe text,
/// plain text.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub(super) enum DocLineClass {
    /// Opens a fenced code block.
    FenceOpen,
    /// A whole-line `![alt](src)` image block.
    Image,
    /// A `- [ ]`/`- [x]` task item.
    Task,
    /// Blank / heading / rule / quote / list: closes paragraphs and
    /// tables, never joins either.
    Boundary,
    /// Plain text carrying at least one unescaped `|` — a table header
    /// candidate, delimiter, or body row (context decides).
    PipeText,
    /// Plain text: opens or continues a paragraph.
    ParaText,
}

pub(super) fn doc_line_class(raw: &str) -> DocLineClass {
    let trimmed = raw.trim_end().trim_start();
    // Refine the CORE classifier (one boundary rule set, no drift):
    // task items are core list lines; block images and pipe lines are
    // core paragraph text.
    match super::stream::line_class(raw) {
        super::stream::LineClass::FenceOpen => DocLineClass::FenceOpen,
        super::stream::LineClass::Boundary => {
            if task_item(trimmed).is_some() {
                DocLineClass::Task
            } else {
                DocLineClass::Boundary
            }
        }
        super::stream::LineClass::ParaText => {
            if image_line(trimmed).is_some() {
                DocLineClass::Image
            } else if has_unescaped_pipe(trimmed) {
                DocLineClass::PipeText
            } else {
                DocLineClass::ParaText
            }
        }
    }
}

/// Does `header` + `next` open a table? (`next` must be a delimiter row
/// with the same cell count.) Both arguments are raw lines.
pub(super) fn table_opens(header_trimmed: &str, next_raw: &str) -> bool {
    match delimiter_alignments(next_raw.trim_end().trim_start()) {
        Some(align) => split_row_cells(header_trimmed).len() == align.len(),
        None => false,
    }
}

fn has_unescaped_pipe(line: &str) -> bool {
    let b = line.as_bytes();
    let mut i = 0;
    while i < b.len() {
        match b[i] {
            b'\\' => i += 2,
            b'|' => return true,
            _ => i += 1,
        }
    }
    false
}

/// Splits a trimmed row line into cell texts: one optional boundary
/// pipe stripped per side, unescaped `|` separates, cells trim their
/// whitespace, `\|` unescapes to a literal pipe (other escapes are the
/// inline parser's job).
pub(super) fn split_row_cells(trimmed: &str) -> Vec<String> {
    let inner = trimmed.strip_prefix('|').unwrap_or(trimmed);
    let b = inner.as_bytes();
    let mut cells = Vec::new();
    let mut cur = String::new();
    let mut i = 0;
    // True only when the very last consumed byte was an UNESCAPED `|`
    // (escape parity is a walk fact — `a\\|` ends on a boundary pipe,
    // `a\|` does not; no suffix test can tell them apart).
    let mut ended_on_boundary = false;
    while i < b.len() {
        match b[i] {
            b'\\' if b.get(i + 1) == Some(&b'|') => {
                cur.push('|');
                i += 2;
                ended_on_boundary = false;
            }
            b'\\' if i + 1 < b.len() => {
                // Keep the escape pair verbatim for the inline parser
                // (multi-byte safe: one whole char follows).
                cur.push('\\');
                let start = i + 1;
                i = next_char_boundary(inner, start);
                cur.push_str(&inner[start..i]);
                ended_on_boundary = false;
            }
            b'|' => {
                cells.push(std::mem::take(&mut cur));
                i += 1;
                ended_on_boundary = true;
            }
            _ => {
                // Copy one whole char (UTF-8 safe).
                let start = i;
                i = next_char_boundary(inner, start);
                cur.push_str(&inner[start..i]);
                ended_on_boundary = false;
            }
        }
    }
    // A trailing boundary pipe leaves an empty final accumulator —
    // drop it; anything else (including interior emptiness) is a cell.
    if !(cur.is_empty() && ended_on_boundary) {
        cells.push(cur);
    }
    for c in &mut cells {
        let t = c.trim();
        if t.len() != c.len() {
            *c = t.to_string();
        }
    }
    cells
}

/// Byte offset one whole char after `start` (or the string's end).
fn next_char_boundary(s: &str, start: usize) -> usize {
    s[start..]
        .char_indices()
        .nth(1)
        .map(|(o, _)| start + o)
        .unwrap_or(s.len())
}

/// Parses a delimiter row (`| :-- | :-: | --: |` shapes). Returns the
/// per-column `(alignment, declared)` pairs — `declared` is whether the
/// cell carried any `:` marker (bare `---` = defaulted left) — or
/// `None` when the line is not a delimiter. Requires at least one
/// unescaped `|` (so `---` stays a rule) and every cell to be `:?-+:?`.
pub(super) fn delimiter_alignments(trimmed: &str) -> Option<Vec<(CellAlign, bool)>> {
    if !has_unescaped_pipe(trimmed) {
        return None;
    }
    let cells = split_row_cells(trimmed);
    if cells.is_empty() {
        return None;
    }
    let mut out = Vec::with_capacity(cells.len());
    for cell in &cells {
        let c = cell.as_str();
        let left = c.starts_with(':');
        let right = c.ends_with(':') && c.len() > 1;
        let dashes = &c[usize::from(left)..c.len() - usize::from(right)];
        if dashes.is_empty() || !dashes.bytes().all(|b| b == b'-') {
            return None;
        }
        let align = match (left, right) {
            (true, true) => CellAlign::Center,
            (false, true) => CellAlign::Right,
            _ => CellAlign::Left,
        };
        out.push((align, left || right));
    }
    Some(out)
}

/// `![alt](src)` covering the WHOLE trimmed line; `src` must be
/// non-empty (an empty target stays literal text, matching the core
/// link rule). Alt text is verbatim (it is caption material).
pub(super) fn image_line(trimmed: &str) -> Option<(String, String)> {
    if !trimmed.starts_with("![") {
        return None;
    }
    let b = trimmed.as_bytes();
    let alt_end = find_close(b, 2, b"]")?;
    if b.get(alt_end + 1) != Some(&b'(') {
        return None;
    }
    let src_end = find_close(b, alt_end + 2, b")")?;
    if src_end + 1 != b.len() {
        return None; // trailing content: not a block image
    }
    let src = &trimmed[alt_end + 2..src_end];
    if src.is_empty() {
        return None;
    }
    Some((trimmed[2..alt_end].to_string(), src.to_string()))
}

/// `- [ ] rest` / `- [x] rest` (any bullet). Returns (checked, rest).
pub(super) fn task_item(trimmed: &str) -> Option<(bool, &str)> {
    let (marker, rest) = list_marker(trimmed)?;
    if marker != Marker::Bullet {
        return None;
    }
    let checked = if rest.starts_with("[ ]") {
        false
    } else if rest.starts_with("[x]") || rest.starts_with("[X]") {
        true
    } else {
        return None;
    };
    match rest.as_bytes().get(3) {
        None => Some((checked, "")),
        Some(b' ') => Some((checked, rest[4..].trim_start())),
        Some(_) => None, // "[ ]x" is ordinary list text
    }
}

#[cfg(test)]
#[path = "md_doc_tests.rs"]
mod tests;