use ratatui::style::Style;
use ratatui::text::Span;
use super::super::super::theme::Palette;
use crate::output::width::{char_columns, text_columns};
pub const MIN_WIDTH: u16 = 31;
pub const MIN_HEIGHT: u16 = 6;
pub const GUTTER: u16 = 2;
#[must_use]
pub const fn mark(selected: bool) -> &'static str {
if selected { ">" } else { " " }
}
#[must_use]
pub fn gutter(selected: bool, palette: Palette) -> (&'static str, Style) {
match (selected, palette.ground()) {
(false, _) => (" ", Style::default()),
(true, ground) if ground.bg.is_some() => (" ", ground),
(true, _) => (mark(true), Style::default()),
}
}
#[must_use]
pub fn fit_owned(mut text: String, width: u16) -> String {
let columns: usize = text_columns(&text);
let width = usize::from(width);
if columns <= width {
text.extend(core::iter::repeat_n(' ', width - columns));
return text;
}
fit(&text, u16::try_from(width).unwrap_or(u16::MAX))
}
#[must_use]
pub(in crate::lookout::view) fn take_columns(text: &str, budget: usize) -> (String, usize) {
let mut kept = String::new();
let mut used = 0;
for c in text.chars() {
let columns = char_columns(c);
if used + columns > budget {
break;
}
kept.push(c);
used += columns;
}
(kept, used)
}
#[must_use]
pub fn fit(text: &str, width: u16) -> String {
let width = usize::from(width);
let columns: usize = text_columns(text);
if columns <= width {
let mut out = String::from(text);
out.extend(core::iter::repeat_n(' ', width - columns));
return out;
}
if width == 0 {
return String::new();
}
let budget = width - 1;
let (mut out, used) = take_columns(text, budget);
out.push('…');
out.extend(core::iter::repeat_n(' ', budget - used));
out
}
pub fn pad_ground(spans: &mut Vec<Span<'static>>, used: u16, width: u16, ground: Style) {
let short = width.saturating_sub(used);
if short > 0 {
spans.push(Span::styled(" ".repeat(usize::from(short)), ground));
}
}
#[must_use]
pub fn scroll_offset(selected: usize, viewport: usize, total: usize) -> usize {
if viewport == 0 || total <= viewport {
return 0;
}
let last = total - viewport;
selected.saturating_sub(viewport / 2).min(last)
}
#[cfg(test)]
mod tests {
use super::*;
use std::ffi::OsStr;
#[test]
fn the_painted_gutter_is_one_column_and_holds_no_glyph() {
let deep = Palette::detect(None, None, Some(OsStr::new("truecolor")));
let (text, style) = gutter(true, deep);
assert_eq!(
text, " ",
"a space, not a block: a block is Ambiguous width"
);
assert_eq!(
crate::output::width::char_columns(text.chars().next().unwrap()),
1
);
assert!(style.bg.is_some());
}
#[test]
fn without_colour_the_gutter_falls_back_to_the_ascii_marker() {
let off = Palette::detect(Some(OsStr::new("1")), None, None);
let (text, style) = gutter(true, off);
assert_eq!(text, ">");
assert_eq!(style, Style::default());
assert_eq!(gutter(false, off).0, " ");
}
#[test]
fn take_columns_with_no_budget_keeps_nothing() {
assert_eq!(take_columns("羊", 0), (String::new(), 0));
}
#[test]
fn take_columns_drops_a_wide_character_that_straddles_the_budget() {
assert_eq!(take_columns("羊羊", 3), ("羊".to_owned(), 2));
}
#[test]
fn a_name_too_long_for_its_column_ends_in_an_ellipsis() {
let cut = fit("payments-reconciliation-worker", 12);
assert_eq!(cut.chars().count(), 12);
assert!(cut.ends_with('…'));
assert!(cut.starts_with("payments"));
assert_eq!(fit("web", 12), "web ");
}
#[test]
fn fit_counts_columns_not_bytes_when_it_pads_and_when_it_truncates() {
assert_eq!(fit("日本語", 6), "日本語");
assert_eq!(fit("ünïcödé", 7), "ünïcödé");
}
#[test]
fn a_double_width_name_is_cut_to_the_columns_it_draws_in() {
assert_eq!(fit("日本語アプリ", 5), "日本…");
assert_eq!(text_columns(&fit("日本語アプリ", 5)), 5);
assert_eq!(fit("日本語", 3), "日…");
assert_eq!(fit("日本語", 4), "日… ");
assert_eq!(text_columns(&fit("日本語", 4)), 4);
assert_eq!(fit("日本語", 1), "…");
assert_eq!(fit("日本語", 2), "… ");
}
#[test]
fn every_cell_measures_exactly_the_width_it_was_given() {
let names = [
"web",
"payments-reconciliation-worker",
"日本語アプリ",
"café",
"cafe\u{301}",
"羊",
"",
];
for name in names {
for width in 0..=12u16 {
let cell = fit(name, width);
assert_eq!(
text_columns(&cell),
usize::from(width),
"fit({name:?}, {width}) == {cell:?}"
);
}
}
}
#[test]
fn an_escape_sequence_is_measured_as_the_text_it_will_be_drawn_as() {
let styled = "\u{1b}[32mup";
assert_eq!(text_columns(styled), 6);
assert_eq!(text_columns(&fit(styled, 4)), 4);
assert!(fit(styled, 4).ends_with('…'));
}
#[test]
fn the_marker_is_one_ascii_column_wide_in_both_states() {
assert_eq!(
mark(true),
">",
"not `▸`: East-Asian Ambiguous width would shift the row"
);
assert_eq!(mark(false), " ");
}
#[test]
fn the_offset_keeps_the_selection_visible_and_centred_where_it_can() {
assert_eq!(scroll_offset(0, 10, 6), 0);
assert_eq!(scroll_offset(5, 10, 6), 0);
assert_eq!(scroll_offset(0, 5, 20), 0);
assert_eq!(scroll_offset(2, 5, 20), 0);
assert_eq!(scroll_offset(3, 5, 20), 1);
assert_eq!(scroll_offset(10, 5, 20), 8);
assert_eq!(scroll_offset(19, 5, 20), 15, "the last page, not past it");
assert_eq!(scroll_offset(usize::MAX, 5, 20), 15);
assert_eq!(scroll_offset(3, 0, 20), 0);
for total in [1usize, 2, 7, 40, 200] {
for viewport in [1usize, 3, 8, 25] {
for selected in 0..total {
let offset = scroll_offset(selected, viewport, total);
assert!(
selected >= offset && selected < offset + viewport,
"selected {selected} fell outside [{offset}, {}) for total {total}",
offset + viewport
);
}
}
}
}
}