1use ratatui::buffer::Buffer;
11use ratatui::layout::Rect;
12use ratatui::symbols::{Marker, line};
13use std::borrow::Cow;
14use std::collections::BTreeMap;
15use std::sync::OnceLock;
16use unicode_width::UnicodeWidthStr;
17
18#[derive(Debug, Clone, Copy)]
20pub struct Glyphs {
21 pub unicode: bool,
23 pub selector: &'static str,
25 pub selector_blank: &'static str,
27 pub cursor: &'static str,
29 pub prompt: &'static str,
31 pub rule: &'static str,
33 pub rule_broken: &'static str,
36 pub ellipsis: &'static str,
38 pub up: &'static str,
40 pub times: &'static str,
42 pub collapsed: &'static str,
44 pub expanded: &'static str,
45 pub updown: &'static str,
48 pub updown_lr: &'static str,
50 pub ctrl_updown: &'static str,
52 pub middot: &'static str,
54 pub dash: &'static str,
57 pub r_squared: &'static str,
59 pub rho: &'static str,
61 pub spinner: &'static [&'static str],
64 pub here: &'static str,
67 pub in_memory: &'static str,
68 pub over_network: &'static str,
69 pub in_object_store: &'static str,
70 pub place_unknown: &'static str,
71 pub null: &'static str,
74 pub absent: &'static str,
77 pub conflict: &'static str,
80 pub drift_mark: &'static str,
83 pub sort_asc: &'static str,
87 pub sort_desc: &'static str,
88 pub rail: &'static str,
90 pub rule_h: &'static str,
92 pub rule_h_focused: &'static str,
93 pub arrow_left: &'static str,
95 pub arrow_right: &'static str,
96 pub trail: &'static str,
98 pub choice_prev: &'static str,
101 pub choice_next: &'static str,
102 pub hidden_mark: &'static str,
105 pub diff_mark: &'static str,
108 pub checkbox_on: &'static str,
110 pub checkbox_off: &'static str,
111 pub radio_on: &'static str,
113 pub radio_off: &'static str,
114 pub dot_full: &'static str,
116 pub dot_half: &'static str,
117 pub dot_empty: &'static str,
118 pub score_marks: &'static [&'static str; 5],
120 pub check: &'static str,
122 pub warning: &'static str,
124 pub scroll_thumb: &'static str,
126 pub scroll_track: &'static str,
128 pub binary_stub: &'static str,
130 pub newline_mark: &'static str,
133 pub tab_mark: &'static str,
134 pub control_mark: &'static str,
135 pub hex_dot: &'static str,
138 pub mini_bars: &'static [&'static str; 8],
140 pub unsampled: &'static str,
143 pub pointer: &'static str,
145 pub bar_eighths: &'static [&'static str; 8],
148 pub wordmark: Option<&'static [&'static str]>,
151 pub border: ratatui::symbols::border::Set<'static>,
154 pub plot: PlotMarks,
157}
158
159#[derive(Debug, Clone, Copy)]
162pub struct PlotMarks {
163 pub line: Marker,
165 pub point: Marker,
167 pub bar: Marker,
169 pub column_eighths: &'static [&'static str; 8],
172 pub axis: line::Set<'static>,
174 pub tick_x: &'static str,
176 pub tick_y: &'static str,
177 pub grid_across: &'static str,
179 pub grid_down: &'static str,
180}
181
182impl PlotMarks {
183 pub fn column_set(&self) -> ratatui::symbols::bar::Set<'static> {
185 let e = self.column_eighths;
186 ratatui::symbols::bar::Set {
187 full: e[7],
188 seven_eighths: e[6],
189 three_quarters: e[5],
190 five_eighths: e[4],
191 half: e[3],
192 three_eighths: e[2],
193 one_quarter: e[1],
194 one_eighth: e[0],
195 empty: " ",
196 }
197 }
198
199 pub fn is_mark(marker: Marker, symbol: &str) -> bool {
202 let mut chars = symbol.chars();
203 let (Some(c), None) = (chars.next(), chars.next()) else {
204 return false;
205 };
206 match marker {
207 Marker::Braille => ('\u{2800}'..='\u{28ff}').contains(&c),
208 Marker::Dot => symbol == ratatui::symbols::DOT,
209 Marker::Custom(mark) => c == mark,
210 _ => false,
211 }
212 }
213
214 pub fn redraw_axes(&self, area: Rect, buf: &mut Buffer) {
219 let (from, to) = (line::NORMAL, self.axis);
220 if from == to {
221 return;
222 }
223 let area = area.intersection(buf.area);
224 let at = |x: u16, y: u16| buf[(x, y)].symbol();
225 let run = |x: u16, y: u16, (dx, dy): (i32, i32), symbol: &str| {
227 let mut n = 0;
228 let (mut cx, mut cy) = (i32::from(x) + dx, i32::from(y) + dy);
229 while (i32::from(area.left())..i32::from(area.right())).contains(&cx)
230 && (i32::from(area.top())..i32::from(area.bottom())).contains(&cy)
231 && at(cx as u16, cy as u16) == symbol
232 {
233 n += 1;
234 cx += dx;
235 cy += dy;
236 }
237 n
238 };
239 let (up, down, right) = ((0, -1), (0, 1), (1, 0));
240 let mut frame = Vec::new();
241 let mut found_axes = false;
242 for y in area.top()..area.bottom() {
243 for x in area.left()..area.right() {
244 let symbol = at(x, y);
245 if symbol == from.bottom_left {
246 let (high, wide) = (
247 run(x, y, up, from.vertical),
248 run(x, y, right, from.horizontal),
249 );
250 let end = x + wide + 1;
251 let boxed = end < area.right() && at(end, y) == from.bottom_right;
252 if high > 0 && wide > 0 && !boxed {
253 found_axes = true;
254 frame.extend((y - high..=y).map(|y| (x, y)));
255 frame.extend((x + 1..end).map(|x| (x, y)));
256 }
257 } else if symbol == from.top_left {
258 let wide = run(x, y, right, from.horizontal);
259 let high = run(x, y, down, from.vertical);
260 let (r, b) = (x + wide + 1, y + high + 1);
261 let closed = r < area.right()
262 && b < area.bottom()
263 && at(r, y) == from.top_right
264 && at(x, b) == from.bottom_left
265 && at(r, b) == from.bottom_right
266 && run(r, y, down, from.vertical) == high
267 && run(x, b, right, from.horizontal) == wide;
268 if closed {
269 for i in x..=r {
270 frame.extend([(i, y), (i, b)]);
271 }
272 for j in y + 1..b {
273 frame.extend([(x, j), (r, j)]);
274 }
275 }
276 }
277 }
278 }
279 if !found_axes {
280 frame = (area.top()..area.bottom())
281 .flat_map(|y| (area.left()..area.right()).map(move |x| (x, y)))
282 .collect();
283 }
284 let pairs = [
285 (from.vertical, to.vertical),
286 (from.horizontal, to.horizontal),
287 (from.top_left, to.top_left),
288 (from.top_right, to.top_right),
289 (from.bottom_left, to.bottom_left),
290 (from.bottom_right, to.bottom_right),
291 ];
292 for (x, y) in frame {
293 let cell = &mut buf[(x, y)];
294 if let Some((_, twin)) = pairs.iter().find(|(line, _)| cell.symbol() == *line) {
295 cell.set_symbol(twin);
296 }
297 }
298 }
299}
300
301const UNICODE: Glyphs = Glyphs {
302 unicode: true,
303 selector: "▎ ",
304 selector_blank: " ",
305 cursor: "▏",
306 prompt: "› ",
307 rule: "│",
308 rule_broken: "┆",
309 ellipsis: "…",
310 up: "..",
311 times: "×",
312 collapsed: "▸ ",
313 expanded: "▾ ",
314 updown: "↑↓",
315 updown_lr: "←→",
316 ctrl_updown: "^↑↓",
317 middot: "·",
318 dash: "—",
319 r_squared: "R²",
320 rho: "ρ",
321 spinner: &["⣷", "⣯", "⣟", "⡿", "⢿", "⣻", "⣽", "⣾"],
322 here: "◦",
327 in_memory: "▪",
328 over_network: "↕",
329 in_object_store: "≈",
330 place_unknown: "◌",
331 null: "∅",
332 absent: "·",
333 conflict: "≠",
334 drift_mark: "*",
335 sort_asc: "▲",
336 sort_desc: "▼",
337 rail: "▎",
338 rule_h: "─",
339 rule_h_focused: "━",
340 arrow_left: "←",
341 arrow_right: "→",
342 trail: "›",
343 choice_prev: "‹",
344 choice_next: "›",
345 hidden_mark: "⊘",
346 diff_mark: "Δ",
347 checkbox_on: "■",
348 checkbox_off: "□",
349 radio_on: "●",
350 radio_off: "○",
351 dot_full: "●",
352 dot_half: "◔",
353 dot_empty: "○",
354 score_marks: &["○", "◔", "◕", "◉", "●"],
355 check: "✓",
356 warning: "▲",
359 scroll_thumb: "█",
360 scroll_track: "░",
361 binary_stub: "‹binary›",
362 newline_mark: "¶",
365 tab_mark: "»",
366 control_mark: "¤",
367 hex_dot: "·",
368 mini_bars: &["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"],
369 unsampled: "·",
370 pointer: "▲",
371 bar_eighths: &["▏", "▎", "▍", "▌", "▋", "▊", "▉", "█"],
372 wordmark: Some(&[
373 "┌──╮ ╭──╮ ╶─┬─╴ ╷ ╷ ╶┬╴",
374 "│ │ ├──┤ │ │ │ │ ",
375 "└──╯ ╵ ╵ ╵ ╰──╯ ╶┴╴",
376 ]),
377 border: ratatui::symbols::border::ROUNDED,
378 plot: PlotMarks {
379 line: Marker::Braille,
380 point: Marker::Dot,
381 bar: Marker::HalfBlock,
382 column_eighths: &["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"],
383 axis: line::NORMAL,
384 tick_x: "┬",
385 tick_y: "┤",
386 grid_across: "·",
387 grid_down: "┊",
388 },
389};
390
391const ASCII: Glyphs = Glyphs {
392 unicode: false,
393 selector: "> ",
394 selector_blank: " ",
395 cursor: "_",
396 prompt: "> ",
397 rule: "|",
398 rule_broken: ":",
399 ellipsis: "...",
400 up: "..",
401 times: "x",
402 collapsed: "+ ",
403 expanded: "- ",
404 updown: "Up/Dn",
405 updown_lr: "Lt/Rt",
406 ctrl_updown: "^Up/Dn",
407 middot: "-",
408 dash: "-",
409 r_squared: "R^2",
410 rho: "rho",
411 spinner: &["|", "/", "-", "\\"],
412 here: ".",
413 in_memory: "*",
414 over_network: "~",
415 in_object_store: "@",
416 place_unknown: "?",
417 null: "~",
418 absent: ".",
419 conflict: "!",
420 drift_mark: "*",
421 sort_asc: "^",
422 sort_desc: "v",
423 rail: ">",
424 rule_h: "-",
425 rule_h_focused: "=",
426 arrow_left: "<",
427 arrow_right: ">",
428 trail: ">",
429 choice_prev: "<",
430 choice_next: ">",
431 hidden_mark: "x",
432 diff_mark: "*",
433 checkbox_on: "[x]",
434 checkbox_off: "[ ]",
435 radio_on: "(*)",
436 radio_off: "( )",
437 dot_full: "#",
438 dot_half: "+",
439 dot_empty: ".",
440 score_marks: &[".", "-", "+", "*", "#"],
441 check: "+",
442 warning: "!",
443 scroll_thumb: "#",
444 scroll_track: "|",
445 binary_stub: "<binary>",
446 newline_mark: "$",
448 tab_mark: ">",
449 control_mark: "?",
450 hex_dot: ".",
451 mini_bars: &[".", ":", "-", "=", "+", "*", "#", "@"],
452 unsampled: "?",
453 pointer: "^",
454 bar_eighths: &["-", "-", "-", "=", "=", "=", "=", "#"],
456 wordmark: None,
457 border: ratatui::symbols::border::Set {
458 top_left: "+",
459 top_right: "+",
460 bottom_left: "+",
461 bottom_right: "+",
462 vertical_left: "|",
463 vertical_right: "|",
464 horizontal_top: "-",
465 horizontal_bottom: "-",
466 },
467 plot: PlotMarks {
468 line: Marker::Custom('*'),
469 point: Marker::Custom('o'),
470 bar: Marker::Custom('#'),
471 column_eighths: &["_", "_", "-", "-", "-", "#", "#", "#"],
473 axis: line::Set {
474 vertical: "|",
475 horizontal: "-",
476 top_right: "+",
477 top_left: "+",
478 bottom_right: "+",
479 bottom_left: "+",
480 vertical_left: "+",
481 vertical_right: "+",
482 horizontal_down: "+",
483 horizontal_up: "+",
484 cross: "+",
485 },
486 tick_x: "+",
487 tick_y: "+",
488 grid_across: ".",
489 grid_down: ":",
490 },
491};
492
493#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
495#[serde(rename_all = "lowercase")]
496pub enum UnicodeMode {
497 #[default]
499 Auto,
500 Always,
501 Never,
502}
503
504#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
507#[serde(untagged)]
508pub enum SlotOverride {
509 One(String),
510 Many(Vec<String>),
511}
512
513macro_rules! with_string_slots {
516 ($callback:ident) => {
517 $callback!(
518 selector,
519 selector_blank,
520 cursor,
521 prompt,
522 rule,
523 rule_broken,
524 ellipsis,
525 up,
526 times,
527 collapsed,
528 expanded,
529 updown,
530 updown_lr,
531 ctrl_updown,
532 middot,
533 dash,
534 r_squared,
535 rho,
536 here,
537 in_memory,
538 over_network,
539 in_object_store,
540 place_unknown,
541 null,
542 absent,
543 conflict,
544 drift_mark,
545 sort_asc,
546 sort_desc,
547 rail,
548 rule_h,
549 rule_h_focused,
550 arrow_left,
551 arrow_right,
552 trail,
553 choice_prev,
554 choice_next,
555 hidden_mark,
556 diff_mark,
557 checkbox_on,
558 checkbox_off,
559 radio_on,
560 radio_off,
561 dot_full,
562 dot_half,
563 dot_empty,
564 check,
565 warning,
566 scroll_thumb,
567 scroll_track,
568 binary_stub,
569 newline_mark,
570 tab_mark,
571 control_mark,
572 hex_dot,
573 unsampled,
574 pointer
575 )
576 };
577}
578
579pub fn asciify_instructions(text: &str) -> std::borrow::Cow<'_, str> {
583 if get().unicode || text.is_ascii() {
584 return std::borrow::Cow::Borrowed(text);
585 }
586 std::borrow::Cow::Owned(instructions_in_ascii(text))
587}
588
589pub fn instructions_in_ascii(text: &str) -> String {
593 let mut out = String::with_capacity(text.len() + text.len() / 8);
594 let mut section: Vec<&str> = Vec::new();
595 for line in text.split('\n') {
596 if line.trim().is_empty() {
597 push_section(§ion, &mut out);
598 section.clear();
599 push_ascii(line, &mut out);
600 out.push('\n');
601 } else {
602 section.push(line);
603 }
604 }
605 push_section(§ion, &mut out);
606 out.pop();
608 out
609}
610
611fn push_section(lines: &[&str], out: &mut String) {
614 let rows: Vec<Option<(usize, usize, usize)>> = lines
616 .iter()
617 .map(|line| key_gap(line).map(|(key, desc)| (key, desc, display_width(&line[..desc]))))
618 .collect();
619 let shift = lines
620 .iter()
621 .zip(&rows)
622 .filter_map(|(line, row)| {
623 row.map(|(key, _, column)| (ascii_width(&line[..key]) + 2).saturating_sub(column))
624 })
625 .max()
626 .unwrap_or(0);
627 for (line, row) in lines.iter().zip(&rows) {
628 match *row {
629 Some((key, desc, column)) => {
630 push_ascii(&line[..key], out);
631 let pad = column + shift - ascii_width(&line[..key]);
632 out.extend(std::iter::repeat_n(' ', pad));
633 push_ascii(&line[desc..], out);
634 }
635 None => {
636 let indent = line.len() - line.trim_start_matches(' ').len();
637 if shift > 0 && rows.iter().flatten().any(|&(_, _, c)| c == indent) {
638 out.extend(std::iter::repeat_n(' ', shift));
639 }
640 push_ascii(line, out);
641 }
642 }
643 out.push('\n');
644 }
645}
646
647fn ascii_width(text: &str) -> usize {
649 let mut ascii = String::new();
650 push_ascii(text, &mut ascii);
651 ascii.len()
652}
653
654pub(crate) fn key_gap(line: &str) -> Option<(usize, usize)> {
658 let lead = line.len() - line.trim_start_matches(' ').len();
659 let key_end = lead + line[lead..].find(" ")?;
660 let desc_start = line.len() - line[key_end..].trim_start_matches(' ').len();
661 (desc_start < line.len()).then_some((key_end, desc_start))
662}
663
664fn push_ascii(text: &str, out: &mut String) {
665 let mut chars = text.chars().peekable();
666 while let Some(c) = chars.next() {
667 let pair = match (c, chars.peek()) {
670 ('↑', Some('↓')) => Some(ASCII.updown),
671 ('←', Some('→')) => Some(ASCII.updown_lr),
672 _ => None,
673 };
674 if let Some(pair) = pair {
675 chars.next();
676 out.push_str(pair);
677 continue;
678 }
679 match ascii_twin(c) {
680 Some(twin) => out.push_str(twin),
681 None if c.is_ascii() => out.push(c),
682 None => out.push('?'),
685 }
686 }
687}
688
689pub fn display_width(text: &str) -> usize {
691 UnicodeWidthStr::width(text)
692}
693
694pub fn take_columns(text: &str, width: usize) -> &str {
697 let mut used = 0usize;
698 for (i, c) in text.char_indices() {
699 let w = unicode_width::UnicodeWidthChar::width(c).unwrap_or(0);
700 if used + w > width {
701 return &text[..i];
702 }
703 used += w;
704 }
705 text
706}
707
708pub fn take_columns_end(text: &str, width: usize) -> &str {
711 let mut used = 0usize;
712 let mut start = text.len();
713 for (i, c) in text.char_indices().rev() {
714 let w = unicode_width::UnicodeWidthChar::width(c).unwrap_or(0);
715 if used + w > width {
716 break;
717 }
718 used += w;
719 start = i;
720 }
721 &text[start..]
722}
723
724fn plain_ascii(text: &str) -> bool {
727 text.bytes().all(|b| (0x20..0x7f).contains(&b))
728}
729
730fn drawn_graphemes<'a>(span: &'a ratatui::text::Span<'a>) -> impl Iterator<Item = &'a str> {
733 span.styled_graphemes(ratatui::style::Style::default())
734 .map(|g| g.symbol)
735}
736
737pub fn cell_width(text: &str) -> usize {
740 use ratatui::buffer::CellWidth;
741 if plain_ascii(text) {
742 return text.len();
743 }
744 let span = ratatui::text::Span::raw(text);
745 drawn_graphemes(&span)
746 .map(|g| usize::from(g.cell_width()))
747 .sum()
748}
749
750pub fn fit_cells<'a>(text: &'a str, width: usize, marker: &str) -> Cow<'a, str> {
755 use ratatui::buffer::CellWidth;
756 let marker_width = cell_width(marker);
757 let head_len = text.len().min(width.saturating_add(1));
758 let head = text.get(..head_len).unwrap_or(text);
759 if head.len() == head_len && plain_ascii(head) {
760 if text.len() <= width {
761 return Cow::Borrowed(text);
762 }
763 if width <= marker_width {
764 return Cow::Owned(take_columns(marker, width).to_string());
765 }
766 return Cow::Owned(format!("{}{marker}", &text[..width - marker_width]));
767 }
768 let span = ratatui::text::Span::raw(text);
769 let mut total = 0usize;
771 let mut drawn_bytes = 0usize;
772 for g in drawn_graphemes(&span) {
773 total += usize::from(g.cell_width());
774 if total > width {
775 break;
776 }
777 drawn_bytes += g.len();
778 }
779 if total <= width {
780 return if drawn_bytes == text.len() {
781 Cow::Borrowed(text)
782 } else {
783 Cow::Owned(drawn_graphemes(&span).collect())
784 };
785 }
786 if width <= marker_width {
787 return Cow::Owned(take_columns(marker, width).to_string());
788 }
789 let budget = width - marker_width;
790 let mut used = 0usize;
791 let mut out = String::with_capacity(budget + marker.len());
792 for g in drawn_graphemes(&span) {
793 let w = usize::from(g.cell_width());
794 if used + w > budget {
795 break;
796 }
797 used += w;
798 out.push_str(g);
799 }
800 out.push_str(marker);
801 Cow::Owned(out)
802}
803
804fn ascii_twin(c: char) -> Option<&'static str> {
807 match c {
808 '↑' => Some("Up"),
809 '↓' => Some("Dn"),
810 '←' => Some("Lt"),
811 '→' => Some("Rt"),
812 '↔' => Some("Lt/Rt"),
813 '—' => Some("-"),
814 '…' => Some("..."),
815 '×' => Some("x"),
816 '≤' => Some("<="),
817 '≥' => Some(">="),
818 '∅' => Some("~"),
819 '·' => Some("."),
820 '≠' => Some("!"),
821 'ρ' => Some("rho"),
822 '│' => Some("|"),
823 '┆' => Some(":"),
824 '¶' => Some("$"),
825 '»' => Some(">"),
826 '¤' => Some("?"),
827 'Δ' => Some("*"),
828 _ => None,
829 }
830}
831
832fn unicode_default(slot: &str) -> Option<&'static str> {
835 macro_rules! lookup {
836 ($($name:ident),*) => {
837 match slot {
838 $(stringify!($name) => Some(UNICODE.$name),)*
839 _ => None,
840 }
841 };
842 }
843 with_string_slots!(lookup)
844}
845
846pub fn validate_overrides(overrides: &BTreeMap<String, SlotOverride>) -> Result<(), String> {
849 let same_width = |slot: &str, text: &str, default: &str| -> Result<(), String> {
850 if text.width() == default.width() {
851 Ok(())
852 } else {
853 Err(format!(
854 "glyph override for `{slot}` is {} columns wide; {default:?} is {} — \
855 an override must keep the width of the glyph it replaces",
856 text.width(),
857 default.width(),
858 ))
859 }
860 };
861 for (slot, value) in overrides {
862 match (unicode_default(slot), value) {
863 (Some(default), SlotOverride::One(text)) => same_width(slot, text, default)?,
864 (Some(_), SlotOverride::Many(_)) => {
865 return Err(format!(
866 "glyph slot `{slot}` takes a single string, not a list"
867 ));
868 }
869 (None, _) => {
870 let (len, default) = match slot.as_str() {
871 "spinner" => (None, UNICODE.spinner),
872 "score_marks" => (Some(5), &UNICODE.score_marks[..]),
873 "mini_bars" => (Some(8), &UNICODE.mini_bars[..]),
874 "bar_eighths" => (Some(8), &UNICODE.bar_eighths[..]),
875 _ => return Err(format!("unknown glyph slot `{slot}`")),
876 };
877 let SlotOverride::Many(entries) = value else {
878 return Err(format!("glyph slot `{slot}` takes a list of strings"));
879 };
880 match len {
881 Some(len) if entries.len() != len => {
882 return Err(format!(
883 "glyph slot `{slot}` takes exactly {len} entries, got {}",
884 entries.len()
885 ));
886 }
887 None if entries.is_empty() => {
888 return Err(format!("glyph slot `{slot}` takes at least one entry"));
889 }
890 _ => {}
891 }
892 for entry in entries {
893 same_width(slot, entry, default[0])?;
894 }
895 }
896 }
897 }
898 Ok(())
899}
900
901fn leak(text: &str) -> &'static str {
903 Box::leak(text.to_string().into_boxed_str())
904}
905
906fn apply_overrides(set: &mut Glyphs, overrides: &BTreeMap<String, SlotOverride>) {
908 for (slot, value) in overrides {
909 match (slot.as_str(), value) {
910 ("spinner", SlotOverride::Many(frames)) => {
911 set.spinner = Box::leak(
912 frames
913 .iter()
914 .map(|f| leak(f))
915 .collect::<Vec<_>>()
916 .into_boxed_slice(),
917 );
918 }
919 ("score_marks", SlotOverride::Many(marks)) if marks.len() == 5 => {
920 set.score_marks = Box::leak(Box::new(std::array::from_fn(|i| leak(&marks[i]))));
921 }
922 ("mini_bars", SlotOverride::Many(bars)) if bars.len() == 8 => {
923 set.mini_bars = Box::leak(Box::new(std::array::from_fn(|i| leak(&bars[i]))));
924 }
925 ("bar_eighths", SlotOverride::Many(bars)) if bars.len() == 8 => {
926 set.bar_eighths = Box::leak(Box::new(std::array::from_fn(|i| leak(&bars[i]))));
927 }
928 (name, SlotOverride::One(text)) => {
929 let text = leak(text);
930 macro_rules! assign {
931 ($($slot:ident),*) => {
932 match name {
933 $(stringify!($slot) => set.$slot = text,)*
934 _ => {}
937 }
938 };
939 }
940 with_string_slots!(assign)
941 }
942 _ => {}
943 }
944 }
945}
946
947static GLYPHS: OnceLock<Glyphs> = OnceLock::new();
948
949#[derive(Debug, Clone, Default, PartialEq, Eq)]
952struct Environment {
953 locale: Option<String>,
955 windows: bool,
957}
958
959impl Environment {
960 fn current() -> Self {
961 Self {
962 locale: ["LC_ALL", "LC_CTYPE", "LANG"]
963 .into_iter()
964 .find_map(|key| std::env::var(key).ok().filter(|v| !v.is_empty())),
965 windows: cfg!(windows),
966 }
967 }
968
969 fn is_utf8(&self) -> bool {
973 match &self.locale {
974 Some(value) => {
975 let lower = value.to_ascii_lowercase();
976 lower.contains("utf-8") || lower.contains("utf8")
977 }
978 None => self.windows,
979 }
980 }
981}
982
983pub fn environment_is_utf8() -> bool {
987 Environment::current().is_utf8()
988}
989
990pub fn init_with_overrides(mode: UnicodeMode, overrides: &BTreeMap<String, SlotOverride>) {
994 let mut chosen = match mode {
995 UnicodeMode::Always => UNICODE,
996 UnicodeMode::Never => ASCII,
997 UnicodeMode::Auto => {
998 if environment_is_utf8() {
999 UNICODE
1000 } else {
1001 ASCII
1002 }
1003 }
1004 };
1005 if chosen.unicode && !overrides.is_empty() {
1006 apply_overrides(&mut chosen, overrides);
1007 }
1008 let _ = GLYPHS.set(chosen);
1009}
1010
1011pub fn get() -> &'static Glyphs {
1014 GLYPHS.get_or_init(|| {
1015 if environment_is_utf8() {
1016 UNICODE
1017 } else {
1018 ASCII
1019 }
1020 })
1021}
1022
1023pub fn active_is_unicode() -> bool {
1025 get().unicode
1026}
1027
1028pub fn unicode() -> &'static Glyphs {
1030 &UNICODE
1031}
1032
1033#[cfg(test)]
1037pub(crate) fn frame_corners(rows: &[String]) -> Vec<(usize, usize)> {
1038 let b = get().border;
1039 let grid: Vec<Vec<String>> = rows
1040 .iter()
1041 .map(|r| r.chars().map(String::from).collect())
1042 .collect();
1043 let at = |x: usize, y: usize| grid.get(y).and_then(|r| r.get(x)).map(String::as_str);
1044 let glyphs = [b.top_left, b.top_right, b.bottom_left, b.bottom_right];
1047 if (1..4).all(|i| !glyphs[..i].contains(&glyphs[i])) {
1048 let opened: usize = rows.iter().map(|r| r.matches(b.top_left).count()).sum();
1049 let closed: Vec<(usize, usize)> = grid
1050 .iter()
1051 .enumerate()
1052 .flat_map(|(y, row)| {
1053 row.iter()
1054 .enumerate()
1055 .filter(|(_, cell)| cell.as_str() == b.bottom_left)
1056 .map(move |(x, _)| (x, y))
1057 })
1058 .collect();
1059 assert_eq!(opened, closed.len(), "every frame closes: {rows:#?}");
1060 return closed;
1061 }
1062 let mut corners = Vec::new();
1063 for (y, row) in grid.iter().enumerate().skip(1) {
1064 for x in 0..row.len() {
1065 if at(x, y) == Some(b.bottom_left)
1066 && at(x, y - 1) == Some(b.vertical_left)
1067 && at(x + 1, y) == Some(b.horizontal_bottom)
1068 && (x == 0 || at(x - 1, y) != Some(b.horizontal_bottom))
1069 {
1070 corners.push((x, y));
1071 }
1072 }
1073 }
1074 corners
1075}
1076
1077pub fn ascii() -> &'static Glyphs {
1079 &ASCII
1080}
1081
1082pub fn fit(text: &str, width: usize) -> String {
1084 fit_cells(text, width, get().ellipsis).into_owned()
1085}
1086
1087pub fn fit_start(text: &str, width: usize) -> String {
1090 if display_width(text) <= width {
1091 return text.to_string();
1092 }
1093 let ellipsis = get().ellipsis;
1094 let ellipsis_width = display_width(ellipsis);
1095 if width <= ellipsis_width {
1096 return take_columns_end(text, width).to_string();
1097 }
1098 format!(
1099 "{ellipsis}{}",
1100 take_columns_end(text, width - ellipsis_width)
1101 )
1102}
1103
1104pub fn fit_middle(text: &str, width: usize) -> String {
1106 if display_width(text) <= width {
1107 return text.to_string();
1108 }
1109 let mark = get().ellipsis;
1110 let mark_w = display_width(mark);
1111 if width <= mark_w {
1112 return take_columns(mark, width).to_string();
1113 }
1114 let room = width - mark_w;
1115 let head = take_columns(text, room.div_ceil(2));
1116 let tail = take_columns_end(text, room - display_width(head));
1117 format!("{head}{mark}{tail}")
1118}
1119
1120pub fn dotted(text: &str) -> String {
1123 text.replace('·', get().middot)
1124}
1125
1126#[cfg(test)]
1127mod tests;