use std::sync::LazyLock;
#[derive(Clone, Copy)]
pub struct Glyph {
preferred: &'static str,
basic: &'static str,
}
impl Glyph {
pub const fn for_basic(self, basic: bool) -> &'static str {
if basic {
self.basic
} else {
self.preferred
}
}
pub fn current(self) -> &'static str {
self.for_basic(Glyphs::basic_terminal())
}
}
#[derive(Clone, Copy)]
pub struct Glyphs {
pub prompt: Glyph,
pub prompt_typing: Glyph,
pub split_divider: Glyph,
pub cell_separator: Glyph,
pub pending_separator: Glyph,
pub fold_rule: Glyph,
pub input_edge: Glyph,
pub unevaluated_cell: Glyph,
pub rejected_cell: Glyph,
}
impl Glyphs {
pub fn basic_terminal() -> bool {
static BASIC: LazyLock<bool> = LazyLock::new(|| {
std::env::var("TERM").is_ok_and(|term| term == "dumb")
|| std::env::var("SPECTRAL_M2_BASIC_GLYPHS").is_ok_and(|value| value == "1")
});
*BASIC
}
}
#[derive(Clone, Copy)]
pub struct SpinnerConfig {
pub frames: &'static [&'static str],
pub basic_frames: &'static [&'static str],
pub frame_ms: u64,
}
impl SpinnerConfig {
pub fn active_frames(self) -> &'static [&'static str] {
if Glyphs::basic_terminal() {
self.basic_frames
} else {
self.frames
}
}
}
pub struct UiConfig {
pub screen_margin: u16,
pub feed_margin: u16,
pub glyphs: Glyphs,
pub spinner: SpinnerConfig,
pub typing_prompt_ms: u64,
pub prompt_margin: u16,
pub cell_margin: u16,
pub cell_gap: u16,
pub min_number_digits: u16,
pub prompt_text_gap: u16,
pub right_margin: u16,
pub min_box_width: u16,
pub min_input_height: u16,
pub picker_tab_width: usize,
pub picker_column_gap: usize,
pub picker_right_margin: usize,
pub statusline_margin: u16,
pub max_output_rows: usize,
pub max_output_preview_bytes: usize,
pub max_output_preview_cells: usize,
pub min_output_rows: usize,
pub tree_location_gap: usize,
pub printed_margin: u16,
}
pub const UI: UiConfig = UiConfig {
screen_margin: 1,
feed_margin: 1,
glyphs: Glyphs {
prompt: Glyph {
preferred: "",
basic: ">",
},
prompt_typing: Glyph {
preferred: " ",
basic: ">>",
},
split_divider: Glyph {
preferred: "│",
basic: "|",
},
cell_separator: Glyph {
preferred: "│",
basic: "|",
},
pending_separator: Glyph {
preferred: "┈",
basic: "-",
},
fold_rule: Glyph {
preferred: "─",
basic: "-",
},
input_edge: Glyph {
preferred: "▀",
basic: "-",
},
unevaluated_cell: Glyph {
preferred: " ",
basic: ".",
},
rejected_cell: Glyph {
preferred: " ",
basic: "x",
},
},
spinner: SpinnerConfig {
frames: &["", "", "", "", "", ""],
basic_frames: &["|", "/", "-", "\\"],
frame_ms: 80,
},
typing_prompt_ms: 50,
prompt_margin: 2,
cell_margin: 0,
cell_gap: 3,
min_number_digits: 3,
prompt_text_gap: 2,
right_margin: 3,
min_box_width: 8,
min_input_height: 3,
picker_tab_width: 4,
picker_column_gap: 2,
picker_right_margin: 2,
statusline_margin: 2,
max_output_rows: 40,
max_output_preview_bytes: 256 * 1024,
max_output_preview_cells: 256 * 1024,
min_output_rows: 5,
tree_location_gap: 2,
printed_margin: 1,
};
impl UiConfig {
pub const fn picker_tab_stop(&self, width: usize) -> usize {
(width + self.picker_column_gap).next_multiple_of(self.picker_tab_width)
}
pub const fn prompt_width(&self) -> u16 {
self.prompt_margin + 1 + self.prompt_text_gap
}
}
#[cfg(test)]
mod tests {
use super::UI;
use unicode_width::UnicodeWidthStr;
#[test]
fn basic_glyphs_and_spinner_frames_fit_one_cell() {
let glyphs = UI.glyphs;
for glyph in [
glyphs.prompt,
glyphs.split_divider,
glyphs.cell_separator,
glyphs.pending_separator,
glyphs.fold_rule,
glyphs.input_edge,
glyphs.unevaluated_cell,
glyphs.rejected_cell,
] {
assert_eq!(UnicodeWidthStr::width(glyph.for_basic(true)), 1);
assert_eq!(UnicodeWidthStr::width_cjk(glyph.for_basic(true)), 1);
}
assert!(!UI.spinner.frames.is_empty());
assert!(!UI.spinner.basic_frames.is_empty());
assert!(UI.spinner.basic_frames.iter().all(|frame| {
UnicodeWidthStr::width(*frame) == 1 && UnicodeWidthStr::width_cjk(*frame) == 1
}));
}
#[test]
fn prompts_and_divider_fall_back_to_plain_ascii() {
let glyphs = UI.glyphs;
for glyph in [glyphs.prompt, glyphs.prompt_typing, glyphs.split_divider] {
assert!(glyph.for_basic(true).is_ascii());
}
}
}