Skip to main content

datui_lib/
glyphs.rs

1//! Terminal glyphs, with an ASCII fallback for terminals not doing UTF-8 (SSH, a C
2//! locale), where Unicode would render as replacement boxes.
3//!
4//! No Nerd Font glyphs. Every Unicode character passes the font-coverage audit
5//! (`scripts/code/audit_glyphs.py`): present in JetBrainsMono Nerd Font, and never an
6//! `Emoji=Yes, Emoji_Presentation=No` codepoint missing from Liberation Mono or Noto Sans
7//! Mono (those fall back to the color-emoji font and draw blank or clipped). Nerd Font
8//! icons appear only in the Omarchy menu and in users' own `[glyphs]` overrides.
9
10use ratatui::buffer::Buffer;
11use ratatui::layout::Rect;
12use ratatui::symbols::{Marker, line};
13use std::borrow::Cow;
14use std::collections::BTreeMap;
15use std::sync::OnceLock;
16use unicode_width::UnicodeWidthStr;
17
18/// Symbols used by the UI, in whichever alphabet the terminal can render.
19#[derive(Debug, Clone, Copy)]
20pub struct Glyphs {
21    /// Whether this is the Unicode set: the sets are consts, so no address identifies them.
22    pub unicode: bool,
23    /// Marks the selected row — the one thing that must be findable instantly.
24    pub selector: &'static str,
25    /// Non-selected row indent; must be the same display width as `selector`.
26    pub selector_blank: &'static str,
27    /// Text cursor in an input.
28    pub cursor: &'static str,
29    /// Prompt marker.
30    pub prompt: &'static str,
31    /// Vertical rule between panes.
32    pub rule: &'static str,
33    /// The frozen-columns separator while too narrow for every frozen column (the rest
34    /// scroll after it). One column wide, like `rule`, and visibly different.
35    pub rule_broken: &'static str,
36    /// Truncation marker.
37    pub ellipsis: &'static str,
38    /// The home screen's row up a level.
39    pub up: &'static str,
40    /// Between row and column counts: `2.4M × 18`.
41    pub times: &'static str,
42    /// Section collapse markers; both must be the same display width.
43    pub collapsed: &'static str,
44    pub expanded: &'static str,
45    /// Keycap names for the footer. Named keys are spelled out there, matching
46    /// the rest of datui, so only the arrows need a fallback.
47    pub updown: &'static str,
48    /// Left/right pair, for the fold hint.
49    pub updown_lr: &'static str,
50    /// Ctrl plus the up/down pair, for the section-jump chip.
51    pub ctrl_updown: &'static str,
52    /// Separator between facts in a status line: `listing · nfs`.
53    pub middot: &'static str,
54    /// A fact that is not there: a size no footer stated, a format nothing named.
55    /// Also joins a note's summary to its scope.
56    pub dash: &'static str,
57    /// The coefficient of determination, in the regression fit line.
58    pub r_squared: &'static str,
59    /// Spearman's rank correlation, in the correlation matrix.
60    pub rho: &'static str,
61    /// Spinner frames, cycled while something is loading. Every frame must be the
62    /// same display width, or the text beside it jitters.
63    pub spinner: &'static [&'static str],
64    /// Where a row's data lives, beside its name (on an ultrawide the details pane is too
65    /// far from the row). All five must share a display width, or names shift.
66    pub here: &'static str,
67    pub in_memory: &'static str,
68    pub over_network: &'static str,
69    pub in_object_store: &'static str,
70    pub place_unknown: &'static str,
71    /// A null cell. Blank is what a null used to be, and blank is also what an empty
72    /// string is, so the two were indistinguishable.
73    pub null: &'static str,
74    /// A cell whose file was written without this column (not a null in the data). One
75    /// column wide, like `null`.
76    pub absent: &'static str,
77    /// A cell whose file stores the column in a type the column cannot hold, so it was
78    /// not read from that file. A value is there; it is not this type.
79    pub conflict: &'static str,
80    /// After a column's name in the header: this column is not in every file, or the
81    /// files disagree on its type. A footnote mark, and the Info panel is the note.
82    pub drift_mark: &'static str,
83    /// After a sorted column's name in the header, giving the direction. One column wide in
84    /// both sets (header arithmetic counts it like `drift_mark`); triangles, since arrows
85    /// mean off-screen columns in that row.
86    pub sort_asc: &'static str,
87    pub sort_desc: &'static str,
88    /// The rail down the left edge of the row the cursor is on.
89    pub rail: &'static str,
90    /// Rule drawn beside a section title: resting, and under the cursor.
91    pub rule_h: &'static str,
92    pub rule_h_focused: &'static str,
93    /// Arrows for the off-screen column hints in the table header.
94    pub arrow_left: &'static str,
95    pub arrow_right: &'static str,
96    /// Between the steps of a location trail: `cloud › Azure › datui-test`.
97    pub trail: &'static str,
98    /// Either side of a choice shown alone because its values do not fit on its
99    /// row: `‹ TSV ›`, which ←/→ step. One column wide in both sets.
100    pub choice_prev: &'static str,
101    pub choice_next: &'static str,
102    /// After a column's name in a column list: hidden from the table. One column
103    /// wide in both sets, like the header marks.
104    pub hidden_mark: &'static str,
105    /// After a field in the inspector's Compare column: the two rows' values
106    /// differ. One column wide in both sets.
107    pub diff_mark: &'static str,
108    /// Checkbox states, for toggle lists.
109    pub checkbox_on: &'static str,
110    pub checkbox_off: &'static str,
111    /// Radio button states, for pick-one lists.
112    pub radio_on: &'static str,
113    pub radio_off: &'static str,
114    /// Single-cell state dots: all, some, none. One column wide in both sets.
115    pub dot_full: &'static str,
116    pub dot_half: &'static str,
117    pub dot_empty: &'static str,
118    /// Five ascending levels for a score shown as one character.
119    pub score_marks: &'static [&'static str; 5],
120    /// A confirmation mark.
121    pub check: &'static str,
122    /// A caution mark.
123    pub warning: &'static str,
124    /// Scrollbar thumb, drawn down the right edge of an overlay.
125    pub scroll_thumb: &'static str,
126    /// The scrollbar's track, above and below the thumb: a lighter shade of it.
127    pub scroll_track: &'static str,
128    /// Stands in for a value that is bytes, not text.
129    pub binary_stub: &'static str,
130    /// Marks for a line break, tab or other control character in a one-line preview (a cell
131    /// cannot draw them); one column wide in both sets. The inspector shows them as is.
132    pub newline_mark: &'static str,
133    pub tab_mark: &'static str,
134    pub control_mark: &'static str,
135    /// A byte in the hex view's ASCII gutter that is not printable. One column wide
136    /// in both sets.
137    pub hex_dot: &'static str,
138    /// Eight compact levels for inline charts (lowest to highest).
139    pub mini_bars: &'static [&'static str; 8],
140    /// In a line of `mini_bars`, a place with nothing measured: segments a sample
141    /// drew no row from. One column wide in both sets, and unlike every bar level.
142    pub unsampled: &'static str,
143    /// Under a line of bars, the one selected. One column wide in both sets.
144    pub pointer: &'static str,
145    /// A horizontal bar's end, one to eight eighths of a cell filled from the left;
146    /// the last is a whole cell, the bar's body. One column wide in both sets.
147    pub bar_eighths: &'static [&'static str; 8],
148    /// The home-screen wordmark, three rows of box drawing. `None` when the terminal
149    /// cannot draw it, and the one-line title bar is used instead.
150    pub wordmark: Option<&'static [&'static str]>,
151    /// The one border every Surface draws. Not a `[glyphs]` override slot:
152    /// its eight pieces must agree with each other, and ratatui draws them.
153    pub border: ratatui::symbols::border::Set<'static>,
154    /// What the plots draw with. Not a `[glyphs]` override slot: ratatui draws
155    /// these, and the Unicode marks are whole blocks of braille and eighths.
156    pub plot: PlotMarks,
157}
158
159/// The marks ratatui's `Chart`, `Canvas` and `BarChart` draw, and their axis and legend
160/// lines; ratatui ignores the locale, so each set names its own.
161#[derive(Debug, Clone, Copy)]
162pub struct PlotMarks {
163    /// A line: an XY line, a density curve, a fit drawn over bars, a dense Q-Q plot.
164    pub line: Marker,
165    /// One mark per point: a scatter, a box plot's strokes, a sparse Q-Q plot.
166    pub point: Marker,
167    /// A column from zero up to each point: the XY bar style and the histogram.
168    pub bar: Marker,
169    /// A vertical bar's top, one to eight eighths of a cell filled from the bottom;
170    /// the last is a whole cell, the bar's body.
171    pub column_eighths: &'static [&'static str; 8],
172    /// The axes and the legend frame.
173    pub axis: line::Set<'static>,
174    /// A tick on the x axis line and on the y axis line, pointing at its label.
175    pub tick_x: &'static str,
176    pub tick_y: &'static str,
177    /// The grid: a dotted line across the plot at a y tick, and down it at an x tick.
178    pub grid_across: &'static str,
179    pub grid_down: &'static str,
180}
181
182impl PlotMarks {
183    /// The vertical bars a `BarChart` draws with.
184    pub fn column_set(&self) -> ratatui::symbols::bar::Set<'static> {
185        let e = self.column_eighths;
186        ratatui::symbols::bar::Set {
187            full: e[7],
188            seven_eighths: e[6],
189            three_quarters: e[5],
190            five_eighths: e[4],
191            half: e[3],
192            three_eighths: e[2],
193            one_quarter: e[1],
194            one_eighth: e[0],
195            empty: " ",
196        }
197    }
198
199    /// Whether a canvas drawing with `marker` put this symbol in its cell, rather
200    /// than an axis or a label. Braille's blank pattern counts: the grid draws it.
201    pub fn is_mark(marker: Marker, symbol: &str) -> bool {
202        let mut chars = symbol.chars();
203        let (Some(c), None) = (chars.next(), chars.next()) else {
204            return false;
205        };
206        match marker {
207            Marker::Braille => ('\u{2800}'..='\u{28ff}').contains(&c),
208            Marker::Dot => symbol == ratatui::symbols::DOT,
209            Marker::Custom(mark) => c == mark,
210            _ => false,
211        }
212    }
213
214    /// Redraw ratatui `Chart` axes and legend frame (always `line::NORMAL`) in this set's
215    /// `axis` lines, found by shape: the axes are the `└` with `│` above and an unclosed
216    /// `─` run to its right; the legend is a closed box. Labels with those characters stay.
217    /// A chart too small for both axes changes every line cell. A no-op under Unicode.
218    pub fn redraw_axes(&self, area: Rect, buf: &mut Buffer) {
219        let (from, to) = (line::NORMAL, self.axis);
220        if from == to {
221            return;
222        }
223        let area = area.intersection(buf.area);
224        let at = |x: u16, y: u16| buf[(x, y)].symbol();
225        // How many cells in a row hold `symbol`, stepping from (x, y) by (dx, dy).
226        let run = |x: u16, y: u16, (dx, dy): (i32, i32), symbol: &str| {
227            let mut n = 0;
228            let (mut cx, mut cy) = (i32::from(x) + dx, i32::from(y) + dy);
229            while (i32::from(area.left())..i32::from(area.right())).contains(&cx)
230                && (i32::from(area.top())..i32::from(area.bottom())).contains(&cy)
231                && at(cx as u16, cy as u16) == symbol
232            {
233                n += 1;
234                cx += dx;
235                cy += dy;
236            }
237            n
238        };
239        let (up, down, right) = ((0, -1), (0, 1), (1, 0));
240        let mut frame = Vec::new();
241        let mut found_axes = false;
242        for y in area.top()..area.bottom() {
243            for x in area.left()..area.right() {
244                let symbol = at(x, y);
245                if symbol == from.bottom_left {
246                    let (high, wide) = (
247                        run(x, y, up, from.vertical),
248                        run(x, y, right, from.horizontal),
249                    );
250                    let end = x + wide + 1;
251                    let boxed = end < area.right() && at(end, y) == from.bottom_right;
252                    if high > 0 && wide > 0 && !boxed {
253                        found_axes = true;
254                        frame.extend((y - high..=y).map(|y| (x, y)));
255                        frame.extend((x + 1..end).map(|x| (x, y)));
256                    }
257                } else if symbol == from.top_left {
258                    let wide = run(x, y, right, from.horizontal);
259                    let high = run(x, y, down, from.vertical);
260                    let (r, b) = (x + wide + 1, y + high + 1);
261                    let closed = r < area.right()
262                        && b < area.bottom()
263                        && at(r, y) == from.top_right
264                        && at(x, b) == from.bottom_left
265                        && at(r, b) == from.bottom_right
266                        && run(r, y, down, from.vertical) == high
267                        && run(x, b, right, from.horizontal) == wide;
268                    if closed {
269                        for i in x..=r {
270                            frame.extend([(i, y), (i, b)]);
271                        }
272                        for j in y + 1..b {
273                            frame.extend([(x, j), (r, j)]);
274                        }
275                    }
276                }
277            }
278        }
279        if !found_axes {
280            frame = (area.top()..area.bottom())
281                .flat_map(|y| (area.left()..area.right()).map(move |x| (x, y)))
282                .collect();
283        }
284        let pairs = [
285            (from.vertical, to.vertical),
286            (from.horizontal, to.horizontal),
287            (from.top_left, to.top_left),
288            (from.top_right, to.top_right),
289            (from.bottom_left, to.bottom_left),
290            (from.bottom_right, to.bottom_right),
291        ];
292        for (x, y) in frame {
293            let cell = &mut buf[(x, y)];
294            if let Some((_, twin)) = pairs.iter().find(|(line, _)| cell.symbol() == *line) {
295                cell.set_symbol(twin);
296            }
297        }
298    }
299}
300
301const UNICODE: Glyphs = Glyphs {
302    unicode: true,
303    selector: "▎ ",
304    selector_blank: "  ",
305    cursor: "▏",
306    prompt: "› ",
307    rule: "│",
308    rule_broken: "┆",
309    ellipsis: "…",
310    up: "..",
311    times: "×",
312    collapsed: "▸ ",
313    expanded: "▾ ",
314    updown: "↑↓",
315    updown_lr: "←→",
316    ctrl_updown: "^↑↓",
317    middot: "·",
318    dash: "—",
319    r_squared: "R²",
320    rho: "ρ",
321    spinner: &["⣷", "⣯", "⣟", "⡿", "⢿", "⣻", "⣽", "⣾"],
322    // Plain Unicode from blocks the common coding fonts cover, checked per codepoint
323    // (`fc-list "<font>:charset=<hex>"`) against JetBrainsMono Nerd Font, Liberation Mono
324    // and Noto Sans Mono. Emoji-class codepoints (☁, ⇅, ☑/☐, ◐/◑) were retired: they fall
325    // back to the color-emoji font.
326    here: "◦",
327    in_memory: "▪",
328    over_network: "↕",
329    in_object_store: "≈",
330    place_unknown: "◌",
331    null: "∅",
332    absent: "·",
333    conflict: "≠",
334    drift_mark: "*",
335    sort_asc: "▲",
336    sort_desc: "▼",
337    rail: "▎",
338    rule_h: "─",
339    rule_h_focused: "━",
340    arrow_left: "←",
341    arrow_right: "→",
342    trail: "›",
343    choice_prev: "‹",
344    choice_next: "›",
345    hidden_mark: "⊘",
346    diff_mark: "Δ",
347    checkbox_on: "■",
348    checkbox_off: "□",
349    radio_on: "●",
350    radio_off: "○",
351    dot_full: "●",
352    dot_half: "◔",
353    dot_empty: "○",
354    score_marks: &["○", "◔", "◕", "◉", "●"],
355    check: "✓",
356    // Not ⚠ U+26A0 (emoji-class, missing from Liberation and Noto): the triangle's shape
357    // from a codepoint all three carry.
358    warning: "▲",
359    scroll_thumb: "█",
360    scroll_track: "░",
361    binary_stub: "‹binary›",
362    // Latin-1, which every floor font carries; the arrows and control pictures
363    // (↵ ⇥ ␊) are missing from Liberation Mono and Noto Sans Mono.
364    newline_mark: "¶",
365    tab_mark: "»",
366    control_mark: "¤",
367    hex_dot: "·",
368    mini_bars: &["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"],
369    unsampled: "·",
370    pointer: "▲",
371    bar_eighths: &["▏", "▎", "▍", "▌", "▋", "▊", "▉", "█"],
372    wordmark: Some(&[
373        "┌──╮ ╭──╮ ╶─┬─╴ ╷  ╷ ╶┬╴",
374        "│  │ ├──┤   │   │  │  │ ",
375        "└──╯ ╵  ╵   ╵   ╰──╯ ╶┴╴",
376    ]),
377    border: ratatui::symbols::border::ROUNDED,
378    plot: PlotMarks {
379        line: Marker::Braille,
380        point: Marker::Dot,
381        bar: Marker::HalfBlock,
382        column_eighths: &["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"],
383        axis: line::NORMAL,
384        tick_x: "┬",
385        tick_y: "┤",
386        grid_across: "·",
387        grid_down: "┊",
388    },
389};
390
391const ASCII: Glyphs = Glyphs {
392    unicode: false,
393    selector: "> ",
394    selector_blank: "  ",
395    cursor: "_",
396    prompt: "> ",
397    rule: "|",
398    rule_broken: ":",
399    ellipsis: "...",
400    up: "..",
401    times: "x",
402    collapsed: "+ ",
403    expanded: "- ",
404    updown: "Up/Dn",
405    updown_lr: "Lt/Rt",
406    ctrl_updown: "^Up/Dn",
407    middot: "-",
408    dash: "-",
409    r_squared: "R^2",
410    rho: "rho",
411    spinner: &["|", "/", "-", "\\"],
412    here: ".",
413    in_memory: "*",
414    over_network: "~",
415    in_object_store: "@",
416    place_unknown: "?",
417    null: "~",
418    absent: ".",
419    conflict: "!",
420    drift_mark: "*",
421    sort_asc: "^",
422    sort_desc: "v",
423    rail: ">",
424    rule_h: "-",
425    rule_h_focused: "=",
426    arrow_left: "<",
427    arrow_right: ">",
428    trail: ">",
429    choice_prev: "<",
430    choice_next: ">",
431    hidden_mark: "x",
432    diff_mark: "*",
433    checkbox_on: "[x]",
434    checkbox_off: "[ ]",
435    radio_on: "(*)",
436    radio_off: "( )",
437    dot_full: "#",
438    dot_half: "+",
439    dot_empty: ".",
440    score_marks: &[".", "-", "+", "*", "#"],
441    check: "+",
442    warning: "!",
443    scroll_thumb: "#",
444    scroll_track: "|",
445    binary_stub: "<binary>",
446    // vim's `list` marks: `$` ends a line, `>` is a tab.
447    newline_mark: "$",
448    tab_mark: ">",
449    control_mark: "?",
450    hex_dot: ".",
451    mini_bars: &[".", ":", "-", "=", "+", "*", "#", "@"],
452    unsampled: "?",
453    pointer: "^",
454    // Under half a cell is still a mark, so a small value never reads as zero.
455    bar_eighths: &["-", "-", "-", "=", "=", "=", "=", "#"],
456    wordmark: None,
457    border: ratatui::symbols::border::Set {
458        top_left: "+",
459        top_right: "+",
460        bottom_left: "+",
461        bottom_right: "+",
462        vertical_left: "|",
463        vertical_right: "|",
464        horizontal_top: "-",
465        horizontal_bottom: "-",
466    },
467    plot: PlotMarks {
468        line: Marker::Custom('*'),
469        point: Marker::Custom('o'),
470        bar: Marker::Custom('#'),
471        // Where the bar's top edge sits in its last cell: low, halfway, full.
472        column_eighths: &["_", "_", "-", "-", "-", "#", "#", "#"],
473        axis: line::Set {
474            vertical: "|",
475            horizontal: "-",
476            top_right: "+",
477            top_left: "+",
478            bottom_right: "+",
479            bottom_left: "+",
480            vertical_left: "+",
481            vertical_right: "+",
482            horizontal_down: "+",
483            horizontal_up: "+",
484            cross: "+",
485        },
486        tick_x: "+",
487        tick_y: "+",
488        grid_across: ".",
489        grid_down: ":",
490    },
491};
492
493/// What the user asked for, from `[display] unicode`.
494#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
495#[serde(rename_all = "lowercase")]
496pub enum UnicodeMode {
497    /// Detect from the environment: see [`environment_is_utf8`].
498    #[default]
499    Auto,
500    Always,
501    Never,
502}
503
504/// One `[glyphs]` override from the config: a single glyph, or a list for the
505/// slots that hold one (`spinner`, `score_marks`, `mini_bars`, `bar_eighths`).
506#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
507#[serde(untagged)]
508pub enum SlotOverride {
509    One(String),
510    Many(Vec<String>),
511}
512
513/// The overridable single-string slots, via a callback macro so names, defaults and
514/// assignment cannot drift. The wordmark is excluded: it is the brand.
515macro_rules! with_string_slots {
516    ($callback:ident) => {
517        $callback!(
518            selector,
519            selector_blank,
520            cursor,
521            prompt,
522            rule,
523            rule_broken,
524            ellipsis,
525            up,
526            times,
527            collapsed,
528            expanded,
529            updown,
530            updown_lr,
531            ctrl_updown,
532            middot,
533            dash,
534            r_squared,
535            rho,
536            here,
537            in_memory,
538            over_network,
539            in_object_store,
540            place_unknown,
541            null,
542            absent,
543            conflict,
544            drift_mark,
545            sort_asc,
546            sort_desc,
547            rail,
548            rule_h,
549            rule_h_focused,
550            arrow_left,
551            arrow_right,
552            trail,
553            choice_prev,
554            choice_next,
555            hidden_mark,
556            diff_mark,
557            checkbox_on,
558            checkbox_off,
559            radio_on,
560            radio_off,
561            dot_full,
562            dot_half,
563            dot_empty,
564            check,
565            warning,
566            scroll_thumb,
567            scroll_track,
568            binary_stub,
569            newline_mark,
570            tab_mark,
571            control_mark,
572            hex_dot,
573            unsampled,
574            pointer
575        )
576    };
577}
578
579/// Instructional text mapped to ASCII for the help overlay on a non-UTF-8 terminal, at
580/// the render boundary only (user data is never transliterated). Coverage is checked by
581/// `every_help_screen_is_ascii_clean`.
582pub fn asciify_instructions(text: &str) -> std::borrow::Cow<'_, str> {
583    if get().unicode || text.is_ascii() {
584        return std::borrow::Cow::Borrowed(text);
585    }
586    std::borrow::Cow::Owned(instructions_in_ascii(text))
587}
588
589/// `text` with every instructional character replaced by its (wider) ASCII twin. Help
590/// rows (key, two+ spaces, description) are re-padded per section, so descriptions
591/// stay aligned and a section shifts right together when a key outgrows its column.
592pub fn instructions_in_ascii(text: &str) -> String {
593    let mut out = String::with_capacity(text.len() + text.len() / 8);
594    let mut section: Vec<&str> = Vec::new();
595    for line in text.split('\n') {
596        if line.trim().is_empty() {
597            push_section(&section, &mut out);
598            section.clear();
599            push_ascii(line, &mut out);
600            out.push('\n');
601        } else {
602            section.push(line);
603        }
604    }
605    push_section(&section, &mut out);
606    // Every line was pushed with a newline; the last one never had it.
607    out.pop();
608    out
609}
610
611/// One section's lines, each followed by a newline. Continuation lines,
612/// indented to one of the section's description columns, move with it.
613fn push_section(lines: &[&str], out: &mut String) {
614    // (key end, description start, description column) per keyed row.
615    let rows: Vec<Option<(usize, usize, usize)>> = lines
616        .iter()
617        .map(|line| key_gap(line).map(|(key, desc)| (key, desc, display_width(&line[..desc]))))
618        .collect();
619    let shift = lines
620        .iter()
621        .zip(&rows)
622        .filter_map(|(line, row)| {
623            row.map(|(key, _, column)| (ascii_width(&line[..key]) + 2).saturating_sub(column))
624        })
625        .max()
626        .unwrap_or(0);
627    for (line, row) in lines.iter().zip(&rows) {
628        match *row {
629            Some((key, desc, column)) => {
630                push_ascii(&line[..key], out);
631                let pad = column + shift - ascii_width(&line[..key]);
632                out.extend(std::iter::repeat_n(' ', pad));
633                push_ascii(&line[desc..], out);
634            }
635            None => {
636                let indent = line.len() - line.trim_start_matches(' ').len();
637                if shift > 0 && rows.iter().flatten().any(|&(_, _, c)| c == indent) {
638                    out.extend(std::iter::repeat_n(' ', shift));
639                }
640                push_ascii(line, out);
641            }
642        }
643        out.push('\n');
644    }
645}
646
647/// Columns `text` takes once its instructional characters are ASCII.
648fn ascii_width(text: &str) -> usize {
649    let mut ascii = String::new();
650    push_ascii(text, &mut ascii);
651    ascii.len()
652}
653
654/// Where a help row's key ends and its description starts: the first run of two or
655/// more spaces after the indent, with text after. `None` for prose, headings or blanks
656/// (help prose uses single spaces).
657pub(crate) fn key_gap(line: &str) -> Option<(usize, usize)> {
658    let lead = line.len() - line.trim_start_matches(' ').len();
659    let key_end = lead + line[lead..].find("  ")?;
660    let desc_start = line.len() - line[key_end..].trim_start_matches(' ').len();
661    (desc_start < line.len()).then_some((key_end, desc_start))
662}
663
664fn push_ascii(text: &str, out: &mut String) {
665    let mut chars = text.chars().peekable();
666    while let Some(c) = chars.next() {
667        // A paired arrow is one key, spelled as the glyph set spells it;
668        // twin by twin it would read "UpDn".
669        let pair = match (c, chars.peek()) {
670            ('↑', Some('↓')) => Some(ASCII.updown),
671            ('←', Some('→')) => Some(ASCII.updown_lr),
672            _ => None,
673        };
674        if let Some(pair) = pair {
675            chars.next();
676            out.push_str(pair);
677            continue;
678        }
679        match ascii_twin(c) {
680            Some(twin) => out.push_str(twin),
681            None if c.is_ascii() => out.push(c),
682            // An unknown character is marked, never sent raw; the audit test keeps this
683            // unreachable from help files.
684            None => out.push('?'),
685        }
686    }
687}
688
689/// Display columns `text` occupies; scalar counts get CJK and combining marks wrong.
690pub fn display_width(text: &str) -> usize {
691    UnicodeWidthStr::width(text)
692}
693
694/// The longest prefix of `text` that fits `width` display columns, never
695/// splitting a wide character.
696pub fn take_columns(text: &str, width: usize) -> &str {
697    let mut used = 0usize;
698    for (i, c) in text.char_indices() {
699        let w = unicode_width::UnicodeWidthChar::width(c).unwrap_or(0);
700        if used + w > width {
701            return &text[..i];
702        }
703        used += w;
704    }
705    text
706}
707
708/// The longest suffix of `text` that fits `width` display columns, never
709/// splitting a wide character.
710pub fn take_columns_end(text: &str, width: usize) -> &str {
711    let mut used = 0usize;
712    let mut start = text.len();
713    for (i, c) in text.char_indices().rev() {
714        let w = unicode_width::UnicodeWidthChar::width(c).unwrap_or(0);
715        if used + w > width {
716            break;
717        }
718        used += w;
719        start = i;
720    }
721    &text[start..]
722}
723
724/// Printable ASCII only: one byte, one cell, one grapheme, so the fast paths
725/// below need no segmentation.
726fn plain_ascii(text: &str) -> bool {
727    text.bytes().all(|b| (0x20..0x7f).contains(&b))
728}
729
730/// The graphemes of `text` as ratatui draws them: its own segmentation, with the
731/// clusters holding a control character dropped as its `Span` drops them.
732fn drawn_graphemes<'a>(span: &'a ratatui::text::Span<'a>) -> impl Iterator<Item = &'a str> {
733    span.styled_graphemes(ratatui::style::Style::default())
734        .map(|g| g.symbol)
735}
736
737/// Cells `text` takes in a table cell, grapheme by grapheme at ratatui's widths: unlike
738/// [`display_width`], a joined emoji sequence counts once and control characters zero.
739pub fn cell_width(text: &str) -> usize {
740    use ratatui::buffer::CellWidth;
741    if plain_ascii(text) {
742        return text.len();
743    }
744    let span = ratatui::text::Span::raw(text);
745    drawn_graphemes(&span)
746        .map(|g| usize::from(g.cell_width()))
747        .sum()
748}
749
750/// `text` fitted to `width` cells: whole if it fits, else cut at a grapheme boundary
751/// and closed with `marker` (never half a wide character). Control characters are
752/// dropped, as ratatui drops them. If `marker` itself does not fit, as much as does.
753pub fn fit_cells<'a>(text: &'a str, width: usize, marker: &str) -> Cow<'a, str> {
754    use ratatui::buffer::CellWidth;
755    let marker_width = cell_width(marker);
756    if plain_ascii(text) {
757        if text.len() <= width {
758            return Cow::Borrowed(text);
759        }
760        if width <= marker_width {
761            return Cow::Owned(take_columns(marker, width).to_string());
762        }
763        return Cow::Owned(format!("{}{marker}", &text[..width - marker_width]));
764    }
765    let span = ratatui::text::Span::raw(text);
766    let total: usize = drawn_graphemes(&span)
767        .map(|g| usize::from(g.cell_width()))
768        .sum();
769    if total <= width {
770        let whole = drawn_graphemes(&span).map(str::len).sum::<usize>() == text.len();
771        return if whole {
772            Cow::Borrowed(text)
773        } else {
774            Cow::Owned(drawn_graphemes(&span).collect())
775        };
776    }
777    if width <= marker_width {
778        return Cow::Owned(take_columns(marker, width).to_string());
779    }
780    let budget = width - marker_width;
781    let mut used = 0usize;
782    let mut out = String::with_capacity(budget + marker.len());
783    for g in drawn_graphemes(&span) {
784        let w = usize::from(g.cell_width());
785        if used + w > budget {
786            break;
787        }
788        used += w;
789        out.push_str(g);
790    }
791    out.push_str(marker);
792    Cow::Owned(out)
793}
794
795/// The ASCII twin of one instructional character, `None` for plain ASCII or
796/// a character no help file may use.
797fn ascii_twin(c: char) -> Option<&'static str> {
798    match c {
799        '↑' => Some("Up"),
800        '↓' => Some("Dn"),
801        '←' => Some("Lt"),
802        '→' => Some("Rt"),
803        '↔' => Some("Lt/Rt"),
804        '—' => Some("-"),
805        '…' => Some("..."),
806        '×' => Some("x"),
807        '≤' => Some("<="),
808        '≥' => Some(">="),
809        '∅' => Some("~"),
810        '·' => Some("."),
811        '≠' => Some("!"),
812        'ρ' => Some("rho"),
813        '│' => Some("|"),
814        '┆' => Some(":"),
815        '¶' => Some("$"),
816        '»' => Some(">"),
817        '¤' => Some("?"),
818        'Δ' => Some("*"),
819        _ => None,
820    }
821}
822
823/// The Unicode default for a single-string slot, or `None` for a list slot or
824/// an unknown name.
825fn unicode_default(slot: &str) -> Option<&'static str> {
826    macro_rules! lookup {
827        ($($name:ident),*) => {
828            match slot {
829                $(stringify!($name) => Some(UNICODE.$name),)*
830                _ => None,
831            }
832        };
833    }
834    with_string_slots!(lookup)
835}
836
837/// Check a `[glyphs]` override map at load time, naming the bad slot. Each override
838/// must keep its glyph's display width, so every layout width invariant holds.
839pub fn validate_overrides(overrides: &BTreeMap<String, SlotOverride>) -> Result<(), String> {
840    let same_width = |slot: &str, text: &str, default: &str| -> Result<(), String> {
841        if text.width() == default.width() {
842            Ok(())
843        } else {
844            Err(format!(
845                "glyph override for `{slot}` is {} columns wide; {default:?} is {} — \
846                 an override must keep the width of the glyph it replaces",
847                text.width(),
848                default.width(),
849            ))
850        }
851    };
852    for (slot, value) in overrides {
853        match (unicode_default(slot), value) {
854            (Some(default), SlotOverride::One(text)) => same_width(slot, text, default)?,
855            (Some(_), SlotOverride::Many(_)) => {
856                return Err(format!(
857                    "glyph slot `{slot}` takes a single string, not a list"
858                ));
859            }
860            (None, _) => {
861                let (len, default) = match slot.as_str() {
862                    "spinner" => (None, UNICODE.spinner),
863                    "score_marks" => (Some(5), &UNICODE.score_marks[..]),
864                    "mini_bars" => (Some(8), &UNICODE.mini_bars[..]),
865                    "bar_eighths" => (Some(8), &UNICODE.bar_eighths[..]),
866                    _ => return Err(format!("unknown glyph slot `{slot}`")),
867                };
868                let SlotOverride::Many(entries) = value else {
869                    return Err(format!("glyph slot `{slot}` takes a list of strings"));
870                };
871                match len {
872                    Some(len) if entries.len() != len => {
873                        return Err(format!(
874                            "glyph slot `{slot}` takes exactly {len} entries, got {}",
875                            entries.len()
876                        ));
877                    }
878                    None if entries.is_empty() => {
879                        return Err(format!("glyph slot `{slot}` takes at least one entry"));
880                    }
881                    _ => {}
882                }
883                for entry in entries {
884                    same_width(slot, entry, default[0])?;
885                }
886            }
887        }
888    }
889    Ok(())
890}
891
892/// A config string lives as long as the run does; the set holds `&'static str`.
893fn leak(text: &str) -> &'static str {
894    Box::leak(text.to_string().into_boxed_str())
895}
896
897/// Lay a validated override map over a set. Called once at startup.
898fn apply_overrides(set: &mut Glyphs, overrides: &BTreeMap<String, SlotOverride>) {
899    for (slot, value) in overrides {
900        match (slot.as_str(), value) {
901            ("spinner", SlotOverride::Many(frames)) => {
902                set.spinner = Box::leak(
903                    frames
904                        .iter()
905                        .map(|f| leak(f))
906                        .collect::<Vec<_>>()
907                        .into_boxed_slice(),
908                );
909            }
910            ("score_marks", SlotOverride::Many(marks)) if marks.len() == 5 => {
911                set.score_marks = Box::leak(Box::new(std::array::from_fn(|i| leak(&marks[i]))));
912            }
913            ("mini_bars", SlotOverride::Many(bars)) if bars.len() == 8 => {
914                set.mini_bars = Box::leak(Box::new(std::array::from_fn(|i| leak(&bars[i]))));
915            }
916            ("bar_eighths", SlotOverride::Many(bars)) if bars.len() == 8 => {
917                set.bar_eighths = Box::leak(Box::new(std::array::from_fn(|i| leak(&bars[i]))));
918            }
919            (name, SlotOverride::One(text)) => {
920                let text = leak(text);
921                macro_rules! assign {
922                    ($($slot:ident),*) => {
923                        match name {
924                            $(stringify!($slot) => set.$slot = text,)*
925                            // Validated at config load; an unknown name that
926                            // still got here changes nothing.
927                            _ => {}
928                        }
929                    };
930                }
931                with_string_slots!(assign)
932            }
933            _ => {}
934        }
935    }
936}
937
938static GLYPHS: OnceLock<Glyphs> = OnceLock::new();
939
940/// The signals the glyph choice reads, gathered so the rule can be tested on
941/// any OS.
942#[derive(Debug, Clone, Default, PartialEq, Eq)]
943struct Environment {
944    /// The first non-empty of `LC_ALL`, `LC_CTYPE`, `LANG`.
945    locale: Option<String>,
946    /// Running on Windows, which sets no locale variable.
947    windows: bool,
948}
949
950impl Environment {
951    fn current() -> Self {
952        Self {
953            locale: ["LC_ALL", "LC_CTYPE", "LANG"]
954                .into_iter()
955                .find_map(|key| std::env::var(key).ok().filter(|v| !v.is_empty())),
956            windows: cfg!(windows),
957        }
958    }
959
960    /// The rule: a set locale variable decides on every OS (`LANG=C` is ASCII; MSYS2 as on
961    /// Unix). Windows sets none and its console takes Unicode regardless of code page;
962    /// `WT_SESSION` and the code page are unusable signals there.
963    fn is_utf8(&self) -> bool {
964        match &self.locale {
965            Some(value) => {
966                let lower = value.to_ascii_lowercase();
967                lower.contains("utf-8") || lower.contains("utf8")
968            }
969            None => self.windows,
970        }
971    }
972}
973
974/// Whether the terminal can be trusted with UTF-8: `LC_ALL` over `LC_CTYPE` over `LANG`,
975/// as POSIX; with none set, only Windows counts. The locale, not terminal capability,
976/// decides whether multi-byte characters render.
977pub fn environment_is_utf8() -> bool {
978    Environment::current().is_utf8()
979}
980
981/// Choose the glyph set for this run, with `[glyphs]` overrides over the Unicode set.
982/// Never over the ASCII set: it is the tested floor, where rich-font overrides would
983/// garble. Later calls are ignored.
984pub fn init_with_overrides(mode: UnicodeMode, overrides: &BTreeMap<String, SlotOverride>) {
985    let mut chosen = match mode {
986        UnicodeMode::Always => UNICODE,
987        UnicodeMode::Never => ASCII,
988        UnicodeMode::Auto => {
989            if environment_is_utf8() {
990                UNICODE
991            } else {
992                ASCII
993            }
994        }
995    };
996    if chosen.unicode && !overrides.is_empty() {
997        apply_overrides(&mut chosen, overrides);
998    }
999    let _ = GLYPHS.set(chosen);
1000}
1001
1002/// The active glyph set. Falls back to detection when [`init_with_overrides`] was never
1003/// called, so library users and tests get sensible symbols without ceremony.
1004pub fn get() -> &'static Glyphs {
1005    GLYPHS.get_or_init(|| {
1006        if environment_is_utf8() {
1007            UNICODE
1008        } else {
1009            ASCII
1010        }
1011    })
1012}
1013
1014/// Whether the active set is Unicode (copies of consts have no identifying address).
1015pub fn active_is_unicode() -> bool {
1016    get().unicode
1017}
1018
1019/// The Unicode set, for tests and for callers that know their output is UTF-8.
1020pub fn unicode() -> &'static Glyphs {
1021    &UNICODE
1022}
1023
1024/// Each bordered box in `rows` as the (column, row) of its bottom-left corner, for
1025/// tests counting frames (ASCII corners are all `+`, so the corner is found by its
1026/// left side above and bottom edge after).
1027#[cfg(test)]
1028pub(crate) fn frame_corners(rows: &[String]) -> Vec<(usize, usize)> {
1029    let b = get().border;
1030    let grid: Vec<Vec<String>> = rows
1031        .iter()
1032        .map(|r| r.chars().map(String::from).collect())
1033        .collect();
1034    let at = |x: usize, y: usize| grid.get(y).and_then(|r| r.get(x)).map(String::as_str);
1035    // Where each corner has a glyph of its own, every one on screen is a box's,
1036    // whatever its shape: count them all, and every box opened must close.
1037    let glyphs = [b.top_left, b.top_right, b.bottom_left, b.bottom_right];
1038    if (1..4).all(|i| !glyphs[..i].contains(&glyphs[i])) {
1039        let opened: usize = rows.iter().map(|r| r.matches(b.top_left).count()).sum();
1040        let closed: Vec<(usize, usize)> = grid
1041            .iter()
1042            .enumerate()
1043            .flat_map(|(y, row)| {
1044                row.iter()
1045                    .enumerate()
1046                    .filter(|(_, cell)| cell.as_str() == b.bottom_left)
1047                    .map(move |(x, _)| (x, y))
1048            })
1049            .collect();
1050        assert_eq!(opened, closed.len(), "every frame closes: {rows:#?}");
1051        return closed;
1052    }
1053    let mut corners = Vec::new();
1054    for (y, row) in grid.iter().enumerate().skip(1) {
1055        for x in 0..row.len() {
1056            if at(x, y) == Some(b.bottom_left)
1057                && at(x, y - 1) == Some(b.vertical_left)
1058                && at(x + 1, y) == Some(b.horizontal_bottom)
1059                && (x == 0 || at(x - 1, y) != Some(b.horizontal_bottom))
1060            {
1061                corners.push((x, y));
1062            }
1063        }
1064    }
1065    corners
1066}
1067
1068/// The ASCII set.
1069pub fn ascii() -> &'static Glyphs {
1070    &ASCII
1071}
1072
1073/// `text` in `width` columns, cut at its end and marked with the ellipsis.
1074pub fn fit(text: &str, width: usize) -> String {
1075    fit_cells(text, width, get().ellipsis).into_owned()
1076}
1077
1078/// `text` in `width` columns, cut at its start: a path keeps its leaf. The ellipsis is
1079/// measured, since it is three columns on an ASCII terminal.
1080pub fn fit_start(text: &str, width: usize) -> String {
1081    if display_width(text) <= width {
1082        return text.to_string();
1083    }
1084    let ellipsis = get().ellipsis;
1085    let ellipsis_width = display_width(ellipsis);
1086    if width <= ellipsis_width {
1087        return take_columns_end(text, width).to_string();
1088    }
1089    format!(
1090        "{ellipsis}{}",
1091        take_columns_end(text, width - ellipsis_width)
1092    )
1093}
1094
1095/// `text` in `width` columns, cut from the middle: `weather/…/daily`.
1096pub fn fit_middle(text: &str, width: usize) -> String {
1097    if display_width(text) <= width {
1098        return text.to_string();
1099    }
1100    let mark = get().ellipsis;
1101    let mark_w = display_width(mark);
1102    if width <= mark_w {
1103        return take_columns(mark, width).to_string();
1104    }
1105    let room = width - mark_w;
1106    let head = take_columns(text, room.div_ceil(2));
1107    let tail = take_columns_end(text, room - display_width(head));
1108    format!("{head}{mark}{tail}")
1109}
1110
1111/// Text written with `·` between its parts, in the glyph set's middot: `-` on an ASCII
1112/// terminal. A `·` in a UI string is this template, drawn through here.
1113pub fn dotted(text: &str) -> String {
1114    text.replace('·', get().middot)
1115}
1116
1117#[cfg(test)]
1118mod tests;