Skip to content

Commit 83c8c20

Browse files
authored
Add ASCII fallback for non-UTF-8 terminals (#34)
1 parent c9dfff1 commit 83c8c20

4 files changed

Lines changed: 265 additions & 34 deletions

File tree

src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ fn run_tui() -> io::Result<()> {
1414
crossterm::terminal::enable_raw_mode()?;
1515
let mut stdout = io::stdout();
1616
crossterm::execute!(stdout, crossterm::terminal::EnterAlternateScreen)?;
17+
tui::glyphs::detect();
1718

1819
let backend = ratatui::backend::CrosstermBackend::new(stdout);
1920
let mut terminal = ratatui::Terminal::new(backend)?;

src/tui/glyphs.rs

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
//! ASCII fallback for terminals that can't render UTF-8, where box-drawing and
2+
//! block glyphs come out as garbage. We probe the terminal at startup (see
3+
//! [`detect`]) and build the UI in Unicode or ASCII accordingly.
4+
5+
use ratatui::symbols;
6+
use ratatui::widgets::{Scrollbar, ScrollbarOrientation};
7+
use std::borrow::Cow;
8+
use std::io::Write;
9+
use std::sync::OnceLock;
10+
11+
static UNICODE: OnceLock<bool> = OnceLock::new();
12+
13+
/// Whether we can safely draw UTF-8 glyphs. Cached after [`detect`].
14+
pub fn unicode() -> bool {
15+
*UNICODE.get_or_init(locale_utf8)
16+
}
17+
18+
/// Decide UTF-8 support from the live terminal, once, at startup. Must run in
19+
/// raw mode with the alternate screen active. Inside tmux we trust its client
20+
/// flag; otherwise we probe the terminal, then fall back to the locale.
21+
pub fn detect() {
22+
// The probe is fooled inside tmux (see tmux_utf8), so when the tmux
23+
// query itself fails we must fall back to the locale, never the probe.
24+
let ok = if std::env::var_os("TMUX").is_some() {
25+
tmux_utf8().unwrap_or_else(locale_utf8)
26+
} else {
27+
probe().unwrap_or_else(locale_utf8)
28+
};
29+
let _ = UNICODE.set(ok);
30+
}
31+
32+
/// Whether tmux will actually render UTF-8. The cursor probe is fooled under
33+
/// tmux: its grid always decodes UTF-8, so the cursor advances as if UTF-8 even
34+
/// when the client (non-UTF-8 locale, no `-u`) will strip the bytes to garbage.
35+
/// `#{client_utf8}` is the only signal that reflects what reaches the terminal.
36+
/// `None` when not under tmux, or no client is attached to answer.
37+
fn tmux_utf8() -> Option<bool> {
38+
use std::process::{Command, Stdio};
39+
std::env::var_os("TMUX")?;
40+
let mut child = Command::new("tmux")
41+
.args(["display-message", "-p", "#{client_utf8}"])
42+
.stdin(Stdio::null())
43+
.stdout(Stdio::piped())
44+
.stderr(Stdio::null())
45+
.spawn()
46+
.ok()?;
47+
// Raw mode already disabled Ctrl-C, so a wedged tmux server must not
48+
// block startup forever: kill the query if it outlives the deadline.
49+
let deadline = std::time::Instant::now() + std::time::Duration::from_millis(500);
50+
while child.try_wait().ok()?.is_none() {
51+
if std::time::Instant::now() >= deadline {
52+
let _ = child.kill();
53+
let _ = child.wait();
54+
return None;
55+
}
56+
std::thread::sleep(std::time::Duration::from_millis(10));
57+
}
58+
let out = child.wait_with_output().ok()?;
59+
match out.stdout.first().copied()? {
60+
b'1' => Some(true),
61+
b'0' => Some(false),
62+
_ => None,
63+
}
64+
}
65+
66+
/// Print a 1-column multibyte glyph and see how far the cursor moved: 1 column
67+
/// means the terminal decoded UTF-8, more means it counted raw bytes.
68+
fn probe() -> Option<bool> {
69+
use crossterm::{cursor, queue, terminal};
70+
let mut out = std::io::stdout();
71+
queue!(out, cursor::MoveTo(0, 0)).ok()?;
72+
write!(out, "\u{2588}").ok()?; // █: 3 UTF-8 bytes, 1 display column
73+
out.flush().ok()?;
74+
let (col, _) = cursor::position().ok()?;
75+
queue!(
76+
out,
77+
cursor::MoveTo(0, 0),
78+
terminal::Clear(terminal::ClearType::All)
79+
)
80+
.ok()?;
81+
out.flush().ok()?;
82+
Some(col == 1)
83+
}
84+
85+
/// Locale fallback, like htop: a UTF-8 `LC_*`/`LANG` gets blocks, else ASCII.
86+
fn locale_utf8() -> bool {
87+
// POSIX precedence: LC_ALL > LC_CTYPE > LANG; first non-empty decides.
88+
for k in ["LC_ALL", "LC_CTYPE", "LANG"] {
89+
if let Ok(v) = std::env::var(k) {
90+
if !v.is_empty() {
91+
let v = v.to_ascii_uppercase();
92+
return v.contains("UTF-8") || v.contains("UTF8");
93+
}
94+
}
95+
}
96+
false
97+
}
98+
99+
/// Map a Unicode glyph to its closest ASCII stand-in.
100+
fn ascii(c: char) -> Option<&'static str> {
101+
Some(match c {
102+
'│' | '▏' | '▐' => "|",
103+
'─' => "-",
104+
'↑' | '▲' => "^",
105+
'↓' | '▼' => "v",
106+
'←' | '◀' => "<",
107+
'→' | '▶' => ">",
108+
'●' => "*",
109+
'▬' => "=",
110+
'±' => "+-",
111+
'…' => "...",
112+
'█' | '▇' => "#",
113+
'░' => "-",
114+
'▆' | '▅' => "*",
115+
'▄' | '▃' => "+",
116+
'▂' => ".",
117+
'▁' => "_",
118+
_ => return None,
119+
})
120+
}
121+
122+
/// Transliterate a string to ASCII when the terminal can't do UTF-8.
123+
pub fn tr(s: &str) -> Cow<'_, str> {
124+
if unicode() || s.is_ascii() {
125+
return Cow::Borrowed(s);
126+
}
127+
let mut out = String::with_capacity(s.len());
128+
for c in s.chars() {
129+
match ascii(c) {
130+
Some(a) => out.push_str(a),
131+
None => out.push(c),
132+
}
133+
}
134+
Cow::Owned(out)
135+
}
136+
137+
/// Throughput meter fill/empty chars: solid block over faint shade in UTF-8,
138+
/// htop-style pipe over blank in ASCII.
139+
pub fn meter() -> (char, char) {
140+
if unicode() {
141+
('█', '░')
142+
} else {
143+
('|', ' ')
144+
}
145+
}
146+
147+
/// Selection cursor for tables (`highlight_symbol`).
148+
pub fn cursor() -> &'static str {
149+
if unicode() {
150+
"▶ "
151+
} else {
152+
"> "
153+
}
154+
}
155+
156+
/// Border set for `Block`s; ASCII box when the locale isn't UTF-8.
157+
pub fn border() -> symbols::border::Set<'static> {
158+
if unicode() {
159+
symbols::border::PLAIN
160+
} else {
161+
symbols::border::Set {
162+
top_left: "+",
163+
top_right: "+",
164+
bottom_left: "+",
165+
bottom_right: "+",
166+
vertical_left: "|",
167+
vertical_right: "|",
168+
horizontal_top: "-",
169+
horizontal_bottom: "-",
170+
}
171+
}
172+
}
173+
174+
pub fn v_scrollbar() -> Scrollbar<'static> {
175+
let (thumb, track, up, down) = if unicode() {
176+
("▐", "│", "▲", "▼")
177+
} else {
178+
("#", "|", "^", "v")
179+
};
180+
Scrollbar::new(ScrollbarOrientation::VerticalRight)
181+
.thumb_symbol(thumb)
182+
.track_symbol(Some(track))
183+
.begin_symbol(Some(up))
184+
.end_symbol(Some(down))
185+
}
186+
187+
pub fn h_scrollbar() -> Scrollbar<'static> {
188+
let (thumb, track, left, right) = if unicode() {
189+
("▬", "─", "◀", "▶")
190+
} else {
191+
("=", "-", "<", ">")
192+
};
193+
Scrollbar::new(ScrollbarOrientation::HorizontalBottom)
194+
.thumb_symbol(thumb)
195+
.track_symbol(Some(track))
196+
.begin_symbol(Some(left))
197+
.end_symbol(Some(right))
198+
}
199+
200+
#[cfg(test)]
201+
mod tests {
202+
use super::ascii;
203+
204+
#[test]
205+
fn ascii_map_covers_ui_glyphs() {
206+
// Every glyph the UI emits must have an ASCII stand-in, and none map
207+
// back to a multibyte char.
208+
for c in [
209+
'│', '─', '↑', '↓', '←', '→', '●', '▏', '▐', '▲', '▼', '◀', '▶', '▬', '±', '…', '█',
210+
'▇', '▆', '▅', '▄', '▃', '▂', '▁', '░',
211+
] {
212+
let a = ascii(c).unwrap_or_else(|| panic!("no ascii for {c:?}"));
213+
assert!(a.is_ascii(), "{c:?} -> {a:?} is not ascii");
214+
}
215+
}
216+
}

src/tui/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
pub mod app;
22
pub mod events;
3+
pub mod glyphs;
34
pub mod theme;
45
pub mod ui;

0 commit comments

Comments
 (0)