Skip to content

Commit 2e4c576

Browse files
committed
fix: centralize ansi styling helpers
1 parent f678fa8 commit 2e4c576

5 files changed

Lines changed: 77 additions & 41 deletions

File tree

crates/deadreckon/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
11
pub mod cards;
22
pub mod sleep;
33
pub mod ui_card;
4+
5+
#[allow(dead_code)]
6+
mod ui;

crates/deadreckon/src/main.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22035,8 +22035,9 @@ mod tui_tests {
2203522035
#[test]
2203622036
fn chain_attach_plain_emits_periodic_snapshot_no_ansi() {
2203722037
let snapshot = chain_attach_header_text(&chain_fixture());
22038+
let ansi_start = format!("{}[", char::from(27));
2203822039

22039-
assert!(!snapshot.contains("\u{1b}["), "{snapshot}");
22040+
assert!(!snapshot.contains(&ansi_start), "{snapshot}");
2204022041
assert!(snapshot.contains("policy branch=stack"), "{snapshot}");
2204122042
}
2204222043

crates/deadreckon/src/ui.rs

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
use std::io::{self, IsTerminal, Write as _};
2+
use std::iter::Peekable;
23
use std::sync::atomic::{AtomicBool, Ordering};
34

45
use ratatui::style::Color;
56

67
static PLAIN_OUTPUT: AtomicBool = AtomicBool::new(false);
8+
#[allow(dead_code)]
9+
const ANSI_ESC: char = '\u{1b}';
10+
const ANSI_RESET: &str = "\x1b[0m";
711

812
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
913
pub(crate) enum Stream {
@@ -87,12 +91,56 @@ pub(crate) fn render(stream: Stream, tone: Tone, text: impl AsRef<str>) -> Strin
8791
return text.to_string();
8892
};
8993
if enabled(stream) {
90-
format!("\x1b[{code}m{text}\x1b[0m")
94+
ansi_wrap(code, text)
9195
} else {
9296
text.to_string()
9397
}
9498
}
9599

100+
pub(crate) fn ansi_wrap(code: &str, text: &str) -> String {
101+
format!("\x1b[{code}m{text}{ANSI_RESET}")
102+
}
103+
104+
#[allow(dead_code)]
105+
pub(crate) fn ansi_reset() -> &'static str {
106+
ANSI_RESET
107+
}
108+
109+
#[allow(dead_code)]
110+
pub(crate) fn strip_ansi(text: &str) -> String {
111+
let mut out = String::with_capacity(text.len());
112+
let mut chars = text.chars().peekable();
113+
while let Some(ch) = chars.next() {
114+
if read_ansi_sequence(ch, &mut chars).is_some() {
115+
continue;
116+
}
117+
out.push(ch);
118+
}
119+
out
120+
}
121+
122+
#[allow(dead_code)]
123+
pub(crate) fn read_ansi_sequence<I>(first: char, chars: &mut Peekable<I>) -> Option<(String, bool)>
124+
where
125+
I: Iterator<Item = char>,
126+
{
127+
if first != ANSI_ESC || chars.peek() != Some(&'[') {
128+
return None;
129+
}
130+
let mut sequence = String::from(first);
131+
if let Some(bracket) = chars.next() {
132+
sequence.push(bracket);
133+
}
134+
for code in chars.by_ref() {
135+
sequence.push(code);
136+
if code.is_ascii_alphabetic() {
137+
let active = sequence != ANSI_RESET;
138+
return Some((sequence, active));
139+
}
140+
}
141+
Some((sequence, false))
142+
}
143+
96144
pub(crate) fn status_tone(status: impl AsRef<str>) -> Tone {
97145
match status.as_ref().trim().to_ascii_lowercase().as_str() {
98146
"ok" | "ready" | "set" | "wrote" | "updated" | "installed" | "completed" | "passed"

crates/deadreckon/src/ui_card.rs

Lines changed: 7 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -123,21 +123,7 @@ pub fn pad_visible(text: &str, width: usize) -> String {
123123
}
124124

125125
pub fn strip_ansi(text: &str) -> String {
126-
let mut out = String::with_capacity(text.len());
127-
let mut chars = text.chars().peekable();
128-
while let Some(ch) = chars.next() {
129-
if ch == '\u{1b}' && chars.peek() == Some(&'[') {
130-
chars.next();
131-
for code in chars.by_ref() {
132-
if code.is_ascii_alphabetic() {
133-
break;
134-
}
135-
}
136-
continue;
137-
}
138-
out.push(ch);
139-
}
140-
out
126+
crate::ui::strip_ansi(text)
141127
}
142128

143129
fn body_lines(card: &Card, plain: bool, color: bool) -> Vec<String> {
@@ -256,15 +242,15 @@ fn border(position: &str, width: usize, plain: bool, color: bool) -> String {
256242

257243
fn style_border(text: &str, color: bool) -> String {
258244
if color {
259-
format!("\u{1b}[2m{text}\u{1b}[0m")
245+
crate::ui::ansi_wrap("2", text)
260246
} else {
261247
text.to_string()
262248
}
263249
}
264250

265251
fn style_title(text: &str, color: bool) -> String {
266252
if color {
267-
format!("\u{1b}[1m{text}\u{1b}[0m")
253+
crate::ui::ansi_wrap("1", text)
268254
} else {
269255
text.to_string()
270256
}
@@ -281,7 +267,7 @@ fn style_tone(text: &str, tone: Tone, color: bool) -> String {
281267
Tone::Bad => "31",
282268
Tone::Dim => "2",
283269
};
284-
format!("\u{1b}[{code}m{text}\u{1b}[0m")
270+
crate::ui::ansi_wrap(code, text)
285271
}
286272

287273
fn truncate_visible_for_mode(text: &str, width: usize, plain: bool) -> String {
@@ -310,18 +296,8 @@ fn truncate_visible_inner(text: &str, width: usize, ellipsis: &str) -> String {
310296
let mut active_style = false;
311297
let mut chars = text.chars().peekable();
312298
while let Some(ch) = chars.next() {
313-
if ch == '\u{1b}' && chars.peek() == Some(&'[') {
314-
let mut sequence = String::from(ch);
315-
if let Some(bracket) = chars.next() {
316-
sequence.push(bracket);
317-
}
318-
for code in chars.by_ref() {
319-
sequence.push(code);
320-
if code.is_ascii_alphabetic() {
321-
active_style = sequence != "\u{1b}[0m";
322-
break;
323-
}
324-
}
299+
if let Some((sequence, active)) = crate::ui::read_ansi_sequence(ch, &mut chars) {
300+
active_style = active;
325301
out.push_str(&sequence);
326302
continue;
327303
}
@@ -333,7 +309,7 @@ fn truncate_visible_inner(text: &str, width: usize, ellipsis: &str) -> String {
333309
}
334310
out.push_str(ellipsis);
335311
if active_style {
336-
out.push_str("\u{1b}[0m");
312+
out.push_str(crate::ui::ansi_reset());
337313
}
338314
out
339315
}

crates/deadreckon/tests/coherence.rs

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,22 @@ fn runstatus_executing_renders_running_through_glossary() {
2323
#[test]
2424
fn raw_ansi_escapes_stay_in_ui_module() {
2525
let manifest = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
26-
let main = fs::read_to_string(manifest.join("src/main.rs")).expect("main");
27-
assert!(!main.contains("\\x1b["), "main.rs contains raw ANSI");
28-
let ui = fs::read_to_string(manifest.join("src/ui.rs")).expect("ui");
29-
let ui_card = fs::read_to_string(manifest.join("src/ui_card.rs")).expect("ui_card");
30-
assert!(
31-
ui.contains("\\x1b[") || ui_card.contains("\\u{1b}["),
32-
"ui modules own ANSI rendering"
33-
);
26+
for entry in fs::read_dir(manifest.join("src")).expect("src dir") {
27+
let path = entry.expect("entry").path();
28+
if path.extension().and_then(|ext| ext.to_str()) != Some("rs") {
29+
continue;
30+
}
31+
let text = fs::read_to_string(&path).expect("source");
32+
if path.file_name().and_then(|name| name.to_str()) == Some("ui.rs") {
33+
assert!(text.contains("\\x1b["), "ui.rs owns raw ANSI rendering");
34+
continue;
35+
}
36+
assert!(
37+
!text.contains("\\x1b[") && !text.contains("\\u{1b}["),
38+
"{} contains raw ANSI escape construction",
39+
path.display()
40+
);
41+
}
3442
}
3543

3644
#[test]

0 commit comments

Comments
 (0)