use std::collections::BTreeMap;
use unicode_segmentation::UnicodeSegmentation;
use yuru_core::KeyKind;
use crate::api::{TuiLayout, TuiStyle};
use crate::preview::PreviewRender;
use crate::render::{
display_width, preview_geometry, render, terminal_safe_prefix, truncate_to_width_with_ellipsis,
RenderContext, Viewport,
};
use crate::state::TuiState;
use crate::TuiAction;
use super::helpers::{force_test_color_output, scored, scored_with_id};
fn csi_sequence(text: &str) -> Option<(usize, u8, &str)> {
let bytes = text.as_bytes();
if !bytes.starts_with(b"\x1b[") {
return None;
}
bytes[2..]
.iter()
.position(|byte| (0x40..=0x7e).contains(byte))
.map(|index| (index + 3, bytes[2 + index], &text[2..2 + index]))
}
fn without_escapes(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut offset = 0usize;
while offset < text.len() {
if let Some((len, _, _)) = csi_sequence(&text[offset..]) {
offset += len;
continue;
}
let ch = text[offset..]
.chars()
.next()
.expect("valid character boundary");
assert_ne!(ch, '\u{1b}', "unhandled escape sequence in {text:?}");
out.push(ch);
offset += ch.len_utf8();
}
out
}
fn painted_row_extents(rendered: &str) -> BTreeMap<usize, usize> {
let mut runs: Vec<(usize, usize, String)> = vec![(1, 1, String::new())];
let mut offset = 0usize;
while offset < rendered.len() {
if let Some((len, final_byte, params)) = csi_sequence(&rendered[offset..]) {
if final_byte == b'H' {
let mut parts = params.split(';');
let row = parts.next().and_then(|part| part.parse().ok()).unwrap_or(1);
let column = parts.next().and_then(|part| part.parse().ok()).unwrap_or(1);
runs.push((row, column, String::new()));
}
offset += len;
continue;
}
let ch = rendered[offset..]
.chars()
.next()
.expect("valid character boundary");
assert_ne!(ch, '\u{1b}', "unhandled escape sequence in {rendered:?}");
runs.last_mut().expect("a run is always open").2.push(ch);
offset += ch.len_utf8();
}
let mut extents = BTreeMap::new();
for (row, column, text) in runs {
if text.is_empty() {
continue;
}
let entry = extents.entry(row).or_insert(0);
*entry = (*entry).max(column.saturating_sub(1) + display_width(&text));
}
extents
}
const SELECTED_ROW_BG: &str = "48;2;52;58;70";
fn row_cell_backgrounds(rendered: &str, row: usize) -> Vec<(char, Option<String>)> {
let mut cells = Vec::new();
let mut current_row = 1usize;
let mut background: Option<String> = None;
let mut offset = 0usize;
while offset < rendered.len() {
if let Some((len, final_byte, params)) = csi_sequence(&rendered[offset..]) {
match final_byte {
b'H' => {
current_row = params
.split(';')
.next()
.and_then(|part| part.parse().ok())
.unwrap_or(1);
}
b'm' => apply_background_sgr(params, &mut background),
_ => {}
}
offset += len;
continue;
}
let ch = rendered[offset..]
.chars()
.next()
.expect("valid character boundary");
assert_ne!(ch, '\u{1b}', "unhandled escape sequence in {rendered:?}");
if current_row == row {
cells.push((ch, background.clone()));
}
offset += ch.len_utf8();
}
cells
}
fn apply_background_sgr(params: &str, background: &mut Option<String>) {
let params: Vec<&str> = params.split(';').collect();
let mut index = 0usize;
while index < params.len() {
let parameter = params[index];
let value = if parameter.is_empty() {
Some(0u16)
} else {
parameter.parse::<u16>().ok()
};
match value {
Some(0) | Some(49) => *background = None,
Some(value) if (40..=47).contains(&value) || (100..=107).contains(&value) => {
*background = Some(parameter.to_string());
}
Some(48) => {
let arguments = match params
.get(index + 1)
.and_then(|part| part.parse::<u16>().ok())
{
Some(5) => 2,
Some(2) => 4,
_ => 0,
};
let end = (index + 1 + arguments).min(params.len());
*background = Some(params[index..end].join(";"));
index = end - 1;
}
_ => {}
}
index += 1;
}
}
fn render_wide_frame(query: &str, display: &str, viewport: Viewport, ellipsis: &str) -> String {
force_test_color_output();
let mut output = Vec::new();
let state = TuiState::new(query);
let results = vec![scored(display, KeyKind::Original)];
render(
&mut output,
&state,
&results,
RenderContext {
candidates: &[],
prompt: "> ",
header: None,
footer: None,
viewport,
layout: TuiLayout::Default,
preview: None,
style: &TuiStyle::default(),
highlight_line: true,
case_sensitive: false,
multi: false,
no_input: false,
pointer: ">",
marker: "*",
ellipsis,
ansi: false,
},
)
.unwrap();
String::from_utf8(output).unwrap()
}
#[test]
fn render_default_layout_places_prompt_at_bottom() {
force_test_color_output();
let mut output = Vec::new();
let state = TuiState::new("al");
let results = vec![scored("alpha", KeyKind::Original)];
render(
&mut output,
&state,
&results,
RenderContext {
candidates: &[],
prompt: "> ",
header: None,
footer: None,
viewport: Viewport { width: 40, rows: 3 },
layout: TuiLayout::Default,
preview: None,
style: &TuiStyle::default(),
highlight_line: true,
case_sensitive: false,
multi: false,
no_input: false,
pointer: ">",
marker: "*",
ellipsis: "..",
ansi: false,
},
)
.unwrap();
let rendered = String::from_utf8(output).unwrap();
assert!(rendered.contains("\u{1b}[4;1H> al"), "{rendered:?}");
assert!(rendered.contains("\u{1b}[?25h"), "{rendered:?}");
assert!(
rendered.contains("\u{1b}[3;1H\u{1b}[48;2;52;58;70m> "),
"{rendered:?}"
);
assert!(!rendered.contains("\u{1b}[7m"), "{rendered:?}");
}
#[test]
fn render_positions_cursor_by_display_width_and_handles_zero_width() {
force_test_color_output();
let state = TuiState::new("日本");
for (width, expected_cursor) in [(40, "\u{1b}[3;7H"), (0, "\u{1b}[3;1H")] {
let mut output = Vec::new();
render(
&mut output,
&state,
&[],
RenderContext {
candidates: &[],
prompt: "> ",
header: None,
footer: None,
viewport: Viewport { width, rows: 2 },
layout: TuiLayout::Default,
preview: None,
style: &TuiStyle::default(),
highlight_line: true,
case_sensitive: false,
multi: false,
no_input: false,
pointer: ">",
marker: "*",
ellipsis: "..",
ansi: false,
},
)
.unwrap();
let rendered = String::from_utf8(output).unwrap();
assert!(rendered.contains(expected_cursor), "{rendered:?}");
}
}
#[test]
fn render_positions_cursor_after_visible_control_pictures() {
force_test_color_output();
let mut output = Vec::new();
let state = TuiState::new("\tA");
render(
&mut output,
&state,
&[],
RenderContext {
candidates: &[],
prompt: "\r",
header: None,
footer: None,
viewport: Viewport { width: 40, rows: 2 },
layout: TuiLayout::Default,
preview: None,
style: &TuiStyle::default(),
highlight_line: true,
case_sensitive: false,
multi: false,
no_input: false,
pointer: ">",
marker: "*",
ellipsis: "..",
ansi: false,
},
)
.unwrap();
let rendered = String::from_utf8(output).unwrap();
assert!(rendered.contains("␍␉A"), "{rendered:?}");
assert!(rendered.contains("\u{1b}[3;4H"), "{rendered:?}");
}
#[test]
fn render_default_layout_paints_results_bottom_up() {
force_test_color_output();
let mut output = Vec::new();
let state = TuiState::new("");
let results = vec![
scored("alpha", KeyKind::Original),
scored_with_id(1, "beta", KeyKind::Original),
scored_with_id(2, "gamma", KeyKind::Original),
];
render(
&mut output,
&state,
&results,
RenderContext {
candidates: &[],
prompt: "> ",
header: None,
footer: None,
viewport: Viewport { width: 40, rows: 3 },
layout: TuiLayout::Default,
preview: None,
style: &TuiStyle::default(),
highlight_line: true,
case_sensitive: false,
multi: false,
no_input: false,
pointer: ">",
marker: "*",
ellipsis: "..",
ansi: false,
},
)
.unwrap();
let rendered = String::from_utf8(output).unwrap();
assert!(
rendered.contains("\u{1b}[3;1H\u{1b}[48;2;52;58;70m> \u{1b}[39malpha"),
"{rendered:?}"
);
assert!(rendered.contains("\u{1b}[2;1H beta"), "{rendered:?}");
assert!(rendered.contains("\u{1b}[1;1H gamma"), "{rendered:?}");
}
#[test]
fn render_reverse_layout_places_prompt_at_top() {
force_test_color_output();
let mut output = Vec::new();
let state = TuiState::new("al");
let results = vec![scored("alpha", KeyKind::Original)];
render(
&mut output,
&state,
&results,
RenderContext {
candidates: &[],
prompt: "> ",
header: None,
footer: None,
viewport: Viewport { width: 40, rows: 3 },
layout: TuiLayout::Reverse,
preview: None,
style: &TuiStyle::default(),
highlight_line: true,
case_sensitive: false,
multi: false,
no_input: false,
pointer: ">",
marker: "*",
ellipsis: "..",
ansi: false,
},
)
.unwrap();
let rendered = String::from_utf8(output).unwrap();
assert!(rendered.contains("\u{1b}[1;1H> al"), "{rendered:?}");
}
#[test]
fn render_reverse_no_input_uses_first_row_for_results_and_preview() {
force_test_color_output();
let mut output = Vec::new();
let state = TuiState::new("");
let results = vec![scored("alpha", KeyKind::Original)];
render(
&mut output,
&state,
&results,
RenderContext {
candidates: &[],
prompt: "> ",
header: None,
footer: None,
viewport: Viewport { width: 80, rows: 3 },
layout: TuiLayout::Reverse,
preview: Some(PreviewRender::Text {
text: "preview alpha",
scroll: 0,
}),
style: &TuiStyle::default(),
highlight_line: true,
case_sensitive: false,
multi: false,
no_input: true,
pointer: ">",
marker: "*",
ellipsis: "..",
ansi: false,
},
)
.unwrap();
let rendered = String::from_utf8(output).unwrap();
assert!(
rendered.contains("\u{1b}[1;1H\u{1b}[48;2;52;58;70m> "),
"{rendered:?}"
);
assert!(
rendered.contains("\u{1b}[1;41Hpreview alpha"),
"{rendered:?}"
);
}
#[test]
fn preview_geometry_tracks_prompt_presence() {
let viewport = Viewport { width: 80, rows: 3 };
let with_prompt = preview_geometry(viewport, TuiLayout::Reverse, true, true).unwrap();
assert_eq!(with_prompt.top, 1);
assert_eq!(with_prompt.lines, 3);
let without_prompt = preview_geometry(viewport, TuiLayout::Reverse, false, true).unwrap();
assert_eq!(without_prompt.top, 0);
assert_eq!(without_prompt.lines, 3);
}
#[test]
fn render_preview_pane_prints_preview_text() {
force_test_color_output();
let mut output = Vec::new();
let state = TuiState::new("");
let results = vec![scored("alpha", KeyKind::Original)];
render(
&mut output,
&state,
&results,
RenderContext {
candidates: &[],
prompt: "> ",
header: None,
footer: None,
viewport: Viewport { width: 80, rows: 3 },
layout: TuiLayout::Default,
preview: Some(PreviewRender::Text {
text: "preview alpha\nsecond line",
scroll: 0,
}),
style: &TuiStyle::default(),
highlight_line: true,
case_sensitive: false,
multi: false,
no_input: false,
pointer: ">",
marker: "*",
ellipsis: "..",
ansi: false,
},
)
.unwrap();
let rendered = String::from_utf8(output).unwrap();
assert!(
rendered.contains("\u{1b}[1;41Hpreview alpha"),
"{rendered:?}"
);
assert!(rendered.contains("\u{1b}[2;41Hsecond line"), "{rendered:?}");
}
#[test]
fn render_preview_pane_uses_scroll_offset() {
force_test_color_output();
let mut output = Vec::new();
let state = TuiState::new("");
let results = vec![scored("alpha", KeyKind::Original)];
render(
&mut output,
&state,
&results,
RenderContext {
candidates: &[],
prompt: "> ",
header: None,
footer: None,
viewport: Viewport { width: 80, rows: 2 },
layout: TuiLayout::Default,
preview: Some(PreviewRender::Text {
text: "first\nsecond\nthird",
scroll: 1,
}),
style: &TuiStyle::default(),
highlight_line: true,
case_sensitive: false,
multi: false,
no_input: false,
pointer: ">",
marker: "*",
ellipsis: "..",
ansi: false,
},
)
.unwrap();
let rendered = String::from_utf8(output).unwrap();
assert!(!rendered.contains("first"), "{rendered:?}");
assert!(rendered.contains("\u{1b}[1;41Hsecond"), "{rendered:?}");
assert!(rendered.contains("\u{1b}[2;41Hthird"), "{rendered:?}");
}
#[test]
fn render_neutralizes_terminal_controls_from_all_untrusted_text_surfaces() {
force_test_color_output();
let payload = "evil\0\t\r\u{7}\u{7f}\u{1b}]52;c;Y2xpcGJvYXJk\u{7}\u{1b}[999C";
let mut output = Vec::new();
let state = TuiState::new("");
let results = vec![scored(payload, KeyKind::Original)];
render(
&mut output,
&state,
&results,
RenderContext {
candidates: &[],
prompt: "> ",
header: Some(payload),
footer: None,
viewport: Viewport {
width: 120,
rows: 3,
},
layout: TuiLayout::Reverse,
preview: Some(PreviewRender::Text {
text: payload,
scroll: 0,
}),
style: &TuiStyle::default(),
highlight_line: true,
case_sensitive: false,
multi: false,
no_input: true,
pointer: ">",
marker: "*",
ellipsis: "..",
ansi: false,
},
)
.unwrap();
let rendered = String::from_utf8(output).unwrap();
assert!(!rendered.contains("\u{1b}]52;"), "{rendered:?}");
assert!(!rendered.contains('\0'), "{rendered:?}");
assert!(!rendered.contains('\t'), "{rendered:?}");
assert!(!rendered.contains('\r'), "{rendered:?}");
assert!(!rendered.contains('\u{7}'), "{rendered:?}");
assert!(!rendered.contains('\u{7f}'), "{rendered:?}");
assert!(!rendered.contains("\u{1b}[999C"), "{rendered:?}");
assert!(rendered.contains("␀␉␍␇␡␛]52;c;Y2xpcGJvYXJk␇␛[999C"));
}
#[test]
fn render_ansi_mode_allows_only_sgr_sequences() {
force_test_color_output();
let display = "\u{1b}[31mred\u{1b}[0m\u{1b}]52;c;YQ==\u{7}";
let mut output = Vec::new();
let state = TuiState::new("");
let results = vec![scored(display, KeyKind::Original)];
render(
&mut output,
&state,
&results,
RenderContext {
candidates: &[],
prompt: "> ",
header: None,
footer: None,
viewport: Viewport { width: 80, rows: 2 },
layout: TuiLayout::Reverse,
preview: None,
style: &TuiStyle::default(),
highlight_line: false,
case_sensitive: false,
multi: false,
no_input: true,
pointer: ">",
marker: "*",
ellipsis: "..",
ansi: true,
},
)
.unwrap();
let rendered = String::from_utf8(output).unwrap();
assert!(rendered.contains("\u{1b}[31mred\u{1b}[0m"), "{rendered:?}");
assert!(!rendered.contains("\u{1b}]52;"), "{rendered:?}");
assert!(rendered.contains("␛]52;c;YQ==␇"), "{rendered:?}");
}
#[test]
fn painted_rows_never_exceed_the_viewport_width_for_wide_text() {
let cases = [
"日本語検索テスト用の候補行",
"日本語 mixed ASCII テキスト mixed",
"🎉🎊🎈 party 🎁🎀 time 🥳",
"한국어 검색 테스트 후보 행",
"中文搜索测试候选行",
];
for display in cases {
for width in [4usize, 5, 8, 13, 20, 21, 40] {
let rendered = render_wide_frame(display, display, Viewport { width, rows: 3 }, "..");
for (row, extent) in painted_row_extents(&rendered) {
assert!(
extent <= width,
"row {row} of a {width}-column viewport reached column {extent} \
for {display:?}: {rendered:?}"
);
}
}
}
}
#[test]
fn a_wide_character_at_an_odd_boundary_is_dropped_not_split() {
let display = "日本語検索テスト用の候補行";
let rendered = render_wide_frame("", display, Viewport { width: 21, rows: 3 }, "..");
assert!(rendered.contains("日本語検索テスト用"), "{rendered:?}");
assert!(!rendered.contains('の'), "{rendered:?}");
assert_eq!(painted_row_extents(&rendered).get(&3).copied(), Some(21));
}
#[test]
fn preview_pane_truncates_a_cjk_line_by_display_columns() {
force_test_color_output();
let line = "日本語検索テスト用の候補行".repeat(3);
let mut output = Vec::new();
let state = TuiState::new("");
let results = vec![scored("alpha", KeyKind::Original)];
render(
&mut output,
&state,
&results,
RenderContext {
candidates: &[],
prompt: "> ",
header: None,
footer: None,
viewport: Viewport { width: 80, rows: 3 },
layout: TuiLayout::Default,
preview: Some(PreviewRender::Text {
text: &line,
scroll: 0,
}),
style: &TuiStyle::default(),
highlight_line: true,
case_sensitive: false,
multi: false,
no_input: false,
pointer: ">",
marker: "*",
ellipsis: "..",
ansi: false,
},
)
.unwrap();
let rendered = String::from_utf8(output).unwrap();
assert!(
rendered.contains("\u{1b}[1;41H日本語検索テスト用の候補行日本語検索テ.."),
"{rendered:?}"
);
for (row, extent) in painted_row_extents(&rendered) {
assert!(
extent <= 80,
"row {row} reached column {extent}: {rendered:?}"
);
}
}
#[test]
fn truncation_charges_the_ellipsis_in_display_columns() {
let line = "日本語検索テスト";
assert_eq!(
truncate_to_width_with_ellipsis(line, 11, ".."),
"日本語検.."
);
assert_eq!(
truncate_to_width_with_ellipsis(line, 11, "…"),
"日本語検索…"
);
assert_eq!(truncate_to_width_with_ellipsis(line, 16, ".."), line);
assert_eq!(truncate_to_width_with_ellipsis(line, 1, ".."), ".");
}
const TWO_COLUMN_CLUSTERS: [&str; 3] = ["日\u{301}", "👩\u{1f3fd}", "#\u{fe0f}\u{20e3}"];
#[test]
fn terminal_safe_prefix_budgets_by_grapheme_cluster() {
for cluster in TWO_COLUMN_CLUSTERS {
assert_eq!(
terminal_safe_prefix(&format!("{cluster}x"), false, 2),
(cluster.to_string(), true)
);
assert_eq!(
terminal_safe_prefix(cluster, false, 1),
(String::new(), true)
);
}
assert_eq!(
terminal_safe_prefix("\u{1b}[31m日\u{301}\u{1b}[0m", true, 2),
("\u{1b}[31m日\u{301}\u{1b}[0m".to_string(), false)
);
}
#[test]
fn display_width_stays_additive_at_every_cluster_boundary() {
for text in [
"🇯🇵\u{200d}🇯🇵",
"日\u{301}本\u{301}",
"👩\u{1f3fd}👩\u{1f3fd}",
] {
for width in 0..=8 {
let (clipped, _) = terminal_safe_prefix(text, false, width);
let clipped_width = display_width(&clipped);
assert!(
clipped_width <= width,
"{clipped:?} costs {clipped_width} of a {width}-column budget"
);
let per_cluster: usize = clipped.graphemes(true).map(display_width).sum();
assert_eq!(per_cluster, clipped_width, "{clipped:?}");
}
}
}
#[test]
fn a_grapheme_cluster_survives_truncation_or_is_dropped_whole() {
for display in TWO_COLUMN_CLUSTERS {
let rendered = render_wide_frame("", display, Viewport { width: 4, rows: 3 }, "..");
assert!(
rendered.contains(display),
"{display:?} was clipped mid-cluster: {rendered:?}"
);
let rendered = render_wide_frame("", display, Viewport { width: 3, rows: 3 }, "..");
for ch in display.chars() {
assert!(
!rendered.contains(ch),
"{display:?} left {ch:?} behind: {rendered:?}"
);
}
}
let rendered = render_wide_frame(
"",
"日\u{301}本\u{301}",
Viewport { width: 4, rows: 3 },
"..",
);
assert!(rendered.contains("日\u{301}"), "{rendered:?}");
assert!(!rendered.contains('本'), "{rendered:?}");
}
fn render_marked_frame(marked: bool, marker: &str, viewport: Viewport) -> String {
force_test_color_output();
let mut output = Vec::new();
let mut state = TuiState::new("");
let results = vec![scored("alpha", KeyKind::Original)];
if marked {
state.apply_with_results(TuiAction::ToggleMark, &results, false, true, None);
}
render(
&mut output,
&state,
&results,
RenderContext {
candidates: &[],
prompt: "> ",
header: None,
footer: None,
viewport,
layout: TuiLayout::Default,
preview: None,
style: &TuiStyle::default(),
highlight_line: true,
case_sensitive: false,
multi: true,
no_input: false,
pointer: ">",
marker,
ellipsis: "..",
ansi: false,
},
)
.unwrap();
String::from_utf8(output).unwrap()
}
#[test]
fn a_wide_custom_marker_reserves_only_the_columns_the_row_prints() {
let rendered = render_marked_frame(false, "界", Viewport { width: 10, rows: 3 });
assert!(!rendered.contains('界'), "{rendered:?}");
assert_eq!(painted_row_extents(&rendered).get(&3).copied(), Some(10));
let rendered = render_marked_frame(true, "界", Viewport { width: 10, rows: 3 });
assert!(rendered.contains('界'), "{rendered:?}");
assert_eq!(painted_row_extents(&rendered).get(&3).copied(), Some(10));
}
#[test]
fn a_gutter_wider_than_the_viewport_is_clipped_to_it() {
let rendered = render_marked_frame(true, "界界界界界", Viewport { width: 10, rows: 3 });
assert_eq!(painted_row_extents(&rendered).get(&3).copied(), Some(10));
assert!(rendered.contains(">界界界界\u{1b}"), "{rendered:?}");
assert!(!rendered.contains("界界界界界"), "{rendered:?}");
let rendered = render_marked_frame(false, "*", Viewport { width: 2, rows: 3 });
for (row, extent) in painted_row_extents(&rendered) {
assert!(
extent <= 2,
"row {row} reached column {extent}: {rendered:?}"
);
}
}
fn render_ansi_frame(display: &str, viewport: Viewport) -> String {
force_test_color_output();
let mut output = Vec::new();
let state = TuiState::new("");
let results = vec![scored(display, KeyKind::Original)];
render(
&mut output,
&state,
&results,
RenderContext {
candidates: &[],
prompt: "> ",
header: None,
footer: None,
viewport,
layout: TuiLayout::Default,
preview: None,
style: &TuiStyle::default(),
highlight_line: true,
case_sensitive: false,
multi: false,
no_input: false,
pointer: ">",
marker: "*",
ellipsis: "..",
ansi: true,
},
)
.unwrap();
String::from_utf8(output).unwrap()
}
#[test]
fn a_combining_mark_reaches_its_base_across_an_sgr_sequence() {
let text = "\u{1b}[31m日\u{1b}[0m\u{301}";
assert_eq!(
terminal_safe_prefix(text, true, 2),
(text.to_string(), false)
);
let rendered = render_ansi_frame(text, Viewport { width: 4, rows: 3 });
assert!(rendered.contains(text), "{rendered:?}");
for (row, extent) in painted_row_extents(&rendered) {
assert!(
extent <= 4,
"row {row} reached column {extent}: {rendered:?}"
);
}
let (clipped, truncated) = terminal_safe_prefix(text, true, 1);
assert!(!clipped.contains('日'), "{clipped:?}");
assert!(!clipped.contains('\u{301}'), "{clipped:?}");
assert!(truncated);
}
const SGR_SPLIT_CLUSTERS: [(&str, &str); 5] = [
("#\u{fe0f}\u{20e3}", "#\u{1b}[31m\u{fe0f}\u{20e3}"),
("👩\u{1f3fb}", "👩\u{1b}[0m\u{1f3fb}"),
("👩\u{200d}💻", "👩\u{1b}[1m\u{200d}💻"),
("🇯🇵", "🇯\u{1b}[32m🇵"),
("☺\u{fe0f}", "☺\u{1b}[33m\u{fe0f}"),
];
#[test]
fn an_sgr_sequence_inside_a_cluster_changes_nothing_but_the_bytes() {
for (plain, styled) in SGR_SPLIT_CLUSTERS {
for width in 0..=6 {
let (plain_clipped, plain_truncated) = terminal_safe_prefix(plain, false, width);
let (styled_clipped, styled_truncated) = terminal_safe_prefix(styled, true, width);
assert_eq!(
without_escapes(&styled_clipped),
plain_clipped,
"{styled:?} at {width} columns"
);
assert_eq!(
styled_truncated, plain_truncated,
"{styled:?} at {width} columns"
);
let cost = display_width(&without_escapes(&styled_clipped));
assert!(cost <= width, "{styled_clipped:?} costs {cost} of {width}");
}
}
}
#[test]
fn a_cluster_split_by_an_sgr_sequence_survives_the_row_or_is_dropped_whole() {
for (plain, styled) in SGR_SPLIT_CLUSTERS {
let fits = 2 + display_width(plain);
let rendered = render_ansi_frame(
styled,
Viewport {
width: fits,
rows: 3,
},
);
assert!(
rendered.contains(styled),
"{styled:?} lost part of its cluster: {rendered:?}"
);
for (row, extent) in painted_row_extents(&rendered) {
assert!(
extent <= fits,
"row {row} reached column {extent} of a {fits}-column viewport: {rendered:?}"
);
}
let rendered = render_ansi_frame(
styled,
Viewport {
width: fits - 1,
rows: 3,
},
);
for ch in plain.chars() {
assert!(
!rendered.contains(ch),
"{styled:?} left {ch:?} behind: {rendered:?}"
);
}
for (row, extent) in painted_row_extents(&rendered) {
assert!(
extent < fits,
"row {row} reached column {extent} of a {}-column viewport: {rendered:?}",
fits - 1
);
}
}
}
#[test]
fn a_keycap_split_by_an_sgr_sequence_does_not_overflow_a_three_column_row() {
let rendered = render_ansi_frame(
"#\u{1b}[31m\u{fe0f}\u{20e3}",
Viewport { width: 3, rows: 3 },
);
assert_eq!(painted_row_extents(&rendered).get(&3).copied(), Some(3));
assert!(!rendered.contains('#'), "{rendered:?}");
}
#[test]
fn an_emoji_modifier_split_by_an_sgr_sequence_is_not_dropped_from_a_row_it_fits() {
let text = "👩\u{1b}[0m\u{1f3fb}";
assert_eq!(
terminal_safe_prefix(text, true, 2),
(text.to_string(), false)
);
let rendered = render_ansi_frame(text, Viewport { width: 4, rows: 3 });
assert!(rendered.contains(text), "{rendered:?}");
}
const CLEARING_BOUNDARY_SGRS: [&str; 18] = [
"\u{1b}[0m",
"\u{1b}[m",
"\u{1b}[00m",
"\u{1b}[0;m",
"\u{1b}[22m",
"\u{1b}[23m",
"\u{1b}[24m",
"\u{1b}[25m",
"\u{1b}[27m",
"\u{1b}[28m",
"\u{1b}[29m",
"\u{1b}[39m",
"\u{1b}[49m",
"\u{1b}[0;39;49m",
"\u{1b}[7;27m",
"\u{1b}[55m",
"\u{1b}[54m",
"\u{1b}[4:0m",
];
const SETTING_BOUNDARY_SGRS: [&str; 13] = [
"\u{1b}[41m",
"\u{1b}[31m",
"\u{1b}[1m",
"\u{1b}[0;31m",
"\u{1b}[39;41m",
"\u{1b}[38;5;49m",
"\u{1b}[48;2;39;49;22m",
"\u{1b}[53m",
"\u{1b}[4:3m",
"\u{1b}[27;7m",
"\u{1b}[38m",
"\u{1b}[38:5:29m",
"\u{1b}[48:2:39:49:22m",
];
#[test]
fn a_boundary_sgr_sequence_is_kept_only_when_it_clears_styling() {
for sequence in CLEARING_BOUNDARY_SGRS {
assert_eq!(
terminal_safe_prefix(&format!("a{sequence}\u{754c}"), true, 2),
(format!("a{sequence}"), true),
"{sequence:?} clears styling and belongs to the retained text"
);
}
for sequence in SETTING_BOUNDARY_SGRS {
assert_eq!(
terminal_safe_prefix(&format!("a{sequence}\u{754c}"), true, 2),
("a".to_string(), true),
"{sequence:?} sets styling and belongs to the discarded character"
);
}
}
#[test]
fn a_setting_sgr_sequence_inside_the_retained_text_is_untouched() {
for sequence in SETTING_BOUNDARY_SGRS {
let text = format!("{sequence}ab\u{754c}");
assert_eq!(
terminal_safe_prefix(&text, true, 2),
(format!("{sequence}ab"), true),
"{sequence:?} is inside the retained text"
);
}
}
#[test]
fn a_boundary_sgr_opener_does_not_recolour_the_selected_row_padding() {
let rendered = render_ansi_frame("a\u{1b}[41m\u{754c}", Viewport { width: 4, rows: 3 });
assert!(
!rendered.contains("\u{1b}[41m"),
"the discarded character's opener reached the terminal: {rendered:?}"
);
assert_eq!(
row_cell_backgrounds(&rendered, 3),
vec![
('>', Some(SELECTED_ROW_BG.to_string())),
(' ', Some(SELECTED_ROW_BG.to_string())),
('a', Some(SELECTED_ROW_BG.to_string())),
(' ', Some(SELECTED_ROW_BG.to_string())),
],
"{rendered:?}"
);
let rendered = render_ansi_frame("a\u{754c}", Viewport { width: 4, rows: 3 });
assert_eq!(
row_cell_backgrounds(&rendered, 3),
vec![
('>', Some(SELECTED_ROW_BG.to_string())),
(' ', Some(SELECTED_ROW_BG.to_string())),
('a', Some(SELECTED_ROW_BG.to_string())),
(' ', Some(SELECTED_ROW_BG.to_string())),
],
"{rendered:?}"
);
}
#[test]
fn a_boundary_reset_still_reaches_the_terminal() {
let rendered = render_ansi_frame(
"\u{1b}[31ma\u{1b}[0m\u{754c}",
Viewport { width: 4, rows: 3 },
);
assert!(rendered.contains("\u{1b}[31ma\u{1b}[0m"), "{rendered:?}");
assert!(!rendered.contains('\u{754c}'), "{rendered:?}");
}
#[test]
fn free_content_is_followed_no_further_than_the_byte_cap() {
for (text, allow_sgr) in [
("\u{200b}".repeat(100_000), false),
("\u{1b}[31m".repeat(100_000), true),
] {
let (clipped, truncated) = terminal_safe_prefix(&text, allow_sgr, 4);
assert!(truncated);
assert!(clipped.len() <= 4 * 64, "{} bytes scanned", clipped.len());
}
let text = format!("{}a\u{301}b", "\u{200b}".repeat(21));
let (clipped, truncated) = terminal_safe_prefix(&text, false, 1);
assert!(clipped.ends_with("a\u{301}"), "{clipped:?}");
assert!(truncated);
}
fn render_prompt_frame(prompt: &str, viewport: Viewport) -> String {
force_test_color_output();
let mut output = Vec::new();
let state = TuiState::new("");
let results = vec![scored("alpha", KeyKind::Original)];
render(
&mut output,
&state,
&results,
RenderContext {
candidates: &[],
prompt,
header: None,
footer: None,
viewport,
layout: TuiLayout::Default,
preview: None,
style: &TuiStyle::default(),
highlight_line: true,
case_sensitive: false,
multi: false,
no_input: false,
pointer: ">",
marker: "*",
ellipsis: "..",
ansi: false,
},
)
.unwrap();
String::from_utf8(output).unwrap()
}
#[test]
fn a_zero_width_tail_is_not_a_truncation() {
assert_eq!(
terminal_safe_prefix("ab\u{200b}", false, 2),
("ab\u{200b}".to_string(), false)
);
assert_eq!(
truncate_to_width_with_ellipsis("ab\u{200b}", 2, ".."),
"ab\u{200b}"
);
assert_eq!(
truncate_to_width_with_ellipsis("ab\u{200b}c", 2, ".."),
".."
);
let rendered = render_prompt_frame("ab\u{200b}", Viewport { width: 2, rows: 3 });
assert!(rendered.contains("ab\u{200b}"), "{rendered:?}");
assert!(!rendered.contains(".."), "{rendered:?}");
for (row, extent) in painted_row_extents(&rendered) {
assert!(
extent <= 2,
"row {row} reached column {extent}: {rendered:?}"
);
}
}