1use 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#[derive(Debug, Clone, Copy)]
29pub struct Glyphs {
30 pub unicode: bool,
34 pub selector: &'static str,
36 pub selector_blank: &'static str,
38 pub cursor: &'static str,
40 pub prompt: &'static str,
42 pub rule: &'static str,
44 pub rule_broken: &'static str,
48 pub ellipsis: &'static str,
50 pub times: &'static str,
52 pub collapsed: &'static str,
54 pub expanded: &'static str,
55 pub updown: &'static str,
58 pub updown_lr: &'static str,
60 pub ctrl_updown: &'static str,
62 pub middot: &'static str,
64 pub dash: &'static str,
67 pub r_squared: &'static str,
69 pub rho: &'static str,
71 pub spinner: &'static [&'static str],
74 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 pub null: &'static str,
89 pub absent: &'static str,
93 pub conflict: &'static str,
96 pub drift_mark: &'static str,
99 pub sort_asc: &'static str,
104 pub sort_desc: &'static str,
105 pub rail: &'static str,
107 pub rule_h: &'static str,
109 pub rule_h_focused: &'static str,
110 pub arrow_left: &'static str,
112 pub arrow_right: &'static str,
113 pub trail: &'static str,
115 pub choice_prev: &'static str,
118 pub choice_next: &'static str,
119 pub hidden_mark: &'static str,
122 pub diff_mark: &'static str,
125 pub checkbox_on: &'static str,
127 pub checkbox_off: &'static str,
128 pub radio_on: &'static str,
130 pub radio_off: &'static str,
131 pub dot_full: &'static str,
133 pub dot_half: &'static str,
134 pub dot_empty: &'static str,
135 pub score_marks: &'static [&'static str; 5],
137 pub check: &'static str,
139 pub warning: &'static str,
141 pub scroll_thumb: &'static str,
143 pub scroll_track: &'static str,
145 pub binary_stub: &'static str,
147 pub newline_mark: &'static str,
151 pub tab_mark: &'static str,
152 pub control_mark: &'static str,
153 pub hex_dot: &'static str,
156 pub mini_bars: &'static [&'static str; 8],
158 pub unsampled: &'static str,
161 pub pointer: &'static str,
163 pub bar_eighths: &'static [&'static str; 8],
166 pub wordmark: Option<&'static [&'static str]>,
169 pub border: ratatui::symbols::border::Set<'static>,
172 pub plot: PlotMarks,
175}
176
177#[derive(Debug, Clone, Copy)]
181pub struct PlotMarks {
182 pub line: Marker,
184 pub point: Marker,
186 pub bar: Marker,
188 pub column_eighths: &'static [&'static str; 8],
191 pub axis: line::Set<'static>,
193 pub tick_x: &'static str,
195 pub tick_y: &'static str,
196 pub grid_across: &'static str,
198 pub grid_down: &'static str,
199}
200
201impl PlotMarks {
202 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 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 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 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 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 warning: "▲",
384 scroll_thumb: "█",
385 scroll_track: "░",
386 binary_stub: "‹binary›",
387 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 newline_mark: "$",
472 tab_mark: ">",
473 control_mark: "?",
474 hex_dot: ".",
475 mini_bars: &[".", ":", "-", "=", "+", "*", "#", "@"],
476 unsampled: "?",
477 pointer: "^",
478 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
519#[serde(rename_all = "lowercase")]
520pub enum UnicodeMode {
521 #[default]
523 Auto,
524 Always,
525 Never,
526}
527
528#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
531#[serde(untagged)]
532pub enum SlotOverride {
533 One(String),
534 Many(Vec<String>),
535}
536
537macro_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
604pub 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
616pub 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(§ion, &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(§ion, &mut out);
636 out.pop();
638 out
639}
640
641fn push_section(lines: &[&str], out: &mut String) {
644 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
677fn ascii_width(text: &str) -> usize {
679 let mut ascii = String::new();
680 push_ascii(text, &mut ascii);
681 ascii.len()
682}
683
684pub(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 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 None => out.push('?'),
717 }
718 }
719}
720
721pub fn display_width(text: &str) -> usize {
725 UnicodeWidthStr::width(text)
726}
727
728pub 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
742pub 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
758fn plain_ascii(text: &str) -> bool {
761 text.bytes().all(|b| (0x20..0x7f).contains(&b))
762}
763
764fn 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
771pub 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
786pub 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
834fn 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
862fn 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
876pub 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
935fn leak(text: &str) -> &'static str {
937 Box::leak(text.to_string().into_boxed_str())
938}
939
940fn 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 _ => {}
971 }
972 };
973 }
974 with_string_slots!(assign)
975 }
976 _ => {}
977 }
978 }
979}
980
981static GLYPHS: OnceLock<Glyphs> = OnceLock::new();
982
983#[derive(Debug, Clone, Default, PartialEq, Eq)]
986struct Environment {
987 locale: Option<String>,
989 unicode_terminal: bool,
993 console_code_page: Option<u32>,
995}
996
997const 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 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 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
1041pub fn environment_is_utf8() -> bool {
1049 Environment::current().is_utf8()
1050}
1051
1052pub fn init(mode: UnicodeMode) {
1055 init_with_overrides(mode, &BTreeMap::new());
1056}
1057
1058pub 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
1079pub fn get() -> &'static Glyphs {
1082 GLYPHS.get_or_init(|| {
1083 if environment_is_utf8() {
1084 UNICODE
1085 } else {
1086 ASCII
1087 }
1088 })
1089}
1090
1091pub fn active_is_unicode() -> bool {
1095 get().unicode
1096}
1097
1098pub fn unicode() -> &'static Glyphs {
1100 &UNICODE
1101}
1102
1103#[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 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
1150pub fn ascii() -> &'static Glyphs {
1152 &ASCII
1153}
1154
1155#[cfg(test)]
1156mod tests {
1157 use super::*;
1158 use unicode_width::UnicodeWidthStr;
1159
1160 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 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 #[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 #[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 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 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 #[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 #[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 #[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 #[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 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 #[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}