Skip to main content

datui_lib/
glyphs.rs

1//! Terminal glyphs, with an ASCII fallback.
2//!
3//! datui runs on a modern desktop terminal *and* over SSH on a plain server with a
4//! bitmap font and a C locale. Neither should be the one that suffers: on a capable
5//! terminal the box-drawing and arrow characters carry real meaning, and on a
6//! limited one they turn into replacement boxes that make the UI harder to read
7//! rather than prettier.
8//!
9//! Nothing here is a Nerd Font glyph. Every Unicode character has passed the
10//! font-coverage audit (`scripts/code/audit_glyphs.py`): present in JetBrainsMono
11//! Nerd Font, and never an `Emoji=Yes, Emoji_Presentation=No` codepoint that
12//! Liberation Mono and Noto Sans Mono don't also carry, because a terminal whose
13//! font lacks one of those falls back to the *color emoji* font and renders a
14//! blank cell or a clipped blob (#325). Nerd Font icons appear only in the Omarchy
15//! menu definition, where the font is guaranteed — and in a user's own `[glyphs]`
16//! overrides, where the risk is theirs. The ASCII fallback exists for terminals
17//! that are not doing UTF-8 at all.
18
19use ratatui::buffer::Buffer;
20use ratatui::layout::Rect;
21use ratatui::symbols::{Marker, line};
22use std::borrow::Cow;
23use std::collections::BTreeMap;
24use std::sync::OnceLock;
25use unicode_width::UnicodeWidthStr;
26
27/// Symbols used by the UI, in whichever alphabet the terminal can render.
28#[derive(Debug, Clone, Copy)]
29pub struct Glyphs {
30    /// Whether this is the Unicode set. `UNICODE` and `ASCII` are consts, so
31    /// every use instantiates fresh promoted statics — neither the set's address
32    /// nor its fields' can identify it. The set says itself.
33    pub unicode: bool,
34    /// Marks the selected row — the one thing that must be findable instantly.
35    pub selector: &'static str,
36    /// Non-selected row indent; must be the same display width as `selector`.
37    pub selector_blank: &'static str,
38    /// Text cursor in an input.
39    pub cursor: &'static str,
40    /// Prompt marker.
41    pub prompt: &'static str,
42    /// Vertical rule between panes.
43    pub rule: &'static str,
44    /// The frozen-columns separator while the window is too narrow for every frozen
45    /// column: the rest scroll after it until there is room. One column wide, like
46    /// `rule`, and visibly not it.
47    pub rule_broken: &'static str,
48    /// Truncation marker.
49    pub ellipsis: &'static str,
50    /// Between row and column counts: `2.4M × 18`.
51    pub times: &'static str,
52    /// Section collapse markers; both must be the same display width.
53    pub collapsed: &'static str,
54    pub expanded: &'static str,
55    /// Keycap names for the control bar. Named keys are spelled out there, matching
56    /// the rest of datui, so only the arrows need a fallback.
57    pub updown: &'static str,
58    /// Left/right pair, for the fold hint.
59    pub updown_lr: &'static str,
60    /// Ctrl plus the up/down pair, for the section-jump chip.
61    pub ctrl_updown: &'static str,
62    /// Separator between facts in a status line: `listing · nfs`.
63    pub middot: &'static str,
64    /// A fact that is not there: a size no footer stated, a format nothing named.
65    /// Also joins a note's summary to its scope.
66    pub dash: &'static str,
67    /// The coefficient of determination, in the regression fit line.
68    pub r_squared: &'static str,
69    /// Spearman's rank correlation, in the correlation matrix.
70    pub rho: &'static str,
71    /// Spinner frames, cycled while something is loading. Every frame must be the
72    /// same display width, or the text beside it jitters.
73    pub spinner: &'static [&'static str],
74    /// Where a row's data lives, shown beside its name.
75    ///
76    /// Beside the name on purpose. The detail pane has said this for a long time, but
77    /// on a full-screen ultrawide the pane is a foot away from the row the cursor is
78    /// on, and "is this one in the cloud?" is a question you ask about the row you are
79    /// looking at. All five must be the same display width or every name after them
80    /// shifts by a column.
81    pub here: &'static str,
82    pub in_memory: &'static str,
83    pub over_network: &'static str,
84    pub in_object_store: &'static str,
85    pub place_unknown: &'static str,
86    /// A null cell. Blank is what a null used to be, and blank is also what an empty
87    /// string is, so the two were indistinguishable.
88    pub null: &'static str,
89    /// A cell whose file has no such column: not a null the data holds, but a column
90    /// that file was written without. One character wide, like `null`, so a column of
91    /// them lines up.
92    pub absent: &'static str,
93    /// A cell whose file stores the column in a type the column cannot hold, so it was
94    /// not read from that file. A value is there; it is not this type.
95    pub conflict: &'static str,
96    /// After a column's name in the header: this column is not in every file, or the
97    /// files disagree on its type. A footnote mark, and the Info panel is the note.
98    pub drift_mark: &'static str,
99    /// After a column's name in the header: the view is sorted by this column, and
100    /// which way. The header width arithmetic counts these like `drift_mark`, so both
101    /// must be one column wide in both sets. Triangles, not `arrow_left`/`arrow_right`,
102    /// which already mean "columns off-screen" in the same header row.
103    pub sort_asc: &'static str,
104    pub sort_desc: &'static str,
105    /// The rail down the left edge of the row the cursor is on.
106    pub rail: &'static str,
107    /// Rule drawn beside a section title: resting, and under the cursor.
108    pub rule_h: &'static str,
109    pub rule_h_focused: &'static str,
110    /// Arrows for the off-screen column hints in the table header.
111    pub arrow_left: &'static str,
112    pub arrow_right: &'static str,
113    /// Between the steps of a location trail: `cloud › Azure › datui-test`.
114    pub trail: &'static str,
115    /// Either side of a choice shown alone because its values do not fit on its
116    /// row: `‹ TSV ›`, which ←/→ step. One column wide in both sets.
117    pub choice_prev: &'static str,
118    pub choice_next: &'static str,
119    /// After a column's name in a column list: hidden from the table. One column
120    /// wide in both sets, like the header marks.
121    pub hidden_mark: &'static str,
122    /// After a field in the inspector's Compare column: the two rows' values
123    /// differ. One column wide in both sets.
124    pub diff_mark: &'static str,
125    /// Checkbox states, for toggle lists.
126    pub checkbox_on: &'static str,
127    pub checkbox_off: &'static str,
128    /// Radio button states, for pick-one lists.
129    pub radio_on: &'static str,
130    pub radio_off: &'static str,
131    /// Single-cell state dots: all, some, none. One column wide in both sets.
132    pub dot_full: &'static str,
133    pub dot_half: &'static str,
134    pub dot_empty: &'static str,
135    /// Five ascending levels for a score shown as one character.
136    pub score_marks: &'static [&'static str; 5],
137    /// A confirmation mark.
138    pub check: &'static str,
139    /// A caution mark.
140    pub warning: &'static str,
141    /// Scrollbar thumb, drawn down the right edge of an overlay.
142    pub scroll_thumb: &'static str,
143    /// The scrollbar's track, above and below the thumb: a lighter shade of it.
144    pub scroll_track: &'static str,
145    /// Stands in for a value that is bytes, not text.
146    pub binary_stub: &'static str,
147    /// In a one-line preview of text, where the value has a line break, a tab, or
148    /// another control character, which a terminal cell cannot draw. One column
149    /// wide in both sets. The inspector shows the characters themselves.
150    pub newline_mark: &'static str,
151    pub tab_mark: &'static str,
152    pub control_mark: &'static str,
153    /// A byte in the hex view's ASCII gutter that is not printable. One column wide
154    /// in both sets.
155    pub hex_dot: &'static str,
156    /// Eight compact levels for inline charts (lowest to highest).
157    pub mini_bars: &'static [&'static str; 8],
158    /// In a line of `mini_bars`, a place with nothing measured: segments a sample
159    /// drew no row from. One column wide in both sets, and unlike every bar level.
160    pub unsampled: &'static str,
161    /// Under a line of bars, the one selected. One column wide in both sets.
162    pub pointer: &'static str,
163    /// A horizontal bar's end, one to eight eighths of a cell filled from the left;
164    /// the last is a whole cell, the bar's body. One column wide in both sets.
165    pub bar_eighths: &'static [&'static str; 8],
166    /// The home-screen wordmark, three rows of box drawing. `None` when the terminal
167    /// cannot draw it, and the one-line title bar is used instead.
168    pub wordmark: Option<&'static [&'static str]>,
169    /// The one border every Surface draws. Not a `[glyphs]` override slot:
170    /// its eight pieces must agree with each other, and ratatui draws them.
171    pub border: ratatui::symbols::border::Set<'static>,
172    /// What the plots draw with. Not a `[glyphs]` override slot: ratatui draws
173    /// these, and the Unicode marks are whole blocks of braille and eighths.
174    pub plot: PlotMarks,
175}
176
177/// The marks ratatui's `Chart`, `Canvas` and `BarChart` put on a plot, and the lines
178/// of its axes and legend frame. ratatui picks none of these from the locale, so each
179/// set names its own.
180#[derive(Debug, Clone, Copy)]
181pub struct PlotMarks {
182    /// A line: an XY line, a density curve, a fit drawn over bars, a dense Q-Q plot.
183    pub line: Marker,
184    /// One mark per point: a scatter, a box plot's strokes, a sparse Q-Q plot.
185    pub point: Marker,
186    /// A column from zero up to each point: the XY bar style and the histogram.
187    pub bar: Marker,
188    /// A vertical bar's top, one to eight eighths of a cell filled from the bottom;
189    /// the last is a whole cell, the bar's body.
190    pub column_eighths: &'static [&'static str; 8],
191    /// The axes and the legend frame.
192    pub axis: line::Set<'static>,
193    /// A tick on the x axis line and on the y axis line, pointing at its label.
194    pub tick_x: &'static str,
195    pub tick_y: &'static str,
196    /// The grid: a dotted line across the plot at a y tick, and down it at an x tick.
197    pub grid_across: &'static str,
198    pub grid_down: &'static str,
199}
200
201impl PlotMarks {
202    /// The vertical bars a `BarChart` draws with.
203    pub fn column_set(&self) -> ratatui::symbols::bar::Set<'static> {
204        let e = self.column_eighths;
205        ratatui::symbols::bar::Set {
206            full: e[7],
207            seven_eighths: e[6],
208            three_quarters: e[5],
209            five_eighths: e[4],
210            half: e[3],
211            three_eighths: e[2],
212            one_quarter: e[1],
213            one_eighth: e[0],
214            empty: " ",
215        }
216    }
217
218    /// Whether a canvas drawing with `marker` put this symbol in its cell, rather
219    /// than an axis or a label. Braille's blank pattern counts: the grid draws it.
220    pub fn is_mark(marker: Marker, symbol: &str) -> bool {
221        let mut chars = symbol.chars();
222        let (Some(c), None) = (chars.next(), chars.next()) else {
223            return false;
224        };
225        match marker {
226            Marker::Braille => ('\u{2800}'..='\u{28ff}').contains(&c),
227            Marker::Dot => symbol == ratatui::symbols::DOT,
228            Marker::Custom(mark) => c == mark,
229            _ => false,
230        }
231    }
232
233    /// ratatui's `Chart` draws its axes and legend frame from `line::NORMAL`
234    /// whatever the set, and exposes neither; this finds them by shape and redraws
235    /// them from the set's own `axis` lines. The axes are the `└` with `│` above it
236    /// and a `─` run to its right that no `┘` closes; the legend is a closed box.
237    /// A label, title or name holding the same characters is left alone. A chart
238    /// too small for both axes has no corner to find them by, so there every line
239    /// cell changes. Nothing changes under the Unicode set.
240    pub fn redraw_axes(&self, area: Rect, buf: &mut Buffer) {
241        let (from, to) = (line::NORMAL, self.axis);
242        if from == to {
243            return;
244        }
245        let area = area.intersection(buf.area);
246        let at = |x: u16, y: u16| buf[(x, y)].symbol();
247        // How many cells in a row hold `symbol`, stepping from (x, y) by (dx, dy).
248        let run = |x: u16, y: u16, (dx, dy): (i32, i32), symbol: &str| {
249            let mut n = 0;
250            let (mut cx, mut cy) = (i32::from(x) + dx, i32::from(y) + dy);
251            while (i32::from(area.left())..i32::from(area.right())).contains(&cx)
252                && (i32::from(area.top())..i32::from(area.bottom())).contains(&cy)
253                && at(cx as u16, cy as u16) == symbol
254            {
255                n += 1;
256                cx += dx;
257                cy += dy;
258            }
259            n
260        };
261        let (up, down, right) = ((0, -1), (0, 1), (1, 0));
262        let mut frame = Vec::new();
263        let mut found_axes = false;
264        for y in area.top()..area.bottom() {
265            for x in area.left()..area.right() {
266                let symbol = at(x, y);
267                if symbol == from.bottom_left {
268                    let (high, wide) = (
269                        run(x, y, up, from.vertical),
270                        run(x, y, right, from.horizontal),
271                    );
272                    let end = x + wide + 1;
273                    let boxed = end < area.right() && at(end, y) == from.bottom_right;
274                    if high > 0 && wide > 0 && !boxed {
275                        found_axes = true;
276                        frame.extend((y - high..=y).map(|y| (x, y)));
277                        frame.extend((x + 1..end).map(|x| (x, y)));
278                    }
279                } else if symbol == from.top_left {
280                    let wide = run(x, y, right, from.horizontal);
281                    let high = run(x, y, down, from.vertical);
282                    let (r, b) = (x + wide + 1, y + high + 1);
283                    let closed = r < area.right()
284                        && b < area.bottom()
285                        && at(r, y) == from.top_right
286                        && at(x, b) == from.bottom_left
287                        && at(r, b) == from.bottom_right
288                        && run(r, y, down, from.vertical) == high
289                        && run(x, b, right, from.horizontal) == wide;
290                    if closed {
291                        for i in x..=r {
292                            frame.extend([(i, y), (i, b)]);
293                        }
294                        for j in y + 1..b {
295                            frame.extend([(x, j), (r, j)]);
296                        }
297                    }
298                }
299            }
300        }
301        if !found_axes {
302            frame = (area.top()..area.bottom())
303                .flat_map(|y| (area.left()..area.right()).map(move |x| (x, y)))
304                .collect();
305        }
306        let pairs = [
307            (from.vertical, to.vertical),
308            (from.horizontal, to.horizontal),
309            (from.top_left, to.top_left),
310            (from.top_right, to.top_right),
311            (from.bottom_left, to.bottom_left),
312            (from.bottom_right, to.bottom_right),
313        ];
314        for (x, y) in frame {
315            let cell = &mut buf[(x, y)];
316            if let Some((_, twin)) = pairs.iter().find(|(line, _)| cell.symbol() == *line) {
317                cell.set_symbol(twin);
318            }
319        }
320    }
321}
322
323const UNICODE: Glyphs = Glyphs {
324    unicode: true,
325    selector: "▎ ",
326    selector_blank: "  ",
327    cursor: "▏",
328    prompt: "› ",
329    rule: "│",
330    rule_broken: "┆",
331    ellipsis: "…",
332    times: "×",
333    collapsed: "▸ ",
334    expanded: "▾ ",
335    updown: "↑↓",
336    updown_lr: "←→",
337    ctrl_updown: "^↑↓",
338    middot: "·",
339    dash: "—",
340    r_squared: "R²",
341    rho: "ρ",
342    spinner: &["⣷", "⣯", "⣟", "⡿", "⢿", "⣻", "⣽", "⣾"],
343    // Plain Unicode from blocks the common coding fonts actually cover — checked
344    // against JetBrainsMono Nerd Font, Liberation Mono and Noto Sans Mono per
345    // codepoint (`fc-list "<font>:charset=<hex>"`). A slot the terminal font lacks
346    // is worse than absent: an `Emoji=Yes, Emoji_Presentation=No` codepoint (☁, ☑)
347    // falls back to the *color emoji* font and renders a blank cell or a clipped
348    // blob, and no font the user picks fixes that. That audit retired ☁ U+2601,
349    // ⇅ U+21C5, ☑/☐ U+2611/U+2610 and ◐/◑ U+25D0/U+25D1 from this set (#325).
350    here: "◦",
351    in_memory: "▪",
352    over_network: "↕",
353    in_object_store: "≈",
354    place_unknown: "◌",
355    null: "∅",
356    absent: "·",
357    conflict: "≠",
358    drift_mark: "*",
359    sort_asc: "▲",
360    sort_desc: "▼",
361    rail: "▎",
362    rule_h: "─",
363    rule_h_focused: "━",
364    arrow_left: "←",
365    arrow_right: "→",
366    trail: "›",
367    choice_prev: "‹",
368    choice_next: "›",
369    hidden_mark: "⊘",
370    diff_mark: "Δ",
371    checkbox_on: "■",
372    checkbox_off: "□",
373    radio_on: "●",
374    radio_off: "○",
375    dot_full: "●",
376    dot_half: "◔",
377    dot_empty: "○",
378    score_marks: &["○", "◔", "◕", "◉", "●"],
379    check: "✓",
380    // Not ⚠ U+26A0: emoji-class, and absent from Liberation Mono and Noto Sans
381    // Mono, so those setups hit the color-emoji fallback. The caution triangle's
382    // shape, from a codepoint all three floor fonts carry.
383    warning: "▲",
384    scroll_thumb: "█",
385    scroll_track: "░",
386    binary_stub: "‹binary›",
387    // Latin-1, which every floor font carries; the arrows and control pictures
388    // (↵ ⇥ ␊) are missing from Liberation Mono and Noto Sans Mono.
389    newline_mark: "¶",
390    tab_mark: "»",
391    control_mark: "¤",
392    hex_dot: "·",
393    mini_bars: &["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"],
394    unsampled: "·",
395    pointer: "▲",
396    bar_eighths: &["▏", "▎", "▍", "▌", "▋", "▊", "▉", "█"],
397    wordmark: Some(&[
398        "┌──╮ ╭──╮ ╶─┬─╴ ╷  ╷ ╶┬╴",
399        "│  │ ├──┤   │   │  │  │ ",
400        "└──╯ ╵  ╵   ╵   ╰──╯ ╶┴╴",
401    ]),
402    border: ratatui::symbols::border::ROUNDED,
403    plot: PlotMarks {
404        line: Marker::Braille,
405        point: Marker::Dot,
406        bar: Marker::HalfBlock,
407        column_eighths: &["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"],
408        axis: line::NORMAL,
409        tick_x: "┬",
410        tick_y: "┤",
411        grid_across: "·",
412        grid_down: "┊",
413    },
414};
415
416const ASCII: Glyphs = Glyphs {
417    unicode: false,
418    selector: "> ",
419    selector_blank: "  ",
420    cursor: "_",
421    prompt: "> ",
422    rule: "|",
423    rule_broken: ":",
424    ellipsis: "...",
425    times: "x",
426    collapsed: "+ ",
427    expanded: "- ",
428    updown: "Up/Dn",
429    updown_lr: "Lt/Rt",
430    ctrl_updown: "^Up/Dn",
431    middot: "-",
432    dash: "-",
433    r_squared: "R^2",
434    rho: "rho",
435    spinner: &["|", "/", "-", "\\"],
436    here: ".",
437    in_memory: "*",
438    over_network: "~",
439    in_object_store: "@",
440    place_unknown: "?",
441    null: "~",
442    absent: ".",
443    conflict: "!",
444    drift_mark: "*",
445    sort_asc: "^",
446    sort_desc: "v",
447    rail: ">",
448    rule_h: "-",
449    rule_h_focused: "=",
450    arrow_left: "<",
451    arrow_right: ">",
452    trail: ">",
453    choice_prev: "<",
454    choice_next: ">",
455    hidden_mark: "x",
456    diff_mark: "*",
457    checkbox_on: "[x]",
458    checkbox_off: "[ ]",
459    radio_on: "(*)",
460    radio_off: "( )",
461    dot_full: "#",
462    dot_half: "+",
463    dot_empty: ".",
464    score_marks: &[".", "-", "+", "*", "#"],
465    check: "+",
466    warning: "!",
467    scroll_thumb: "#",
468    scroll_track: "|",
469    binary_stub: "<binary>",
470    // vim's `list` marks: `$` ends a line, `>` is a tab.
471    newline_mark: "$",
472    tab_mark: ">",
473    control_mark: "?",
474    hex_dot: ".",
475    mini_bars: &[".", ":", "-", "=", "+", "*", "#", "@"],
476    unsampled: "?",
477    pointer: "^",
478    // Under half a cell is still a mark, so a small value never reads as zero.
479    bar_eighths: &["-", "-", "-", "=", "=", "=", "=", "#"],
480    wordmark: None,
481    border: ratatui::symbols::border::Set {
482        top_left: "+",
483        top_right: "+",
484        bottom_left: "+",
485        bottom_right: "+",
486        vertical_left: "|",
487        vertical_right: "|",
488        horizontal_top: "-",
489        horizontal_bottom: "-",
490    },
491    plot: PlotMarks {
492        line: Marker::Custom('*'),
493        point: Marker::Custom('o'),
494        bar: Marker::Custom('#'),
495        // Where the bar's top edge sits in its last cell: low, halfway, full.
496        column_eighths: &["_", "_", "-", "-", "-", "#", "#", "#"],
497        axis: line::Set {
498            vertical: "|",
499            horizontal: "-",
500            top_right: "+",
501            top_left: "+",
502            bottom_right: "+",
503            bottom_left: "+",
504            vertical_left: "+",
505            vertical_right: "+",
506            horizontal_down: "+",
507            horizontal_up: "+",
508            cross: "+",
509        },
510        tick_x: "+",
511        tick_y: "+",
512        grid_across: ".",
513        grid_down: ":",
514    },
515};
516
517/// What the user asked for, from `[display] unicode`.
518#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
519#[serde(rename_all = "lowercase")]
520pub enum UnicodeMode {
521    /// Detect from the environment: see [`environment_is_utf8`].
522    #[default]
523    Auto,
524    Always,
525    Never,
526}
527
528/// One `[glyphs]` override from the config: a single glyph, or a list for the
529/// slots that hold one (`spinner`, `score_marks`, `mini_bars`, `bar_eighths`).
530#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
531#[serde(untagged)]
532pub enum SlotOverride {
533    One(String),
534    Many(Vec<String>),
535}
536
537/// The overridable single-string slots, passed to a callback macro so the name
538/// list, the default lookup and the assignment cannot drift apart. The wordmark
539/// is deliberately absent: it is the brand, and it already yields to the
540/// one-line title wherever it cannot be drawn.
541macro_rules! with_string_slots {
542    ($callback:ident) => {
543        $callback!(
544            selector,
545            selector_blank,
546            cursor,
547            prompt,
548            rule,
549            rule_broken,
550            ellipsis,
551            times,
552            collapsed,
553            expanded,
554            updown,
555            updown_lr,
556            ctrl_updown,
557            middot,
558            dash,
559            r_squared,
560            rho,
561            here,
562            in_memory,
563            over_network,
564            in_object_store,
565            place_unknown,
566            null,
567            absent,
568            conflict,
569            drift_mark,
570            sort_asc,
571            sort_desc,
572            rail,
573            rule_h,
574            rule_h_focused,
575            arrow_left,
576            arrow_right,
577            trail,
578            choice_prev,
579            choice_next,
580            hidden_mark,
581            diff_mark,
582            checkbox_on,
583            checkbox_off,
584            radio_on,
585            radio_off,
586            dot_full,
587            dot_half,
588            dot_empty,
589            check,
590            warning,
591            scroll_thumb,
592            scroll_track,
593            binary_stub,
594            newline_mark,
595            tab_mark,
596            control_mark,
597            hex_dot,
598            unsampled,
599            pointer
600        )
601    };
602}
603
604/// Instructional text with its Unicode characters mapped to ASCII, for the
605/// help overlay on a terminal that is not doing UTF-8. Applied at the render
606/// boundary only — user data is never transliterated. The pairs cover what
607/// the help files actually contain; the audit that counts them is
608/// `every_help_screen_is_ascii_clean`.
609pub fn asciify_instructions(text: &str) -> std::borrow::Cow<'_, str> {
610    if get().unicode || text.is_ascii() {
611        return std::borrow::Cow::Borrowed(text);
612    }
613    std::borrow::Cow::Owned(instructions_in_ascii(text))
614}
615
616/// `text` with every instructional character replaced by its ASCII twin,
617/// whatever the terminal. The twins are wider (`↑` is `Up`), so help rows
618/// laid out as key, two or more spaces, description are re-padded one
619/// section (a run of non-blank lines) at a time: descriptions stay at the
620/// columns they were authored at, and when an ASCII key no longer fits, the
621/// whole section moves right together rather than that one row.
622pub fn instructions_in_ascii(text: &str) -> String {
623    let mut out = String::with_capacity(text.len() + text.len() / 8);
624    let mut section: Vec<&str> = Vec::new();
625    for line in text.split('\n') {
626        if line.trim().is_empty() {
627            push_section(&section, &mut out);
628            section.clear();
629            push_ascii(line, &mut out);
630            out.push('\n');
631        } else {
632            section.push(line);
633        }
634    }
635    push_section(&section, &mut out);
636    // Every line was pushed with a newline; the last one never had it.
637    out.pop();
638    out
639}
640
641/// One section's lines, each followed by a newline. Continuation lines,
642/// indented to one of the section's description columns, move with it.
643fn push_section(lines: &[&str], out: &mut String) {
644    // (key end, description start, description column) per keyed row.
645    let rows: Vec<Option<(usize, usize, usize)>> = lines
646        .iter()
647        .map(|line| key_gap(line).map(|(key, desc)| (key, desc, display_width(&line[..desc]))))
648        .collect();
649    let shift = lines
650        .iter()
651        .zip(&rows)
652        .filter_map(|(line, row)| {
653            row.map(|(key, _, column)| (ascii_width(&line[..key]) + 2).saturating_sub(column))
654        })
655        .max()
656        .unwrap_or(0);
657    for (line, row) in lines.iter().zip(&rows) {
658        match *row {
659            Some((key, desc, column)) => {
660                push_ascii(&line[..key], out);
661                let pad = column + shift - ascii_width(&line[..key]);
662                out.extend(std::iter::repeat_n(' ', pad));
663                push_ascii(&line[desc..], out);
664            }
665            None => {
666                let indent = line.len() - line.trim_start_matches(' ').len();
667                if shift > 0 && rows.iter().flatten().any(|&(_, _, c)| c == indent) {
668                    out.extend(std::iter::repeat_n(' ', shift));
669                }
670                push_ascii(line, out);
671            }
672        }
673        out.push('\n');
674    }
675}
676
677/// Columns `text` takes once its instructional characters are ASCII.
678fn ascii_width(text: &str) -> usize {
679    let mut ascii = String::new();
680    push_ascii(text, &mut ascii);
681    ascii.len()
682}
683
684/// Where a help row's key ends and its description starts: the first run of
685/// two or more spaces after the indent, with text after it. `None` for a
686/// line without one (prose, a heading, a blank line). Help prose takes one
687/// space between sentences, so the first double space is always a key gap.
688pub(crate) fn key_gap(line: &str) -> Option<(usize, usize)> {
689    let lead = line.len() - line.trim_start_matches(' ').len();
690    let key_end = lead + line[lead..].find("  ")?;
691    let desc_start = line.len() - line[key_end..].trim_start_matches(' ').len();
692    (desc_start < line.len()).then_some((key_end, desc_start))
693}
694
695fn push_ascii(text: &str, out: &mut String) {
696    let mut chars = text.chars().peekable();
697    while let Some(c) = chars.next() {
698        // A paired arrow is one key, spelled as the glyph set spells it;
699        // twin by twin it would read "UpDn".
700        let pair = match (c, chars.peek()) {
701            ('↑', Some('↓')) => Some(ASCII.updown),
702            ('←', Some('→')) => Some(ASCII.updown_lr),
703            _ => None,
704        };
705        if let Some(pair) = pair {
706            chars.next();
707            out.push_str(pair);
708            continue;
709        }
710        match ascii_twin(c) {
711            Some(twin) => out.push_str(twin),
712            None if c.is_ascii() => out.push(c),
713            // A character the map does not know is marked rather than
714            // shipped to a terminal that cannot draw it; the audit test
715            // keeps this case from ever being reachable from a help file.
716            None => out.push('?'),
717        }
718    }
719}
720
721/// Display columns `text` will occupy, as the terminal draws it. Scalar
722/// counts undercount CJK and overcount combining marks; layout math that
723/// budgets cells must use this.
724pub fn display_width(text: &str) -> usize {
725    UnicodeWidthStr::width(text)
726}
727
728/// The longest prefix of `text` that fits `width` display columns, never
729/// splitting a wide character.
730pub fn take_columns(text: &str, width: usize) -> &str {
731    let mut used = 0usize;
732    for (i, c) in text.char_indices() {
733        let w = unicode_width::UnicodeWidthChar::width(c).unwrap_or(0);
734        if used + w > width {
735            return &text[..i];
736        }
737        used += w;
738    }
739    text
740}
741
742/// The longest suffix of `text` that fits `width` display columns, never
743/// splitting a wide character.
744pub fn take_columns_end(text: &str, width: usize) -> &str {
745    let mut used = 0usize;
746    let mut start = text.len();
747    for (i, c) in text.char_indices().rev() {
748        let w = unicode_width::UnicodeWidthChar::width(c).unwrap_or(0);
749        if used + w > width {
750            break;
751        }
752        used += w;
753        start = i;
754    }
755    &text[start..]
756}
757
758/// Printable ASCII only: one byte, one cell, one grapheme, so the fast paths
759/// below need no segmentation.
760fn plain_ascii(text: &str) -> bool {
761    text.bytes().all(|b| (0x20..0x7f).contains(&b))
762}
763
764/// The graphemes of `text` as ratatui draws them: its own segmentation, with the
765/// clusters holding a control character dropped as its `Span` drops them.
766fn drawn_graphemes<'a>(span: &'a ratatui::text::Span<'a>) -> impl Iterator<Item = &'a str> {
767    span.styled_graphemes(ratatui::style::Style::default())
768        .map(|g| g.symbol)
769}
770
771/// Cells `text` takes when a table cell draws it, grapheme by grapheme at
772/// ratatui's own widths. Unlike [`display_width`], an emoji sequence joined
773/// into one grapheme counts once, and control characters count nothing,
774/// because ratatui draws nothing for them.
775pub fn cell_width(text: &str) -> usize {
776    use ratatui::buffer::CellWidth;
777    if plain_ascii(text) {
778        return text.len();
779    }
780    let span = ratatui::text::Span::raw(text);
781    drawn_graphemes(&span)
782        .map(|g| usize::from(g.cell_width()))
783        .sum()
784}
785
786/// `text` as it fits in `width` cells: whole when it fits, otherwise cut at a
787/// grapheme boundary and closed with `marker`, so a clipped value never passes
788/// for a whole one. A wide character that would straddle the edge goes, never
789/// half of it. Control characters are dropped, as ratatui would drop them, so
790/// the result is exactly what is drawn. When even `marker` does not fit, as much
791/// of it as fits.
792pub fn fit_cells<'a>(text: &'a str, width: usize, marker: &str) -> Cow<'a, str> {
793    use ratatui::buffer::CellWidth;
794    let marker_width = cell_width(marker);
795    if plain_ascii(text) {
796        if text.len() <= width {
797            return Cow::Borrowed(text);
798        }
799        if width <= marker_width {
800            return Cow::Owned(take_columns(marker, width).to_string());
801        }
802        return Cow::Owned(format!("{}{marker}", &text[..width - marker_width]));
803    }
804    let span = ratatui::text::Span::raw(text);
805    let total: usize = drawn_graphemes(&span)
806        .map(|g| usize::from(g.cell_width()))
807        .sum();
808    if total <= width {
809        let whole = drawn_graphemes(&span).map(str::len).sum::<usize>() == text.len();
810        return if whole {
811            Cow::Borrowed(text)
812        } else {
813            Cow::Owned(drawn_graphemes(&span).collect())
814        };
815    }
816    if width <= marker_width {
817        return Cow::Owned(take_columns(marker, width).to_string());
818    }
819    let budget = width - marker_width;
820    let mut used = 0usize;
821    let mut out = String::with_capacity(budget + marker.len());
822    for g in drawn_graphemes(&span) {
823        let w = usize::from(g.cell_width());
824        if used + w > budget {
825            break;
826        }
827        used += w;
828        out.push_str(g);
829    }
830    out.push_str(marker);
831    Cow::Owned(out)
832}
833
834/// The ASCII twin of one instructional character, `None` for plain ASCII or
835/// a character no help file may use.
836fn ascii_twin(c: char) -> Option<&'static str> {
837    match c {
838        '↑' => Some("Up"),
839        '↓' => Some("Dn"),
840        '←' => Some("Lt"),
841        '→' => Some("Rt"),
842        '↔' => Some("Lt/Rt"),
843        '—' => Some("-"),
844        '…' => Some("..."),
845        '×' => Some("x"),
846        '≤' => Some("<="),
847        '≥' => Some(">="),
848        '∅' => Some("~"),
849        '·' => Some("."),
850        '≠' => Some("!"),
851        'ρ' => Some("rho"),
852        '│' => Some("|"),
853        '┆' => Some(":"),
854        '¶' => Some("$"),
855        '»' => Some(">"),
856        '¤' => Some("?"),
857        'Δ' => Some("*"),
858        _ => None,
859    }
860}
861
862/// The Unicode default for a single-string slot, or `None` for a list slot or
863/// an unknown name.
864fn unicode_default(slot: &str) -> Option<&'static str> {
865    macro_rules! lookup {
866        ($($name:ident),*) => {
867            match slot {
868                $(stringify!($name) => Some(UNICODE.$name),)*
869                _ => None,
870            }
871        };
872    }
873    with_string_slots!(lookup)
874}
875
876/// Check a `[glyphs]` override map without touching the active set, so a bad
877/// config fails at load time with the slot named, not mid-draw.
878///
879/// An override must keep the display width of the glyph it replaces: every
880/// width invariant in the layout arithmetic — the locality markers, the header
881/// marks, the equal-width spinner frames — holds automatically that way.
882pub fn validate_overrides(overrides: &BTreeMap<String, SlotOverride>) -> Result<(), String> {
883    let same_width = |slot: &str, text: &str, default: &str| -> Result<(), String> {
884        if text.width() == default.width() {
885            Ok(())
886        } else {
887            Err(format!(
888                "glyph override for `{slot}` is {} columns wide; {default:?} is {} — \
889                 an override must keep the width of the glyph it replaces",
890                text.width(),
891                default.width(),
892            ))
893        }
894    };
895    for (slot, value) in overrides {
896        match (unicode_default(slot), value) {
897            (Some(default), SlotOverride::One(text)) => same_width(slot, text, default)?,
898            (Some(_), SlotOverride::Many(_)) => {
899                return Err(format!(
900                    "glyph slot `{slot}` takes a single string, not a list"
901                ));
902            }
903            (None, _) => {
904                let (len, default) = match slot.as_str() {
905                    "spinner" => (None, UNICODE.spinner),
906                    "score_marks" => (Some(5), &UNICODE.score_marks[..]),
907                    "mini_bars" => (Some(8), &UNICODE.mini_bars[..]),
908                    "bar_eighths" => (Some(8), &UNICODE.bar_eighths[..]),
909                    _ => return Err(format!("unknown glyph slot `{slot}`")),
910                };
911                let SlotOverride::Many(entries) = value else {
912                    return Err(format!("glyph slot `{slot}` takes a list of strings"));
913                };
914                match len {
915                    Some(len) if entries.len() != len => {
916                        return Err(format!(
917                            "glyph slot `{slot}` takes exactly {len} entries, got {}",
918                            entries.len()
919                        ));
920                    }
921                    None if entries.is_empty() => {
922                        return Err(format!("glyph slot `{slot}` takes at least one entry"));
923                    }
924                    _ => {}
925                }
926                for entry in entries {
927                    same_width(slot, entry, default[0])?;
928                }
929            }
930        }
931    }
932    Ok(())
933}
934
935/// A config string lives as long as the run does; the set holds `&'static str`.
936fn leak(text: &str) -> &'static str {
937    Box::leak(text.to_string().into_boxed_str())
938}
939
940/// Lay a validated override map over a set. Called once at startup.
941fn apply_overrides(set: &mut Glyphs, overrides: &BTreeMap<String, SlotOverride>) {
942    for (slot, value) in overrides {
943        match (slot.as_str(), value) {
944            ("spinner", SlotOverride::Many(frames)) => {
945                set.spinner = Box::leak(
946                    frames
947                        .iter()
948                        .map(|f| leak(f))
949                        .collect::<Vec<_>>()
950                        .into_boxed_slice(),
951                );
952            }
953            ("score_marks", SlotOverride::Many(marks)) if marks.len() == 5 => {
954                set.score_marks = Box::leak(Box::new(std::array::from_fn(|i| leak(&marks[i]))));
955            }
956            ("mini_bars", SlotOverride::Many(bars)) if bars.len() == 8 => {
957                set.mini_bars = Box::leak(Box::new(std::array::from_fn(|i| leak(&bars[i]))));
958            }
959            ("bar_eighths", SlotOverride::Many(bars)) if bars.len() == 8 => {
960                set.bar_eighths = Box::leak(Box::new(std::array::from_fn(|i| leak(&bars[i]))));
961            }
962            (name, SlotOverride::One(text)) => {
963                let text = leak(text);
964                macro_rules! assign {
965                    ($($slot:ident),*) => {
966                        match name {
967                            $(stringify!($slot) => set.$slot = text,)*
968                            // Validated at config load; an unknown name that
969                            // still got here changes nothing.
970                            _ => {}
971                        }
972                    };
973                }
974                with_string_slots!(assign)
975            }
976            _ => {}
977        }
978    }
979}
980
981static GLYPHS: OnceLock<Glyphs> = OnceLock::new();
982
983/// The signals the glyph choice reads, gathered so the rule can be tested on
984/// any OS.
985#[derive(Debug, Clone, Default, PartialEq, Eq)]
986struct Environment {
987    /// The first non-empty of `LC_ALL`, `LC_CTYPE`, `LANG`.
988    locale: Option<String>,
989    /// A terminal that draws these glyphs whatever the code page: Windows
990    /// Terminal (`WT_SESSION`) or VS Code's (`TERM_PROGRAM=vscode`). Always false
991    /// off Windows.
992    unicode_terminal: bool,
993    /// The console output code page. Always `None` off Windows.
994    console_code_page: Option<u32>,
995}
996
997/// UTF-8, as a Windows code page.
998const CP_UTF8: u32 = 65001;
999
1000impl Environment {
1001    fn current() -> Self {
1002        let locale = ["LC_ALL", "LC_CTYPE", "LANG"]
1003            .into_iter()
1004            .find_map(|key| std::env::var(key).ok().filter(|v| !v.is_empty()));
1005        #[cfg(windows)]
1006        {
1007            // SAFETY: takes no arguments and only reads console state; 0 means
1008            // there is no console.
1009            let page = unsafe { windows_sys::Win32::System::Console::GetConsoleOutputCP() };
1010            Self {
1011                locale,
1012                unicode_terminal: std::env::var_os("WT_SESSION").is_some()
1013                    || std::env::var_os("TERM_PROGRAM").is_some_and(|t| t == "vscode"),
1014                console_code_page: (page != 0).then_some(page),
1015            }
1016        }
1017        #[cfg(not(windows))]
1018        Self {
1019            locale,
1020            ..Self::default()
1021        }
1022    }
1023
1024    /// The rule. A locale variable decides when one is set, on every OS, so
1025    /// `LANG=C` means ASCII everywhere and MSYS2 shells on Windows count as
1026    /// they do on Unix. Windows itself sets none: there, Windows Terminal or
1027    /// VS Code (whose consoles default to an OEM code page, but which draw
1028    /// these glyphs) or a console switched to UTF-8 (`chcp 65001`, or the
1029    /// system "Use Unicode UTF-8" option) picks Unicode.
1030    fn is_utf8(&self) -> bool {
1031        match &self.locale {
1032            Some(value) => {
1033                let lower = value.to_ascii_lowercase();
1034                lower.contains("utf-8") || lower.contains("utf8")
1035            }
1036            None => self.unicode_terminal || self.console_code_page == Some(CP_UTF8),
1037        }
1038    }
1039}
1040
1041/// Whether the terminal can be trusted with UTF-8.
1042///
1043/// `LC_ALL` beats `LC_CTYPE` beats `LANG`, as in POSIX. With none of them set,
1044/// Windows counts as UTF-8 under Windows Terminal, VS Code's terminal or a
1045/// UTF-8 console code page. A terminal that is not doing UTF-8 renders
1046/// multi-byte characters as replacement boxes, so this is the signal that
1047/// matters, not terminal capability, which says nothing about the font.
1048pub fn environment_is_utf8() -> bool {
1049    Environment::current().is_utf8()
1050}
1051
1052/// Choose the glyph set for this run. Later calls are ignored, so this is safe to
1053/// call once from startup and never think about again.
1054pub fn init(mode: UnicodeMode) {
1055    init_with_overrides(mode, &BTreeMap::new());
1056}
1057
1058/// [`init`], with the config's `[glyphs]` overrides laid over the Unicode set.
1059/// The ASCII set is never touched: it is the tested floor a C locale falls back
1060/// to, and an override written for a rich font would garble exactly there.
1061pub fn init_with_overrides(mode: UnicodeMode, overrides: &BTreeMap<String, SlotOverride>) {
1062    let mut chosen = match mode {
1063        UnicodeMode::Always => UNICODE,
1064        UnicodeMode::Never => ASCII,
1065        UnicodeMode::Auto => {
1066            if environment_is_utf8() {
1067                UNICODE
1068            } else {
1069                ASCII
1070            }
1071        }
1072    };
1073    if chosen.unicode && !overrides.is_empty() {
1074        apply_overrides(&mut chosen, overrides);
1075    }
1076    let _ = GLYPHS.set(chosen);
1077}
1078
1079/// The active glyph set. Falls back to detection when [`init`] was never
1080/// called, so library users and tests get sensible symbols without ceremony.
1081pub fn get() -> &'static Glyphs {
1082    GLYPHS.get_or_init(|| {
1083        if environment_is_utf8() {
1084            UNICODE
1085        } else {
1086            ASCII
1087        }
1088    })
1089}
1090
1091/// Whether the active set is the Unicode one. `get` hands out a copy of a
1092/// const, so no address — the set's nor a field's — can identify it; the flag
1093/// on the set can.
1094pub fn active_is_unicode() -> bool {
1095    get().unicode
1096}
1097
1098/// The Unicode set, for tests and for callers that know their output is UTF-8.
1099pub fn unicode() -> &'static Glyphs {
1100    &UNICODE
1101}
1102
1103/// Each bordered box drawn in `rows` (one string per screen row), as the
1104/// (column, row) of its bottom-left corner, in the active set.
1105///
1106/// For tests that count frames. The ASCII set draws every corner as `+`, so
1107/// counting the corner glyph finds four per box there; a bottom-left corner
1108/// is the one with the left side above it and the bottom edge after it.
1109#[cfg(test)]
1110pub(crate) fn frame_corners(rows: &[String]) -> Vec<(usize, usize)> {
1111    let b = get().border;
1112    let grid: Vec<Vec<String>> = rows
1113        .iter()
1114        .map(|r| r.chars().map(String::from).collect())
1115        .collect();
1116    let at = |x: usize, y: usize| grid.get(y).and_then(|r| r.get(x)).map(String::as_str);
1117    // Where each corner has a glyph of its own, every one on screen is a box's,
1118    // whatever its shape: count them all, and every box opened must close.
1119    let glyphs = [b.top_left, b.top_right, b.bottom_left, b.bottom_right];
1120    if (1..4).all(|i| !glyphs[..i].contains(&glyphs[i])) {
1121        let opened: usize = rows.iter().map(|r| r.matches(b.top_left).count()).sum();
1122        let closed: Vec<(usize, usize)> = grid
1123            .iter()
1124            .enumerate()
1125            .flat_map(|(y, row)| {
1126                row.iter()
1127                    .enumerate()
1128                    .filter(|(_, cell)| cell.as_str() == b.bottom_left)
1129                    .map(move |(x, _)| (x, y))
1130            })
1131            .collect();
1132        assert_eq!(opened, closed.len(), "every frame closes: {rows:#?}");
1133        return closed;
1134    }
1135    let mut corners = Vec::new();
1136    for (y, row) in grid.iter().enumerate().skip(1) {
1137        for x in 0..row.len() {
1138            if at(x, y) == Some(b.bottom_left)
1139                && at(x, y - 1) == Some(b.vertical_left)
1140                && at(x + 1, y) == Some(b.horizontal_bottom)
1141                && (x == 0 || at(x - 1, y) != Some(b.horizontal_bottom))
1142            {
1143                corners.push((x, y));
1144            }
1145        }
1146    }
1147    corners
1148}
1149
1150/// The ASCII set.
1151pub fn ascii() -> &'static Glyphs {
1152    &ASCII
1153}
1154
1155#[cfg(test)]
1156mod tests {
1157    use super::*;
1158    use unicode_width::UnicodeWidthStr;
1159
1160    /// Every character the key registry uses outside ASCII has a twin in
1161    /// `ascii_twin`, so the ASCII floor never sees a `?` where an
1162    /// instruction was.
1163    #[test]
1164    fn every_help_screen_is_ascii_clean() {
1165        use datui_cli::keys;
1166        let mut texts: Vec<&str> = Vec::new();
1167        for (screen, group, key) in keys::entries() {
1168            texts.extend([group.name, key.keys, key.label, key.line, key.long()]);
1169            if let Some(screen) = screen {
1170                texts.push(screen.title);
1171            }
1172        }
1173        for (example, meaning) in keys::Q_SUMMARY {
1174            texts.extend([*example, *meaning]);
1175        }
1176        for text in &texts {
1177            for c in text.chars().filter(|c| !c.is_ascii()) {
1178                assert!(
1179                    ascii_twin(c).is_some(),
1180                    "{text:?} uses {c:?}, which has no ASCII twin"
1181                );
1182            }
1183        }
1184        let checked = texts.len();
1185        assert!(checked > 10, "the help files were found");
1186        // And the border set's twin is pure ASCII by construction.
1187        let b = ASCII.border;
1188        for piece in [
1189            b.top_left,
1190            b.top_right,
1191            b.bottom_left,
1192            b.bottom_right,
1193            b.vertical_left,
1194            b.vertical_right,
1195            b.horizontal_top,
1196            b.horizontal_bottom,
1197        ] {
1198            assert!(piece.is_ascii(), "{piece:?}");
1199        }
1200    }
1201
1202    /// A key whose arrows become words re-pads, so its description stays in
1203    /// line with the rows around it.
1204    #[test]
1205    fn an_ascii_key_keeps_its_description_column() {
1206        let text = "Keys:\n  ↑ / ↓:      Move\n  Enter:      Open, → on a folder\n";
1207        assert_eq!(
1208            instructions_in_ascii(text),
1209            "Keys:\n  Up / Dn:    Move\n  Enter:      Open, Rt on a folder\n"
1210        );
1211    }
1212
1213    /// Paired arrows read as the glyph set's pair, not as two words run
1214    /// together, and the row still keeps its column.
1215    #[test]
1216    fn paired_arrows_read_as_one_key() {
1217        let text = "  ↑↓ / j/k:      Rows\n  ←→ / h/l:      Columns\n  Home/End:      Ends";
1218        assert_eq!(
1219            instructions_in_ascii(text),
1220            "  Up/Dn / j/k:   Rows\n  Lt/Rt / h/l:   Columns\n  Home/End:      Ends"
1221        );
1222    }
1223
1224    /// A key that no longer fits its column moves its whole section right,
1225    /// continuation lines included; prose and other sections stay put.
1226    #[test]
1227    fn an_overlong_ascii_key_moves_its_section_together() {
1228        let text = [
1229            "Keys:",
1230            "  ← / → (h/l):  Page",
1231            "  e:            Plan, and",
1232            "                more",
1233            "  Prose → here.",
1234            "",
1235            "  q:  Quit",
1236        ]
1237        .join("\n");
1238        let expected = [
1239            "Keys:",
1240            "  Lt / Rt (h/l):  Page",
1241            "  e:              Plan, and",
1242            "                  more",
1243            "  Prose Rt here.",
1244            "",
1245            "  q:  Quit",
1246        ]
1247        .join("\n");
1248        assert_eq!(instructions_in_ascii(&text), expected);
1249    }
1250
1251    /// Every locality marker has to be the same display width in a given set, or the
1252    /// name beside it starts one column further along on some rows than on others and
1253    /// the whole list looks broken.
1254    #[test]
1255    fn locality_markers_are_all_one_column() {
1256        for set in [unicode(), ascii()] {
1257            for marker in [
1258                set.here,
1259                set.in_memory,
1260                set.over_network,
1261                set.in_object_store,
1262                set.place_unknown,
1263            ] {
1264                assert_eq!(
1265                    UnicodeWidthStr::width(marker),
1266                    1,
1267                    "{marker:?} is not one column wide"
1268                );
1269            }
1270        }
1271    }
1272
1273    /// The sort marks sit inside the header's column-width arithmetic, so each must be
1274    /// exactly one column in both sets or a sorted column drifts out of line.
1275    #[test]
1276    fn sort_marks_are_one_column() {
1277        for set in [unicode(), ascii()] {
1278            for mark in [set.sort_asc, set.sort_desc] {
1279                assert_eq!(
1280                    UnicodeWidthStr::width(mark),
1281                    1,
1282                    "{mark:?} is not one column wide"
1283                );
1284            }
1285        }
1286    }
1287
1288    /// The two sets must agree column for column, since the layout arithmetic around
1289    /// them is written once and used for both.
1290    #[test]
1291    fn the_two_sets_have_the_same_shape() {
1292        let (u, a) = (unicode(), ascii());
1293        for (left, right) in [
1294            (u.here, a.here),
1295            (u.in_memory, a.in_memory),
1296            (u.over_network, a.over_network),
1297            (u.in_object_store, a.in_object_store),
1298            (u.place_unknown, a.place_unknown),
1299            (u.selector, a.selector),
1300            (u.selector_blank, a.selector_blank),
1301            (u.collapsed, a.collapsed),
1302            (u.expanded, a.expanded),
1303            (u.sort_asc, a.sort_asc),
1304            (u.sort_desc, a.sort_desc),
1305        ]
1306        .into_iter()
1307        .chain(
1308            u.bar_eighths
1309                .iter()
1310                .copied()
1311                .zip(a.bar_eighths.iter().copied()),
1312        ) {
1313            assert_eq!(
1314                UnicodeWidthStr::width(left),
1315                UnicodeWidthStr::width(right),
1316                "{left:?} and {right:?} are different widths"
1317            );
1318        }
1319    }
1320
1321    /// ratatui draws the plot marks, so the audit script cannot see the ASCII
1322    /// set's marker characters; this checks them, and that each column eighth is
1323    /// one cell in both sets.
1324    #[test]
1325    fn the_ascii_plot_marks_are_ascii() {
1326        let p = ascii().plot;
1327        for marker in [p.line, p.point, p.bar] {
1328            let Marker::Custom(c) = marker else {
1329                panic!("{marker:?} is drawn by ratatui from its own Unicode set");
1330            };
1331            assert!(c.is_ascii_graphic(), "{c:?}");
1332        }
1333        let a = p.axis;
1334        for piece in [
1335            a.vertical,
1336            a.horizontal,
1337            a.top_right,
1338            a.top_left,
1339            a.bottom_right,
1340            a.bottom_left,
1341            a.vertical_left,
1342            a.vertical_right,
1343            a.horizontal_down,
1344            a.horizontal_up,
1345            a.cross,
1346        ] {
1347            assert!(piece.is_ascii(), "{piece:?}");
1348        }
1349        for set in [unicode(), ascii()] {
1350            for eighth in set.plot.column_eighths {
1351                assert_eq!(UnicodeWidthStr::width(*eighth), 1, "{eighth:?}");
1352            }
1353        }
1354    }
1355
1356    fn chart_buffer(width: u16, height: u16, x_title: &str, name: &str) -> Buffer {
1357        use ratatui::widgets::{Axis, Chart, Dataset, LegendPosition, Widget};
1358        let labels = || vec!["0", "5", "10"];
1359        let chart = Chart::new(vec![
1360            Dataset::default()
1361                .name(name)
1362                .marker(Marker::Custom('o'))
1363                .data(&[(5.0, 5.0)]),
1364        ])
1365        .x_axis(
1366            Axis::default()
1367                .title(x_title)
1368                .bounds([0.0, 10.0])
1369                .labels(labels()),
1370        )
1371        .y_axis(Axis::default().bounds([0.0, 10.0]).labels(labels()))
1372        .legend_position(Some(LegendPosition::TopRight))
1373        .hidden_legend_constraints((
1374            ratatui::layout::Constraint::Percentage(100),
1375            ratatui::layout::Constraint::Percentage(100),
1376        ));
1377        let area = Rect::new(0, 0, width, height);
1378        let mut buf = Buffer::empty(area);
1379        chart.render(area, &mut buf);
1380        buf
1381    }
1382
1383    fn buffer_text(buf: &Buffer) -> String {
1384        let a = buf.area;
1385        (a.top()..a.bottom())
1386            .map(|y| {
1387                (a.left()..a.right())
1388                    .map(|x| buf[(x, y)].symbol())
1389                    .collect::<String>()
1390            })
1391            .collect::<Vec<_>>()
1392            .join("\n")
1393    }
1394
1395    /// The swap finds the axes and the legend frame by shape: a title or a legend
1396    /// name holding the same characters keeps them, and Unicode changes nothing.
1397    #[test]
1398    fn redraw_axes_changes_only_the_frame() {
1399        let before = chart_buffer(40, 12, "a│b└─c", "x─│y");
1400        let mut buf = before.clone();
1401        unicode().plot.redraw_axes(buf.area, &mut buf);
1402        assert_eq!(buf, before);
1403
1404        ascii().plot.redraw_axes(buf.area, &mut buf);
1405        let text = buffer_text(&buf);
1406        let rows: Vec<&str> = text.lines().collect();
1407        assert!(rows[0].ends_with("+----+"), "the legend frame:\n{text}");
1408        assert!(rows[1].ends_with("|x─│y|"), "the legend name:\n{text}");
1409        assert!(rows[2].ends_with("+----+"), "the legend frame:\n{text}");
1410        assert!(text.contains("a│b└─c"), "the axis title:\n{text}");
1411        assert!(rows[10].contains("+-------"), "the axis corner:\n{text}");
1412        let kept: String = text.chars().filter(|c| !c.is_ascii()).collect();
1413        assert_eq!(kept, "─││└─", "only the name and the title:\n{text}");
1414    }
1415
1416    /// Under three rows the chart has no x axis, so no corner to find the y axis by;
1417    /// every line cell changes then, and the plot is still ASCII.
1418    #[test]
1419    fn redraw_axes_in_a_chart_too_small_for_both_axes() {
1420        let mut buf = chart_buffer(20, 2, "", "");
1421        assert!(!buffer_text(&buf).is_ascii());
1422        ascii().plot.redraw_axes(buf.area, &mut buf);
1423        let text = buffer_text(&buf);
1424        assert!(text.is_ascii() && text.contains('|'), "{text}");
1425    }
1426
1427    /// `get()` stores a copy of a const, so no address can identify the active
1428    /// set — the `unicode` flag on the set is what `active_is_unicode` reads.
1429    #[test]
1430    fn active_is_unicode_matches_the_chosen_set() {
1431        let expected = get().spinner.len() == unicode().spinner.len();
1432        assert_eq!(active_is_unicode(), expected);
1433        assert!(unicode().unicode);
1434        assert!(!ascii().unicode);
1435    }
1436
1437    /// A locale variable decides on every OS; Windows signals count only when
1438    /// none is set (#541).
1439    #[test]
1440    fn utf8_rule_reads_the_locale_then_the_windows_console() {
1441        let env = |locale: Option<&str>, unicode_terminal: bool, page: Option<u32>| Environment {
1442            locale: locale.map(String::from),
1443            unicode_terminal,
1444            console_code_page: page,
1445        };
1446        // Unix: the locale alone.
1447        assert!(env(Some("en_US.UTF-8"), false, None).is_utf8());
1448        assert!(env(Some("C.utf8"), false, None).is_utf8());
1449        assert!(!env(Some("C"), false, None).is_utf8());
1450        assert!(!env(None, false, None).is_utf8());
1451        // Windows sets no locale: Windows Terminal, VS Code or a UTF-8 code page.
1452        assert!(env(None, true, Some(437)).is_utf8());
1453        assert!(env(None, false, Some(CP_UTF8)).is_utf8());
1454        assert!(!env(None, false, Some(437)).is_utf8());
1455        // An explicit locale still wins there, as LANG=C does on Unix.
1456        assert!(!env(Some("C"), true, Some(CP_UTF8)).is_utf8());
1457        assert!(env(Some("en_US.UTF-8"), false, Some(437)).is_utf8());
1458    }
1459
1460    /// Off Windows the console signals are never read, so Unix behavior is the
1461    /// locale's alone.
1462    #[cfg(not(windows))]
1463    #[test]
1464    fn unix_reads_no_windows_signals() {
1465        let current = Environment::current();
1466        assert!(!current.unicode_terminal);
1467        assert_eq!(current.console_code_page, None);
1468    }
1469
1470    /// A bad `[glyphs]` line must fail at config load with the slot named.
1471    #[test]
1472    fn overrides_validate_names_arity_and_width() {
1473        let one =
1474            |k: &str, v: &str| BTreeMap::from([(k.to_string(), SlotOverride::One(v.to_string()))]);
1475        assert!(validate_overrides(&one("in_object_store", "☁")).is_ok());
1476        assert!(
1477            validate_overrides(&one("no_such_slot", "x"))
1478                .is_err_and(|e| e.contains("no_such_slot"))
1479        );
1480        // ‹binary› is eight columns; a one-column override moves every layout after it.
1481        assert!(
1482            validate_overrides(&one("binary_stub", "b")).is_err_and(|e| e.contains("binary_stub"))
1483        );
1484        assert!(validate_overrides(&one("checkbox_on", "")).is_err());
1485        // The wordmark is not a slot.
1486        assert!(validate_overrides(&one("wordmark", "datui")).is_err());
1487
1488        let many = |k: &str, v: &[&str]| {
1489            BTreeMap::from([(
1490                k.to_string(),
1491                SlotOverride::Many(v.iter().map(|s| s.to_string()).collect()),
1492            )])
1493        };
1494        assert!(validate_overrides(&many("spinner", &["◐", "◓", "◑", "◒"])).is_ok());
1495        assert!(validate_overrides(&many("spinner", &[])).is_err());
1496        assert!(
1497            validate_overrides(&many("score_marks", &["a", "b"]))
1498                .is_err_and(|e| e.contains("exactly 5"))
1499        );
1500        assert!(
1501            validate_overrides(&many("times", &["×"])).is_err_and(|e| e.contains("single string"))
1502        );
1503        assert!(validate_overrides(&one("spinner", "◐")).is_err_and(|e| e.contains("list")));
1504    }
1505
1506    /// Overrides land on the set they name and leave every other slot alone.
1507    #[test]
1508    fn overrides_apply_over_the_unicode_set() {
1509        let mut set = UNICODE;
1510        let overrides = BTreeMap::from([
1511            (
1512                "in_object_store".to_string(),
1513                SlotOverride::One("☁".to_string()),
1514            ),
1515            (
1516                "spinner".to_string(),
1517                SlotOverride::Many(vec!["◐".to_string(), "◑".to_string()]),
1518            ),
1519        ]);
1520        validate_overrides(&overrides).expect("a valid override map");
1521        apply_overrides(&mut set, &overrides);
1522        assert_eq!(set.in_object_store, "☁");
1523        assert_eq!(set.spinner, &["◐", "◑"]);
1524        assert_eq!(set.checkbox_on, UNICODE.checkbox_on);
1525    }
1526
1527    /// A marker that is also a letter or a space would read as part of the name.
1528    #[test]
1529    fn no_marker_could_be_mistaken_for_text() {
1530        for set in [unicode(), ascii()] {
1531            for marker in [
1532                set.here,
1533                set.in_memory,
1534                set.over_network,
1535                set.in_object_store,
1536                set.place_unknown,
1537            ] {
1538                let c = marker.chars().next().expect("a marker");
1539                assert!(
1540                    !c.is_alphanumeric() && !c.is_whitespace(),
1541                    "{marker:?} would read as part of a filename"
1542                );
1543            }
1544        }
1545    }
1546
1547    /// Cells, not characters and not bytes: wide characters count two, a combining
1548    /// mark and a joined emoji sequence count with the character they join, and a
1549    /// control character counts nothing, since ratatui draws nothing for it.
1550    #[test]
1551    fn cell_width_counts_what_is_drawn() {
1552        assert_eq!(cell_width("tail"), 4);
1553        assert_eq!(cell_width("東京大阪"), 8);
1554        assert_eq!(cell_width("e\u{301}e\u{301}"), 2);
1555        assert_eq!(cell_width("line1\nline2"), 10);
1556        assert_eq!(cell_width("tab\tseparated"), 12);
1557        assert_eq!(cell_width("👩\u{200d}👩\u{200d}👧"), 2);
1558    }
1559
1560    /// Whole when it fits, borrowed; otherwise cut at a grapheme and marked, never
1561    /// wider than asked, in both marker widths.
1562    #[test]
1563    fn fit_cells_clips_at_graphemes_and_marks_the_cut() {
1564        assert!(matches!(fit_cells("tail", 4, "…"), Cow::Borrowed("tail")));
1565        assert_eq!(fit_cells("abcdef", 5, "…"), "abcd…");
1566        assert_eq!(fit_cells("abcdef", 5, "..."), "ab...");
1567        assert_eq!(fit_cells("abcdef", 2, "..."), "..");
1568        assert_eq!(fit_cells("abcdef", 0, "…"), "");
1569        assert!(matches!(
1570            fit_cells("東京大阪", 8, "…"),
1571            Cow::Borrowed("東京大阪")
1572        ));
1573        assert_eq!(fit_cells("東京大阪", 7, "…"), "東京大…");
1574        // 京 would straddle the edge: it goes whole, and its cell stays blank.
1575        assert_eq!(fit_cells("東京大阪", 4, "…"), "東…");
1576        assert_eq!(fit_cells("東京大阪", 4, "..."), "...");
1577        assert_eq!(fit_cells("e\u{301}e\u{301}e\u{301}", 2, "…"), "e\u{301}…");
1578        assert_eq!(fit_cells("line1\nline2", 10, "…"), "line1line2");
1579        assert_eq!(fit_cells("line1\nline2", 6, "…"), "line1…");
1580    }
1581
1582    /// Whatever the text and the width, the result is a run of the text's own
1583    /// graphemes plus the marker, within the width.
1584    #[test]
1585    fn fit_cells_never_splits_a_grapheme() {
1586        let samples = [
1587            "plain ascii text",
1588            "東京大阪 京都横浜",
1589            "e\u{301}a\u{308}o\u{302}u\u{30a}",
1590            "👩\u{200d}👩\u{200d}👧 family",
1591            "🇯🇵🇺🇸 flags",
1592            "mixed 名古屋 e\u{301} 👍🏽 end",
1593        ];
1594        for marker in ["…", "..."] {
1595            for text in samples {
1596                let span = ratatui::text::Span::raw(text);
1597                let graphemes: Vec<&str> = drawn_graphemes(&span).collect();
1598                for width in 0..=cell_width(text) + 1 {
1599                    let fitted = fit_cells(text, width, marker);
1600                    assert!(
1601                        cell_width(&fitted) <= width,
1602                        "{text:?} at {width}: {fitted:?} is too wide"
1603                    );
1604                    if fitted == text {
1605                        assert!(cell_width(text) <= width);
1606                        continue;
1607                    }
1608                    let kept = fitted.strip_suffix(marker).unwrap_or("");
1609                    let mut rebuilt = String::new();
1610                    for g in &graphemes {
1611                        if rebuilt.len() >= kept.len() {
1612                            break;
1613                        }
1614                        rebuilt.push_str(g);
1615                    }
1616                    assert_eq!(
1617                        rebuilt, kept,
1618                        "{text:?} at {width}: {fitted:?} cuts a grapheme"
1619                    );
1620                }
1621            }
1622        }
1623    }
1624}