use unicode_width::UnicodeWidthStr as _;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum GlyphMode {
#[default]
Unicode,
Ascii,
}
impl GlyphMode {
pub fn resolve(ascii_flag: bool, env: Option<&str>) -> Self {
if ascii_flag || env.is_some_and(is_truthy) {
Self::Ascii
} else {
Self::Unicode
}
}
}
fn is_truthy(value: &str) -> bool {
!matches!(
value.trim().to_ascii_lowercase().as_str(),
"" | "0" | "false" | "no" | "off"
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Glyph {
Here,
Open,
Closed,
MainMarker,
SubmenuArrow,
NotMain,
InFlight,
Operation,
MarkedGutter,
UnmarkedGutter,
ChecksPass,
ChecksFail,
ChecksPending,
SessionWaiting,
SessionWorking,
SessionIdle,
Ellipsis,
}
impl Glyph {
pub fn as_str(self, mode: GlyphMode) -> &'static str {
let (unicode, ascii) = self.forms();
match mode {
GlyphMode::Unicode => unicode,
GlyphMode::Ascii => ascii,
}
}
#[allow(clippy::match_same_arms)]
const fn forms(self) -> (&'static str, &'static str) {
match self {
Self::Here => ("*", "*"),
Self::Open => ("\u{25cf}", "o"), Self::Closed => ("\u{00b7}", "."), Self::MainMarker => ("\u{25b8}", ">"), Self::SubmenuArrow => ("\u{25b8}", ">"), Self::NotMain => (" ", " "),
Self::InFlight => ("~", "~"),
Self::Operation => ("!", "!"),
Self::MarkedGutter => ("\u{2502}", "|"),
Self::UnmarkedGutter => (" ", " "),
Self::ChecksPass => ("\u{2713}", "+"), Self::ChecksFail => ("\u{2717}", "x"), Self::ChecksPending => ("\u{2026}", "~"), Self::SessionWaiting => ("!", "!"),
Self::SessionWorking => ("\u{25cf}", "*"), Self::SessionIdle => ("\u{00b7}", "."), Self::Ellipsis => ("\u{2026}", "~"), }
}
}
pub fn truncate_middle(text: &str, max: usize, mode: GlyphMode) -> String {
if text.width() <= max {
return text.to_string();
}
let ellipsis = Glyph::Ellipsis.as_str(mode);
if max <= 1 {
return ellipsis.to_string();
}
let budget = max - 1;
let head_budget = budget.div_ceil(2);
let tail_budget = budget - head_budget;
let head = take_columns(text, head_budget);
let tail = take_columns_from_end(text, tail_budget);
format!("{head}{ellipsis}{tail}")
}
fn take_columns(text: &str, columns: usize) -> String {
let mut used = 0;
let mut out = String::new();
for ch in text.chars() {
let w = char_width(ch);
if used + w > columns {
break;
}
used += w;
out.push(ch);
}
out
}
fn take_columns_from_end(text: &str, columns: usize) -> String {
let mut used = 0;
let mut taken = 0;
for ch in text.chars().rev() {
let w = char_width(ch);
if used + w > columns {
break;
}
used += w;
taken += 1;
}
text.chars().skip(text.chars().count() - taken).collect()
}
fn char_width(ch: char) -> usize {
ch.to_string().width()
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
const ALL: &[Glyph] = &[
Glyph::Here,
Glyph::Open,
Glyph::Closed,
Glyph::MainMarker,
Glyph::SubmenuArrow,
Glyph::NotMain,
Glyph::InFlight,
Glyph::Operation,
Glyph::MarkedGutter,
Glyph::UnmarkedGutter,
Glyph::ChecksPass,
Glyph::ChecksFail,
Glyph::ChecksPending,
Glyph::SessionWaiting,
Glyph::SessionWorking,
Glyph::SessionIdle,
Glyph::Ellipsis,
];
#[test]
fn the_all_list_covers_every_variant() {
for glyph in ALL {
let named = match glyph {
Glyph::Here => "Here",
Glyph::Open => "Open",
Glyph::Closed => "Closed",
Glyph::MainMarker => "MainMarker",
Glyph::SubmenuArrow => "SubmenuArrow",
Glyph::NotMain => "NotMain",
Glyph::InFlight => "InFlight",
Glyph::Operation => "Operation",
Glyph::MarkedGutter => "MarkedGutter",
Glyph::UnmarkedGutter => "UnmarkedGutter",
Glyph::ChecksPass => "ChecksPass",
Glyph::ChecksFail => "ChecksFail",
Glyph::ChecksPending => "ChecksPending",
Glyph::SessionWaiting => "SessionWaiting",
Glyph::SessionWorking => "SessionWorking",
Glyph::SessionIdle => "SessionIdle",
Glyph::Ellipsis => "Ellipsis",
};
assert!(!named.is_empty());
}
assert_eq!(ALL.len(), 17, "ALL must list every variant exactly once");
}
#[test]
fn every_glyph_is_one_cell_wide_in_both_modes() {
for glyph in ALL {
for mode in [GlyphMode::Unicode, GlyphMode::Ascii] {
let s = glyph.as_str(mode);
assert_eq!(
s.width(),
1,
"{glyph:?} in {mode:?} is {} cells: {s:?}",
s.width()
);
assert_eq!(
s.chars().count(),
1,
"{glyph:?} in {mode:?} is not one char"
);
}
}
}
#[test]
fn every_ascii_form_is_ascii() {
for glyph in ALL {
let s = glyph.as_str(GlyphMode::Ascii);
assert!(s.is_ascii(), "{glyph:?} ascii form is not ascii: {s:?}");
}
}
#[test]
fn no_east_asian_ambiguous_glyph_is_in_the_table() {
let banned = ['✔', '⟳', '⚠', '▌', '⇊', '↑', '↓', '●'];
for glyph in ALL {
for mode in [GlyphMode::Unicode, GlyphMode::Ascii] {
let s = glyph.as_str(mode);
for ch in banned {
if ch == '\u{25cf}' {
continue;
}
assert!(
!s.contains(ch),
"{glyph:?} in {mode:?} uses the ambiguous-width {ch:?}"
);
}
}
}
}
#[test]
fn mode_resolves_from_the_flag_then_the_environment() {
assert_eq!(GlyphMode::resolve(false, None), GlyphMode::Unicode);
assert_eq!(GlyphMode::resolve(true, None), GlyphMode::Ascii);
assert_eq!(GlyphMode::resolve(true, Some("0")), GlyphMode::Ascii);
for truthy in ["1", "true", "TRUE", "yes", "on", "anything"] {
assert_eq!(
GlyphMode::resolve(false, Some(truthy)),
GlyphMode::Ascii,
"{truthy:?}"
);
}
for falsey in ["", "0", "false", "FALSE", "no", "off", " "] {
assert_eq!(
GlyphMode::resolve(false, Some(falsey)),
GlyphMode::Unicode,
"{falsey:?}"
);
}
assert_eq!(GlyphMode::default(), GlyphMode::Unicode);
}
#[test]
fn truncate_middle_keeps_both_ends_and_never_exceeds_the_budget() {
let mode = GlyphMode::Unicode;
assert_eq!(truncate_middle("short", 10, mode), "short");
assert_eq!(truncate_middle("exact", 5, mode), "exact");
let long = "issue-1585-worktrees-ui-phase-4c";
let out = truncate_middle(long, 20, mode);
assert!(out.width() <= 20, "{out:?} is {} cells", out.width());
assert!(out.starts_with("issue"), "the head survives: {out}");
assert!(out.ends_with("4c"), "the tail survives: {out}");
assert!(out.contains('\u{2026}'));
let ascii = truncate_middle(long, 20, GlyphMode::Ascii);
assert!(ascii.is_ascii());
assert!(ascii.width() <= 20);
assert!(ascii.contains('~'));
}
#[test]
fn truncate_middle_measures_columns_not_chars() {
let mode = GlyphMode::Unicode;
let cjk = "你好你好你好你好你好";
assert_eq!(cjk.width(), 20);
let out = truncate_middle(cjk, 11, mode);
assert!(
out.width() <= 11,
"{out:?} is {} cells, budget was 11",
out.width()
);
assert!(out
.chars()
.all(|c| c == '\u{2026}' || c == '你' || c == '好'));
}
#[test]
fn truncate_middle_handles_budgets_too_small_to_elide_into() {
let mode = GlyphMode::Unicode;
assert_eq!(truncate_middle("abcdef", 1, mode), "\u{2026}");
assert_eq!(truncate_middle("abcdef", 0, mode), "\u{2026}");
let two = truncate_middle("abcdef", 2, mode);
assert!(two.width() <= 2, "{two:?}");
let wide = truncate_middle("你好", 2, mode);
assert!(wide.width() <= 2, "{wide:?}");
}
}