use std::collections::BTreeSet;
use std::fmt;
use unicode_width::UnicodeWidthStr;
use crate::color::{Console, Tone};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[non_exhaustive]
pub enum SelectMode {
#[default]
Auto,
Always,
Never,
}
impl SelectMode {
pub const fn is_interactive(self, is_terminal: bool) -> bool {
match self {
Self::Auto => is_terminal,
Self::Always => true,
Self::Never => false,
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[non_exhaustive]
pub enum Layout {
#[default]
Flat,
Tabs,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Item {
pub id: String,
pub label: String,
pub description: Option<String>,
}
impl Item {
pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
Self {
id: id.into(),
label: label.into(),
description: None,
}
}
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Group {
pub label: String,
pub items: Vec<Item>,
pub tab: Option<String>,
pub divider: bool,
}
impl Group {
pub fn new(label: impl Into<String>) -> Self {
Self {
label: label.into(),
items: Vec::new(),
tab: None,
divider: false,
}
}
pub fn add_item(mut self, item: Item) -> Self {
self.items.push(item);
self
}
pub fn in_tab(mut self, tab: impl Into<String>) -> Self {
self.tab = Some(tab.into());
self
}
pub fn with_divider(mut self) -> Self {
self.divider = true;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Tab<'a> {
label: &'a str,
groups: std::ops::Range<usize>,
divider: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Hint {
pub key: char,
pub label: String,
}
impl Hint {
pub fn new(key: char, label: impl Into<String>) -> Self {
Self {
key,
label: label.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Outcome {
Selected(String),
Hotkey(char),
Cancelled,
Unavailable,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Picked {
Chosen(Vec<String>),
Cancelled,
Unavailable,
}
const CHECKBOX_WIDTH: usize = 4;
const SEARCH_WORTH_MENTIONING: usize = 5;
const MARKER_WIDTH: usize = 2;
const GAP_WIDTH: usize = 2;
const MIN_DESCRIPTION: usize = 12;
const TAB_GAP: usize = 3;
const FOOTER_GAP: usize = 3;
const TAB_ELLIPSIS: usize = 1 + TAB_GAP;
const TAB_DIVIDER: usize = 1 + TAB_GAP;
const TAB_KEYS: usize = 9;
fn shorten(text: &str, max: usize) -> std::borrow::Cow<'_, str> {
if text.width() <= max {
return std::borrow::Cow::Borrowed(text);
}
if max <= 1 {
return std::borrow::Cow::Borrowed("");
}
let mut out = String::new();
let mut used = 0;
for character in text.chars() {
let next = character.to_string().width();
if used + next > max - 1 {
break;
}
out.push(character);
used += next;
}
out.push('…');
std::borrow::Cow::Owned(out)
}
fn tab_window(
labels: &[(String, bool)],
active: usize,
columns: Option<usize>,
) -> std::ops::Range<usize> {
let all = 0..labels.len();
let Some(columns) = columns else {
return all;
};
let room = columns.saturating_sub(MARKER_WIDTH);
let width = |range: &std::ops::Range<usize>| {
let mut width = 0;
for (drawn, index) in range.clone().enumerate() {
let (label, divider) = &labels[index];
if drawn > 0 {
width += TAB_GAP;
if *divider {
width += TAB_DIVIDER;
}
}
width += label.width();
}
if range.start > 0 {
width += TAB_ELLIPSIS;
}
if range.end < labels.len() {
width += TAB_ELLIPSIS;
}
width
};
if width(&all) <= room {
return all;
}
let mut window = active..active + 1;
loop {
let mut grew = false;
if window.end < labels.len() {
let wider = window.start..window.end + 1;
if width(&wider) <= room {
window = wider;
grew = true;
}
}
if window.start > 0 {
let wider = window.start - 1..window.end;
if width(&wider) <= room {
window = wider;
grew = true;
}
}
if !grew {
return window;
}
}
}
fn footer_lines(keys: &[(String, &str)], columns: Option<usize>) -> Vec<std::ops::Range<usize>> {
let all = 0..keys.len();
let Some(columns) = columns else {
return vec![all];
};
let width = |(key, label): &(String, &str)| key.width() + 1 + label.width();
let mut lines = Vec::new();
let mut start = 0;
let mut used = 0;
for (at, key) in keys.iter().enumerate() {
let next = if at == start {
width(key)
} else {
used + FOOTER_GAP + width(key)
};
if at > start && next > columns {
lines.push(start..at);
start = at;
used = width(key);
} else {
used = next;
}
}
if start < keys.len() {
lines.push(start..keys.len());
}
lines
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
struct Score(u32);
impl Score {
const LABEL_SUBSTRING: u32 = 0;
const LABEL_SUBSEQUENCE: u32 = 1_000;
const DESCRIPTION: u32 = 10_000;
}
fn score(item: &Item, query: &str) -> Option<Score> {
let label = item.label.to_lowercase();
if let Some(at) = label.find(query) {
return Some(Score(
Score::LABEL_SUBSTRING + u32::try_from(at).unwrap_or(u32::MAX),
));
}
if let Some(span) = subsequence_span(&label, query) {
return Some(Score(
Score::LABEL_SUBSEQUENCE + u32::try_from(span).unwrap_or(u32::MAX),
));
}
let description = item.description.as_ref()?.to_lowercase();
let at = description.find(query)?;
Some(Score(
Score::DESCRIPTION + u32::try_from(at).unwrap_or(u32::MAX),
))
}
fn subsequence_span(text: &str, query: &str) -> Option<usize> {
let mut chars = text.char_indices();
let mut first = None;
let mut last = 0;
for wanted in query.chars() {
let (at, _) = chars.find(|(_, character)| *character == wanted)?;
first.get_or_insert(at);
last = at;
}
Some(last - first.unwrap_or(last) + 1)
}
#[derive(Debug, Clone, Copy, Default)]
struct View<'a> {
query: Option<&'a str>,
checked: Option<&'a BTreeSet<String>>,
tab: Option<usize>,
}
enum Row<'a> {
Group(&'a str),
Item(&'a Item, usize),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Viewport {
start: usize,
height: usize,
width: Option<usize>,
}
impl Viewport {
pub(crate) const fn new(start: usize, height: usize) -> Self {
Self {
start,
height,
width: None,
}
}
pub(crate) const fn with_width(mut self, width: Option<usize>) -> Self {
self.width = width;
self
}
#[cfg(any(all(feature = "select", unix), test))]
pub(crate) fn shows(self, rows: usize, row: usize) -> bool {
self.window(rows).contains(&row)
}
fn window(self, rows: usize) -> std::ops::Range<usize> {
if rows <= self.height {
return 0..rows;
}
let start = self.start.min(rows.saturating_sub(1));
let above = usize::from(start > 0);
let visible = self.height.saturating_sub(above + 1).max(1);
let end = (start + visible).min(rows);
if end == rows {
let visible = self.height.saturating_sub(above).max(1);
let start = rows.saturating_sub(visible).max(start);
return start..rows;
}
start..end
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Menu {
heading: Option<String>,
note: Option<String>,
summary: Option<String>,
groups: Vec<Group>,
hints: Vec<Hint>,
layout: Layout,
ticked: BTreeSet<String>,
}
impl Menu {
pub fn new() -> Self {
Self::default()
}
pub fn with_heading(mut self, heading: impl Into<String>) -> Self {
self.heading = Some(heading.into());
self
}
pub fn with_note(mut self, note: impl Into<String>) -> Self {
self.note = Some(note.into());
self
}
pub fn with_summary(mut self, summary: impl Into<String>) -> Self {
self.summary = Some(summary.into());
self
}
pub fn with_layout(mut self, layout: Layout) -> Self {
self.layout = layout;
self
}
pub fn add_group(mut self, group: Group) -> Self {
self.groups.push(group);
self
}
pub fn with_ticked<I, S>(mut self, ids: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.ticked.extend(ids.into_iter().map(Into::into));
self
}
pub fn add_hint(mut self, hint: Hint) -> Self {
self.hints.push(hint);
self
}
pub fn items(&self) -> impl Iterator<Item = &Item> {
self.groups.iter().flat_map(|group| group.items.iter())
}
pub fn len(&self) -> usize {
self.items().count()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
fn active_tab(&self, view: View<'_>) -> Option<usize> {
if self.layout != Layout::Tabs || view.query.is_some() {
return None;
}
let tabs = self.tabs().len();
if tabs < 2 {
return None;
}
Some(view.tab?.min(tabs - 1))
}
fn tabs(&self) -> Vec<Tab<'_>> {
let mut tabs: Vec<Tab<'_>> = Vec::with_capacity(self.groups.len());
for (index, group) in self.groups.iter().enumerate() {
let label = group.tab.as_deref().unwrap_or(&group.label);
match tabs.last_mut() {
Some(last) if group.tab.is_some() && last.label == label => {
last.groups.end = index + 1;
}
_ => tabs.push(Tab {
label,
groups: index..index + 1,
divider: group.divider,
}),
}
}
tabs
}
#[cfg(any(all(feature = "select", unix), test))]
fn tab_count(&self) -> usize {
self.tabs().len()
}
fn tab_labels(&self) -> Vec<(String, bool)> {
self.tabs()
.into_iter()
.enumerate()
.map(|(index, tab)| {
let label = if index < TAB_KEYS {
format!("{} {}", index + 1, tab.label)
} else {
tab.label.to_owned()
};
(label, tab.divider)
})
.collect()
}
fn write_tabs(
&self,
writer: &mut (impl std::io::Write + ?Sized),
console: Console,
active: usize,
columns: Option<usize>,
) -> std::io::Result<()> {
let labels = self.tab_labels();
let window = tab_window(&labels, active, columns);
let mut column = MARKER_WIDTH;
let mut underline = None;
write!(writer, "{:MARKER_WIDTH$}", "")?;
if window.start > 0 {
console.write_paint(Tone::Muted, "…", writer)?;
write!(writer, "{:TAB_GAP$}", "")?;
column += TAB_ELLIPSIS;
}
for index in window.clone() {
let (label, divider) = &labels[index];
if index > window.start {
write!(writer, "{:TAB_GAP$}", "")?;
column += TAB_GAP;
if *divider {
console.write_paint(Tone::Muted, "│", writer)?;
write!(writer, "{:TAB_GAP$}", "")?;
column += TAB_DIVIDER;
}
}
if index == active {
console.write_paint(Tone::Title, label, writer)?;
underline = Some((column, label.width()));
} else {
console.write_paint(Tone::Muted, label, writer)?;
}
column += label.width();
}
if window.end < labels.len() {
write!(writer, "{:TAB_GAP$}", "")?;
console.write_paint(Tone::Muted, "…", writer)?;
}
writeln!(writer)?;
if let Some((at, width)) = underline {
write!(writer, "{:at$}", "")?;
console.write_paint(Tone::Success, "─".repeat(width), writer)?;
}
writeln!(writer)
}
fn label_width(&self, view: View<'_>) -> usize {
let boxes = if view.checked.is_some() {
CHECKBOX_WIDTH
} else {
0
};
self.items()
.map(|item| item.label.width() + boxes)
.max()
.unwrap_or(0)
}
pub fn render(&self, console: Console) -> String {
crate::internal::collect_to_string(|buf| {
self.write_frame(buf, console, None, None, View::default())
})
}
fn body_rows(&self, view: View<'_>) -> Vec<Row<'_>> {
let query = view.query.filter(|query| !query.is_empty());
let Some(query) = query else {
if let Some(active) = self.active_tab(view) {
let tab = self.tabs().swap_remove(active);
let mut rows = Vec::new();
let mut index = 0;
for group in &self.groups[tab.groups] {
if group.label != tab.label {
rows.push(Row::Group(&group.label));
}
for item in &group.items {
rows.push(Row::Item(item, index));
index += 1;
}
}
return rows;
}
let mut rows = Vec::with_capacity(self.groups.len() + self.len());
let mut index = 0;
for group in &self.groups {
rows.push(Row::Group(&group.label));
for item in &group.items {
rows.push(Row::Item(item, index));
index += 1;
}
}
return rows;
};
let mut ranked: Vec<(Score, &str, &Item)> =
self.groups
.iter()
.flat_map(|group| {
group.items.iter().filter_map(move |item| {
Some((score(item, query)?, group.label.as_str(), item))
})
})
.collect();
ranked.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.2.label.cmp(&b.2.label)));
let mut rows = Vec::with_capacity(ranked.len() + 1);
let mut last_group = None;
for (index, (_, group, item)) in ranked.iter().enumerate() {
if last_group != Some(*group) {
rows.push(Row::Group(group));
last_group = Some(*group);
}
rows.push(Row::Item(item, index));
}
rows
}
fn matching_items(&self, view: View<'_>) -> Vec<&Item> {
self.body_rows(view)
.into_iter()
.filter_map(|row| match row {
Row::Item(item, _) => Some(item),
Row::Group(_) => None,
})
.collect()
}
fn write_frame(
&self,
writer: &mut (impl std::io::Write + ?Sized),
console: Console,
cursor: Option<usize>,
viewport: Option<Viewport>,
view: View<'_>,
) -> std::io::Result<()> {
let columns = viewport.and_then(|viewport| viewport.width);
let query = view.query;
if let Some(heading) = &self.heading {
let note_room = self
.note
.as_ref()
.map_or(0, |note| note.width() + GAP_WIDTH);
let room = columns.map_or(usize::MAX, |columns| columns.saturating_sub(note_room));
console.write_paint(Tone::Title, shorten(heading, room), writer)?;
if let Some(note) = &self.note {
write!(writer, " ")?;
console.write_paint(Tone::Muted, note, writer)?;
}
writeln!(writer)?;
}
if let Some(summary) = &self.summary {
let room = columns.map_or(usize::MAX, |columns| columns.saturating_sub(MARKER_WIDTH));
write!(writer, "{:MARKER_WIDTH$}", "")?;
console.write_paint(Tone::Muted, shorten(summary, room), writer)?;
writeln!(writer)?;
}
if self.heading.is_some() || self.summary.is_some() {
writeln!(writer)?;
}
if let Some(active) = self.active_tab(view) {
self.write_tabs(writer, console, active, columns)?;
}
let width = self.label_width(view).min(
columns.map_or(usize::MAX, |columns| columns.saturating_sub(MARKER_WIDTH)),
);
let rows = self.body_rows(view);
let window = viewport.map_or(0..rows.len(), |viewport| viewport.window(rows.len()));
if window.start > 0 {
console.write_paint(Tone::Muted, format!(" ↑ {} more", window.start), writer)?;
writeln!(writer)?;
}
for row in &rows[window.clone()] {
match row {
Row::Group(label) => {
let room = columns.unwrap_or(usize::MAX);
console.write_paint(Tone::Info, shorten(label, room), writer)?;
}
Row::Item(item, index) => {
let selected = cursor == Some(*index);
let marker = if selected { "›" } else { " " };
let boxes = view.checked.map(|checked| checked.contains(&item.id));
let room = width.saturating_sub(boxes.map_or(0, |_| CHECKBOX_WIDTH));
let label = shorten(&item.label, room);
let padding = room.saturating_sub(label.width());
write!(writer, "{marker} ")?;
if let Some(ticked) = boxes {
if ticked {
console.write_paint(Tone::Success, "[x]", writer)?;
} else {
console.write_paint(Tone::Muted, "[ ]", writer)?;
}
write!(writer, " ")?;
}
console.write_paint(
if selected { Tone::Success } else { Tone::Info },
&label,
writer,
)?;
if let Some(description) = &item.description {
let room = columns.map_or(usize::MAX, |columns| {
columns.saturating_sub(MARKER_WIDTH + width + GAP_WIDTH)
});
if room >= MIN_DESCRIPTION {
write!(writer, "{:padding$} ", "")?;
console.write_paint(Tone::Muted, shorten(description, room), writer)?;
}
}
}
}
writeln!(writer)?;
}
if rows.is_empty() && query.is_some() {
console.write_paint(Tone::Muted, " no matches", writer)?;
writeln!(writer)?;
}
let remaining = rows.len() - window.end;
if remaining > 0 {
console.write_paint(Tone::Muted, format!(" ↓ {remaining} more"), writer)?;
writeln!(writer)?;
}
if let Some(query) = query {
writeln!(writer)?;
console.write_paint(Tone::Success, "/", writer)?;
write!(writer, " ")?;
let room = columns.map_or(usize::MAX, |columns| columns.saturating_sub(2));
if query.is_empty() {
console.write_paint(Tone::Muted, shorten("type to filter", room), writer)?;
} else {
console.write_paint(Tone::Title, shorten(query, room), writer)?;
}
writeln!(writer)?;
} else {
let keys = self.footer_keys(view, cursor);
if !keys.is_empty() {
writeln!(writer)?;
for line in footer_lines(&keys, columns) {
for (at, (key, label)) in keys[line].iter().enumerate() {
if at > 0 {
write!(writer, "{:FOOTER_GAP$}", "")?;
}
console.write_paint(Tone::Success, key, writer)?;
write!(writer, " ")?;
console.write_paint(Tone::Muted, label, writer)?;
}
writeln!(writer)?;
}
}
}
Ok(())
}
fn footer_keys(&self, view: View<'_>, cursor: Option<usize>) -> Vec<(String, &str)> {
let mut keys = Vec::with_capacity(self.hints.len() + 3);
if self.active_tab(view).is_some() {
keys.push(("\u{2190}\u{2192}".to_owned(), "group"));
}
if view.checked.is_some() {
keys.push(("space".to_owned(), "tick"));
keys.push(("enter".to_owned(), "confirm"));
if self.offers_search(cursor) {
keys.push(("/".to_owned(), "search"));
}
return keys;
}
if self.offers_search(cursor) {
keys.push(("/".to_owned(), "search"));
}
for hint in &self.hints {
keys.push((hint.key.to_string(), hint.label.as_str()));
}
keys
}
fn offers_search(&self, cursor: Option<usize>) -> bool {
cursor.is_some() && self.len() > SEARCH_WORTH_MENTIONING
}
#[cfg(any(all(feature = "select", unix), test))]
fn row_of_item(&self, index: usize, view: View<'_>) -> usize {
self.body_rows(view)
.iter()
.position(|row| matches!(row, Row::Item(_, item) if *item == index))
.unwrap_or(0)
}
#[cfg(any(all(feature = "select", unix), test))]
fn body_height(&self, view: View<'_>) -> usize {
self.body_rows(view)
.len()
.max(usize::from(view.query.is_some()))
}
#[cfg(any(all(feature = "select", unix), test))]
fn chrome_height(&self, view: View<'_>, columns: Option<usize>) -> usize {
let lines = usize::from(self.heading.is_some()) + usize::from(self.summary.is_some());
let heading = if lines > 0 { lines + 1 } else { 0 };
let tabs = usize::from(self.active_tab(view).is_some()) * 2;
let footer = if view.query.is_some() {
2
} else {
let keys = self.footer_keys(view, Some(0));
if keys.is_empty() {
0
} else {
1 + footer_lines(&keys, columns).len()
}
};
heading + tabs + footer
}
}
impl fmt::Display for Menu {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.render(Console::new(crate::ColorMode::Never, false)))
}
}
#[cfg(all(feature = "select", unix))]
mod interactive;
#[cfg(all(feature = "select", unix))]
mod terminal;
#[cfg(all(feature = "select", not(unix)))]
impl Menu {
pub fn run(
&self,
_console: Console,
_mode: SelectMode,
_is_terminal: bool,
) -> std::io::Result<Outcome> {
Ok(Outcome::Unavailable)
}
pub fn run_multi(
&self,
_console: Console,
_mode: SelectMode,
_is_terminal: bool,
) -> std::io::Result<Picked> {
Ok(Picked::Unavailable)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ColorMode;
fn plain() -> Console {
Console::new(ColorMode::Never, false)
}
fn flat() -> View<'static> {
View::default()
}
fn searching(query: &str) -> View<'_> {
View {
query: Some(query),
checked: None,
tab: None,
}
}
fn on_tab(tab: usize) -> View<'static> {
View {
query: None,
checked: None,
tab: Some(tab),
}
}
fn menu() -> Menu {
Menu::new()
.with_heading("casoon.dev")
.with_note("pnpm")
.add_group(
Group::new("Development")
.add_item(Item::new("dev", "dev").with_description("Start the site"))
.add_item(Item::new("dev:landings", "dev:landings")),
)
.add_group(Group::new("Build").add_item(Item::new("build", "build")))
.add_hint(Hint::new('U', "Updates"))
}
#[test]
fn renders_groups_headings_and_hints() {
let output = menu().render(plain());
assert!(output.starts_with("casoon.dev pnpm\n\n"));
assert!(output.contains("Development\n"));
assert!(output.contains(" dev "));
assert!(output.contains("Build\n"));
assert!(output.trim_end().ends_with("U Updates"));
}
#[test]
fn a_keyboard_frame_advertises_the_filter() {
let menu = long_menu(20).add_hint(Hint::new('U', "Updates"));
let interactive = crate::internal::collect_to_string(|buf| {
menu.write_frame(buf, plain(), Some(0), None, flat())
});
assert!(interactive.contains("/ search"));
assert!(
interactive.contains("U Updates"),
"and the menu's own hints"
);
}
#[test]
fn a_ticking_frame_draws_boxes_and_its_own_keys() {
let checked: BTreeSet<String> = ["build".to_owned()].into();
let view = View {
checked: Some(&checked),
..View::default()
};
let shown = crate::internal::collect_to_string(|buf| {
menu().write_frame(buf, plain(), Some(0), None, view)
});
assert!(shown.contains("› [ ] dev "));
assert!(shown.contains(" [x] build"));
assert!(shown.contains("space tick enter confirm"));
assert!(!shown.contains("U Updates"), "hints are not answered here");
}
#[test]
fn a_rendered_frame_offers_no_keys() {
assert!(!menu().render(plain()).contains("/ search"));
}
#[test]
fn an_empty_menu_advertises_nothing() {
let empty = Menu::new().with_heading("nothing");
let shown = crate::internal::collect_to_string(|buf| {
empty.write_frame(buf, plain(), Some(0), None, flat())
});
assert!(!shown.contains("search"));
}
#[test]
fn a_short_menu_does_not_offer_to_search_itself() {
let confirm = Menu::new().with_heading("Run deploy?").add_group(
Group::new("Confirm")
.add_item(Item::new("no", "Cancel"))
.add_item(Item::new("yes", "Run deploy")),
);
let shown = crate::internal::collect_to_string(|buf| {
confirm.write_frame(buf, plain(), Some(0), None, flat())
});
assert!(!shown.contains("search"));
}
#[test]
fn filtering_a_short_menu_still_works() {
let short = long_menu(3);
assert_eq!(searched(&short, "t2"), ["t2"]);
let shown = crate::internal::collect_to_string(|buf| {
short.write_frame(buf, plain(), Some(0), None, searching("t2"))
});
assert!(shown.contains("/ t2"));
}
#[test]
fn a_long_menu_still_advertises_it() {
let long = long_menu(20);
let shown = crate::internal::collect_to_string(|buf| {
long.write_frame(buf, plain(), Some(0), None, flat())
});
assert!(shown.contains("/ search"));
}
#[test]
fn plain_rendering_has_no_cursor_marker() {
assert!(!menu().render(plain()).contains('›'));
}
#[test]
fn descriptions_line_up_across_groups() {
let output = menu().render(plain());
let line = output
.lines()
.find(|line| line.contains("Start the site"))
.expect("description line");
assert_eq!(
line.find("Start the site"),
Some(2 + "dev:landings".width() + 2)
);
}
#[test]
fn an_item_without_a_description_ends_at_its_label() {
let output = menu().render(plain());
let line = output
.lines()
.find(|line| line.trim_start().starts_with("build"))
.expect("build line");
assert_eq!(line, " build");
}
#[test]
fn items_are_yielded_in_display_order() {
let menu = menu();
let ids: Vec<&str> = menu.items().map(|item| item.id.as_str()).collect();
assert_eq!(ids, ["dev", "dev:landings", "build"]);
}
#[test]
fn length_counts_items_not_groups() {
assert_eq!(menu().len(), 3);
assert!(!menu().is_empty());
assert!(Menu::new().is_empty());
}
fn windowed(menu: &Menu, start: usize, height: usize) -> String {
crate::internal::collect_to_string(|buf| {
menu.write_frame(
buf,
plain(),
Some(0),
Some(Viewport::new(start, height)),
flat(),
)
})
}
fn at_width(menu: &Menu, columns: usize) -> String {
crate::internal::collect_to_string(|buf| {
menu.write_frame(
buf,
plain(),
Some(0),
Some(Viewport::new(0, 999).with_width(Some(columns))),
flat(),
)
})
}
fn long_menu(items: usize) -> Menu {
let mut group = Group::new("Scripts");
for n in 0..items {
group = group.add_item(Item::new(format!("t{n}"), format!("t{n}")));
}
Menu::new().with_heading("many").add_group(group)
}
#[test]
fn a_body_that_fits_is_shown_whole() {
let menu = long_menu(3);
let output = windowed(&menu, 0, 50);
assert!(!output.contains("more"));
assert!(output.contains("t2"));
}
#[test]
fn a_body_that_does_not_fit_says_how_much_is_below() {
let menu = long_menu(40);
let output = windowed(&menu, 0, 10);
assert!(output.contains("↓ "));
assert!(!output.contains("↑ "), "nothing is above the top");
}
#[test]
fn scrolling_into_the_middle_shows_both_directions() {
let menu = long_menu(40);
let output = windowed(&menu, 15, 10);
assert!(output.contains("↑ 15 more"));
assert!(output.contains("↓ "));
}
#[test]
fn the_end_of_the_list_drops_the_trailing_indicator() {
let menu = long_menu(40);
let output = windowed(&menu, 60, 10);
assert!(output.contains("↑ "));
assert!(
!output.contains("↓ "),
"there is nothing below the last row"
);
assert!(output.contains("t39"), "the last entry is visible");
}
#[test]
fn a_window_never_draws_more_body_lines_than_it_was_given() {
let menu = long_menu(40);
let chrome = menu.chrome_height(flat(), None);
for start in [0, 1, 7, 20, 39] {
for height in [3, 5, 10, 25] {
let body = windowed(&menu, start, height).lines().count() - chrome;
assert!(
body <= height,
"start {start}, height {height}: drew {body} body lines"
);
}
}
}
#[test]
fn a_window_always_draws_something() {
let menu = long_menu(40);
let chrome = menu.chrome_height(flat(), None);
for height in [1, 2, 3] {
let body = windowed(&menu, 0, height).lines().count() - chrome;
assert!(body >= 1, "height {height} drew nothing");
}
}
#[test]
fn row_lookup_accounts_for_group_labels() {
let menu = menu();
assert_eq!(menu.row_of_item(0, flat()), 1);
assert_eq!(menu.row_of_item(2, flat()), 4);
assert_eq!(menu.body_height(flat()), 5);
}
#[test]
fn chrome_height_counts_heading_and_hints() {
assert_eq!(menu().chrome_height(flat(), None), 4);
assert_eq!(Menu::new().chrome_height(flat(), None), 0);
assert_eq!(
Menu::new().with_heading("h").chrome_height(flat(), None),
2,
"heading plus its blank line"
);
assert_eq!(
Menu::new().chrome_height(searching(""), None),
2,
"the query line needs room even without hints"
);
}
#[test]
fn scrolling_keeps_every_cursor_position_in_view() {
let menu = long_menu(40);
let rows = menu.body_height(flat());
for height in [4, 6, 11, 21, 30] {
let mut start = 0usize;
for index in 0..menu.len() {
let cursor = menu.row_of_item(index, flat());
if cursor < start {
start = cursor;
}
while start < rows - 1 && !Viewport::new(start, height).shows(rows, cursor) {
start += 1;
}
assert!(
Viewport::new(start, height).shows(rows, cursor),
"height {height}, item {index} (row {cursor}) not visible from {start}"
);
}
}
}
#[test]
fn nothing_exceeds_the_given_width() {
let menu = Menu::new()
.with_heading("a-rather-long-project-name")
.with_note("pnpm")
.add_group(Group::new("Quality").add_item(
Item::new("type-check", "type-check").with_description(
"Führt den TypeScript-Check in allen Packages des Workspace aus",
),
));
for columns in [20, 40, 60, 80, 100] {
for line in at_width(&menu, columns).lines() {
assert!(
line.width() <= columns,
"width {columns}: line of {} columns: {line:?}",
line.width()
);
}
}
}
#[test]
fn a_shortened_entry_is_marked_as_cut() {
let menu = Menu::new().add_group(Group::new("G").add_item(
Item::new("x", "x").with_description("eine sehr lange Beschreibung, die nicht passt"),
));
assert!(at_width(&menu, 30).contains('…'));
}
#[test]
fn a_description_with_no_room_is_dropped_rather_than_stubbed() {
let menu = Menu::new().add_group(Group::new("G").add_item(
Item::new("a-long-script-name", "a-long-script-name").with_description("beschreibung"),
));
let narrow = at_width(&menu, 24);
assert!(!narrow.contains("besch"), "no room left, so no description");
assert!(
narrow.contains("a-long-script-name"),
"the name still shows"
);
}
#[test]
fn shortening_counts_display_columns_not_bytes() {
assert_eq!(shorten("äöüß", 10), "äöüß");
assert_eq!(shorten("äöüß", 3).width(), 3);
assert!(shorten("äöüß", 3).ends_with('…'));
assert_eq!(shorten("abc", 1), "");
}
fn searched(menu: &Menu, query: &str) -> Vec<String> {
menu.matching_items(searching(query))
.into_iter()
.map(|item| item.label.clone())
.collect()
}
fn script_menu() -> Menu {
Menu::new()
.add_group(
Group::new("Development")
.add_item(Item::new("dev", "dev").with_description("Start the site"))
.add_item(Item::new("dev:landings", "dev:landings")),
)
.add_group(
Group::new("Deploy")
.add_item(Item::new("deploy", "deploy").with_description("Ship everything"))
.add_item(Item::new("deploy:landings", "deploy:landings")),
)
.add_group(
Group::new("Quality")
.add_item(Item::new("check", "check").with_description("Lint and format")),
)
}
#[test]
fn an_empty_query_keeps_the_menu_as_it_was() {
let menu = script_menu();
let unsearched: Vec<String> = menu.items().map(|item| item.label.clone()).collect();
assert_eq!(searched(&menu, ""), unsearched);
}
#[test]
fn a_substring_in_the_name_wins_over_one_in_a_description() {
let hits = searched(&script_menu(), "landings");
assert_eq!(hits, ["dev:landings", "deploy:landings"]);
}
#[test]
fn scattered_letters_still_find_a_name() {
assert!(searched(&script_menu(), "dpl").contains(&"deploy".to_owned()));
}
#[test]
fn a_tight_match_ranks_before_a_scattered_one() {
let hits = searched(&script_menu(), "dep");
assert_eq!(hits.first().map(String::as_str), Some("deploy"));
}
#[test]
fn a_description_match_is_found_when_no_name_matches() {
let hits = searched(&script_menu(), "lint");
assert_eq!(hits, ["check"]);
}
#[test]
fn a_query_that_matches_nothing_yields_nothing() {
assert!(searched(&script_menu(), "qqqq").is_empty());
}
#[test]
fn a_query_that_matches_nothing_says_so() {
let menu = script_menu();
let shown = crate::internal::collect_to_string(|buf| {
menu.write_frame(buf, plain(), Some(0), None, searching("qqqq"))
});
assert!(shown.contains("no matches"));
assert_eq!(
menu.body_height(searching("qqqq")),
1,
"the notice needs a line"
);
}
#[test]
fn searching_is_case_insensitive() {
assert_eq!(
searched(&script_menu(), "dev"),
searched(&script_menu(), "dev")
);
assert!(
!searched(&script_menu(), "start").is_empty(),
"matches a capitalised description"
);
}
#[test]
fn a_group_with_no_matches_is_not_drawn() {
let menu = script_menu();
let rows = menu.body_rows(searching("check"));
let groups: Vec<&str> = rows
.iter()
.filter_map(|row| match row {
Row::Group(label) => Some(*label),
Row::Item(..) => None,
})
.collect();
assert_eq!(groups, ["Quality"]);
}
#[test]
fn filtered_item_indices_are_positions_in_the_result() {
let menu = script_menu();
let rows = menu.body_rows(searching("landings"));
let indices: Vec<usize> = rows
.iter()
.filter_map(|row| match row {
Row::Item(_, index) => Some(*index),
Row::Group(_) => None,
})
.collect();
assert_eq!(indices, [0, 1], "the cursor counts matches, not all items");
}
fn tabbed(groups: usize) -> Menu {
let mut menu = Menu::new()
.with_heading("web-casoon")
.with_layout(Layout::Tabs);
for n in 0..groups {
menu = menu.add_group(
Group::new(format!("Group{n}"))
.add_item(Item::new(format!("a{n}"), format!("a{n}")))
.add_item(Item::new(format!("b{n}"), format!("b{n}"))),
);
}
menu
}
fn framed(menu: &Menu, view: View<'_>) -> String {
crate::internal::collect_to_string(|buf| {
menu.write_frame(buf, plain(), Some(0), None, view)
})
}
#[test]
fn a_tab_shows_only_its_own_group() {
let menu = tabbed(3);
let shown = framed(&menu, on_tab(1));
assert!(shown.contains("a1") && shown.contains("b1"));
assert!(!shown.contains("a0"), "the other tabs' entries stay hidden");
assert!(!shown.contains("a2"));
}
#[test]
fn a_tab_repeats_no_group_heading() {
let menu = tabbed(3);
let rows = menu.body_rows(on_tab(0));
assert!(rows.iter().all(|row| matches!(row, Row::Item(..))));
}
#[test]
fn a_tab_counts_its_own_entries_from_zero() {
let indices: Vec<usize> = tabbed(3)
.body_rows(on_tab(2))
.iter()
.filter_map(|row| match row {
Row::Item(_, index) => Some(*index),
Row::Group(_) => None,
})
.collect();
assert_eq!(indices, [0, 1], "the cursor counts what is on screen");
}
#[test]
fn a_pipe_is_shown_every_group_despite_the_tab_layout() {
let shown = tabbed(3).render(plain());
for n in 0..3 {
assert!(shown.contains(&format!("a{n}")), "group {n} is listed");
}
assert!(!shown.contains("───"), "and no tab row is drawn");
}
#[test]
fn the_active_tab_is_marked_without_colour() {
let lines: Vec<String> = framed(&tabbed(3), on_tab(1))
.lines()
.map(str::to_owned)
.collect();
let row = lines.iter().position(|l| l.contains("2 Group1")).unwrap();
let rule = &lines[row + 1];
let at = lines[row].find("2 Group1").unwrap();
assert_eq!(
rule.find('─'),
Some(at),
"the rule starts under the active tab"
);
assert_eq!(rule.trim().chars().count(), "2 Group1".width());
}
#[test]
fn tabs_are_numbered_up_to_nine() {
let shown = framed(&tabbed(11), on_tab(0));
let row = shown.lines().find(|l| l.contains("1 Group0")).unwrap();
assert!(row.contains("9 Group8"));
assert!(
!row.contains("10 Group9"),
"past nine there is no digit left to offer"
);
}
#[test]
fn one_group_gets_no_tab_row() {
let menu = Menu::new()
.with_heading("one")
.with_layout(Layout::Tabs)
.add_group(Group::new("Scripts").add_item(Item::new("dev", "dev")));
assert!(menu.active_tab(on_tab(0)).is_none());
assert!(framed(&menu, on_tab(0)).contains("Scripts"), "as a heading");
}
#[test]
fn a_tab_index_past_the_end_is_clamped() {
assert_eq!(tabbed(3).active_tab(on_tab(9)), Some(2));
}
#[test]
fn searching_leaves_the_tabs_and_spans_all_of_them() {
let menu = tabbed(3);
assert!(menu.active_tab(searching("a")).is_none());
let shown = framed(&menu, searching("a"));
for n in 0..3 {
assert!(
shown.contains(&format!("a{n}")),
"group {n} is searched too"
);
}
}
fn mixed(packages: usize) -> Menu {
let mut menu = tabbed(3).with_layout(Layout::Tabs);
for n in 0..packages {
let mut group = Group::new(format!("@scope/pkg{n}"))
.in_tab("Packages")
.add_item(Item::new(format!("p{n}"), format!("p{n}")));
if n == 0 {
group = group.with_divider();
}
menu = menu.add_group(group);
}
menu
}
#[test]
fn groups_sharing_a_tab_collapse_into_one() {
let menu = mixed(52);
assert_eq!(menu.groups.len(), 55);
assert_eq!(menu.tabs().len(), 4, "three actions and one Packages");
assert_eq!(menu.tabs()[3].label, "Packages");
}
#[test]
fn a_shared_tab_keeps_each_group_heading() {
let menu = mixed(3);
let rows = menu.body_rows(on_tab(3));
let headings: Vec<&str> = rows
.iter()
.filter_map(|row| match row {
Row::Group(label) => Some(*label),
Row::Item(..) => None,
})
.collect();
assert_eq!(headings, ["@scope/pkg0", "@scope/pkg1", "@scope/pkg2"]);
}
#[test]
fn a_shared_tab_counts_its_entries_across_the_groups() {
let indices: Vec<usize> = mixed(3)
.body_rows(on_tab(3))
.iter()
.filter_map(|row| match row {
Row::Item(_, index) => Some(*index),
Row::Group(_) => None,
})
.collect();
assert_eq!(indices, [0, 1, 2], "the cursor counts the whole tab");
}
#[test]
fn a_group_that_names_its_own_tab_still_carries_no_heading() {
let menu = mixed(3);
let rows = menu.body_rows(on_tab(0));
assert!(rows.iter().all(|row| matches!(row, Row::Item(..))));
}
#[test]
fn the_divider_marks_where_the_kind_changes() {
let shown = framed(&mixed(3), on_tab(0));
let row = shown.lines().find(|l| l.contains("1 Group0")).unwrap();
assert!(row.contains("│"), "{row:?}");
let (before, after) = row.split_once('│').unwrap();
assert!(before.contains("3 Group2"), "actions on one side");
assert!(after.contains("4 Packages"), "packages on the other");
}
#[test]
fn a_divider_never_leads_the_row() {
let shown = crate::internal::collect_to_string(|buf| {
mixed(3).write_frame(
buf,
plain(),
Some(0),
Some(Viewport::new(0, 999).with_width(Some(24))),
on_tab(3),
)
});
let row = shown.lines().find(|l| l.contains("Packages")).unwrap();
assert!(!row.trim_start().starts_with('│'), "{row:?}");
}
#[test]
fn a_divider_is_counted_in_the_row_width() {
let menu = mixed(3);
for columns in [20, 28, 36, 50, 80] {
let shown = crate::internal::collect_to_string(|buf| {
menu.write_frame(
buf,
plain(),
Some(0),
Some(Viewport::new(0, 999).with_width(Some(columns))),
on_tab(0),
)
});
for line in shown.lines() {
assert!(
line.width() <= columns,
"width {columns}: line of {} columns: {line:?}",
line.width()
);
}
}
}
#[test]
fn a_menu_whose_groups_all_share_one_tab_gets_no_tab_row() {
let menu = Menu::new()
.with_layout(Layout::Tabs)
.add_group(Group::new("a").in_tab("All").add_item(Item::new("1", "1")))
.add_group(Group::new("b").in_tab("All").add_item(Item::new("2", "2")));
assert!(menu.active_tab(on_tab(0)).is_none());
}
#[test]
fn a_narrow_tab_row_keeps_the_active_tab_visible() {
let labels: Vec<(String, bool)> = (0..9)
.map(|n| (format!("{} Group{n}", n + 1), false))
.collect();
for active in 0..labels.len() {
for columns in [20, 30, 45, 80] {
let window = tab_window(&labels, active, Some(columns));
assert!(
window.contains(&active),
"width {columns}, tab {active}: {window:?}"
);
}
}
}
#[test]
fn a_tab_row_never_exceeds_the_given_width() {
let menu = tabbed(9);
for columns in [20, 30, 45, 80, 120] {
let shown = crate::internal::collect_to_string(|buf| {
menu.write_frame(
buf,
plain(),
Some(0),
Some(Viewport::new(0, 999).with_width(Some(columns))),
on_tab(4),
)
});
for line in shown.lines() {
assert!(
line.width() <= columns,
"width {columns}: line of {} columns: {line:?}",
line.width()
);
}
}
}
#[test]
fn a_summary_gets_its_own_line_under_the_heading() {
let menu = Menu::new()
.with_heading("web-casoon")
.with_note("pnpm")
.with_summary("27 scripts · 7 groups")
.add_group(Group::new("G").add_item(Item::new("dev", "dev")));
let rendered = menu.render(plain());
let lines: Vec<&str> = rendered.lines().collect();
assert_eq!(lines[0], "web-casoon pnpm");
assert_eq!(lines[1].trim(), "27 scripts · 7 groups");
assert_eq!(lines[2], "", "the blank line stays under the block");
}
#[test]
fn chrome_height_counts_the_summary_and_the_tab_row() {
let menu = tabbed(3).with_summary("6 scripts · 3 groups");
assert_eq!(menu.chrome_height(on_tab(0), None), 7);
assert_eq!(
menu.chrome_height(searching("a"), None),
5,
"the tab row goes away while searching"
);
}
#[test]
fn a_tabbed_window_never_draws_more_body_lines_than_it_was_given() {
let menu = tabbed(9).with_summary("18 scripts · 9 groups");
let chrome = menu.chrome_height(on_tab(4), None);
for height in [3, 5, 10, 25] {
let shown = crate::internal::collect_to_string(|buf| {
menu.write_frame(
buf,
plain(),
Some(0),
Some(Viewport::new(0, height)),
on_tab(4),
)
});
let body = shown.lines().count() - chrome;
assert!(body <= height, "height {height}: drew {body} body lines");
}
}
#[test]
fn a_footer_too_wide_breaks_rather_than_wrapping() {
let menu = tabbed(3)
.add_hint(Hint::new('H', "Health"))
.add_hint(Hint::new('C', "Clean"))
.add_hint(Hint::new('S', "Security"))
.add_hint(Hint::new('U', "Updates"));
for columns in [30, 40, 60, 80, 120] {
let shown = crate::internal::collect_to_string(|buf| {
menu.write_frame(
buf,
plain(),
Some(0),
Some(Viewport::new(0, 999).with_width(Some(columns))),
on_tab(0),
)
});
for line in shown.lines() {
assert!(
line.width() <= columns,
"width {columns}: line of {} columns: {line:?}",
line.width()
);
}
for key in ["←→ group", "H Health", "C Clean", "S Security", "U Updates"] {
assert!(shown.contains(key), "width {columns} dropped {key}");
}
}
}
#[test]
fn the_frame_is_exactly_as_tall_as_it_says() {
let menu = tabbed(3)
.with_summary("6 entries · 3 groups")
.add_hint(Hint::new('H', "Health"))
.add_hint(Hint::new('C', "Clean"))
.add_hint(Hint::new('S', "Security"))
.add_hint(Hint::new('U', "Updates"));
for columns in [30, 60, 120] {
for view in [on_tab(0), on_tab(2), searching("a"), searching("")] {
let shown = crate::internal::collect_to_string(|buf| {
menu.write_frame(
buf,
plain(),
Some(0),
Some(Viewport::new(0, 999).with_width(Some(columns))),
view,
)
});
let body = menu.body_height(view).min(999);
assert_eq!(
shown.lines().count(),
menu.chrome_height(view, Some(columns)) + body,
"width {columns}, view {view:?}"
);
}
}
}
#[test]
fn a_long_query_cannot_wrap_the_frame() {
let menu = tabbed(3);
let query = "a".repeat(200);
let shown = crate::internal::collect_to_string(|buf| {
menu.write_frame(
buf,
plain(),
Some(0),
Some(Viewport::new(0, 999).with_width(Some(40))),
searching(&query),
)
});
for line in shown.lines() {
assert!(line.width() <= 40, "line of {} columns", line.width());
}
}
#[test]
fn a_tabbed_frame_advertises_the_group_keys() {
assert!(framed(&tabbed(3), on_tab(0)).contains("←→ group"));
assert!(
!framed(&tabbed(3), flat()).contains("←→ group"),
"and not where they do nothing"
);
}
#[test]
fn a_subsequence_span_measures_tightness() {
assert_eq!(subsequence_span("deploy", "dep"), Some(3));
assert_eq!(subsequence_span("deploy", "dy"), Some(6));
assert_eq!(subsequence_span("deploy", "dz"), None);
}
#[test]
fn the_query_line_is_drawn_while_searching() {
let menu = script_menu();
let shown = crate::internal::collect_to_string(|buf| {
menu.write_frame(buf, plain(), Some(0), None, searching("dep"))
});
assert!(shown.contains("/ dep"));
}
#[test]
fn an_opened_search_prompts_before_anything_is_typed() {
let menu = script_menu();
let shown = crate::internal::collect_to_string(|buf| {
menu.write_frame(buf, plain(), Some(0), None, searching(""))
});
assert!(shown.contains("type to filter"));
}
#[test]
fn rendering_is_never_windowed() {
let output = long_menu(40).render(plain());
assert!(output.contains("t0") && output.contains("t39"));
assert!(!output.contains("more"));
}
#[test]
fn auto_mode_is_interactive_only_for_terminals() {
assert!(SelectMode::Auto.is_interactive(true));
assert!(!SelectMode::Auto.is_interactive(false));
assert!(SelectMode::Always.is_interactive(false));
assert!(!SelectMode::Never.is_interactive(true));
}
#[test]
fn display_renders_without_colour() {
let shown = menu().to_string();
assert!(!shown.contains('\u{1b}'));
assert_eq!(shown, menu().render(plain()));
}
#[cfg(feature = "select")]
#[test]
fn a_non_interactive_stream_returns_without_reading() {
assert_eq!(
menu().run(plain(), SelectMode::Auto, false).expect("run"),
Outcome::Unavailable
);
assert_eq!(
menu().run(plain(), SelectMode::Never, true).expect("run"),
Outcome::Unavailable
);
}
#[cfg(feature = "select")]
#[test]
fn an_empty_menu_never_takes_over_the_terminal() {
assert_eq!(
Menu::new()
.run(plain(), SelectMode::Always, true)
.expect("run"),
Outcome::Unavailable
);
}
}