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, 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>,
}
impl Group {
pub fn new(label: impl Into<String>) -> Self {
Self {
label: label.into(),
items: Vec::new(),
}
}
pub fn add_item(mut self, item: Item) -> Self {
self.items.push(item);
self
}
}
#[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,
}
const SEARCH_WORTH_MENTIONING: usize = 5;
const MARKER_WIDTH: usize = 2;
const GAP_WIDTH: usize = 2;
const MIN_DESCRIPTION: usize = 12;
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)
}
#[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)
}
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>,
groups: Vec<Group>,
hints: Vec<Hint>,
}
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 add_group(mut self, group: Group) -> Self {
self.groups.push(group);
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 label_width(&self) -> usize {
self.items()
.map(|item| item.label.width())
.max()
.unwrap_or(0)
}
pub fn render(&self, console: Console) -> String {
crate::internal::collect_to_string(|buf| self.write_frame(buf, console, None, None, None))
}
fn body_rows(&self, query: Option<&str>) -> Vec<Row<'_>> {
let query = query.filter(|query| !query.is_empty());
let Some(query) = query else {
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, query: Option<&str>) -> Vec<&Item> {
self.body_rows(query)
.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>,
query: Option<&str>,
) -> std::io::Result<()> {
let columns = viewport.and_then(|viewport| viewport.width);
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)?;
writeln!(writer)?;
}
let width = self.label_width().min(
columns.map_or(usize::MAX, |columns| columns.saturating_sub(MARKER_WIDTH)),
);
let rows = self.body_rows(query);
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 label = shorten(&item.label, width);
let padding = width.saturating_sub(label.width());
write!(writer, "{marker} ")?;
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, " ")?;
if query.is_empty() {
console.write_paint(Tone::Muted, "type to filter", writer)?;
} else {
console.write_paint(Tone::Title, query, writer)?;
}
writeln!(writer)?;
} else if !self.hints.is_empty() || self.offers_search(cursor) {
writeln!(writer)?;
let mut written = 0;
if self.offers_search(cursor) {
console.write_paint(Tone::Success, '/', writer)?;
write!(writer, " ")?;
console.write_paint(Tone::Muted, "search", writer)?;
written += 1;
}
for hint in &self.hints {
if written > 0 {
write!(writer, " ")?;
}
console.write_paint(Tone::Success, hint.key, writer)?;
write!(writer, " ")?;
console.write_paint(Tone::Muted, &hint.label, writer)?;
written += 1;
}
writeln!(writer)?;
}
Ok(())
}
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, query: Option<&str>) -> usize {
self.body_rows(query)
.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, query: Option<&str>) -> usize {
self.body_rows(query)
.len()
.max(usize::from(query.is_some()))
}
#[cfg(any(all(feature = "select", unix), test))]
fn chrome_height(&self, searching: bool) -> usize {
let heading = if self.heading.is_some() { 2 } else { 0 };
let footer = if searching || !self.hints.is_empty() || !self.is_empty() {
2
} else {
0
};
heading + 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)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ColorMode;
fn plain() -> Console {
Console::new(ColorMode::Never, false)
}
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, None)
});
assert!(interactive.contains("/ search"));
assert!(
interactive.contains("U Updates"),
"and the menu's own hints"
);
}
#[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, None)
});
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, None)
});
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, Some("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, None)
});
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)),
None,
)
})
}
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))),
None,
)
})
}
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(false);
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(false);
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, None), 1);
assert_eq!(menu.row_of_item(2, None), 4);
assert_eq!(menu.body_height(None), 5);
}
#[test]
fn chrome_height_counts_heading_and_hints() {
assert_eq!(menu().chrome_height(false), 4);
assert_eq!(Menu::new().chrome_height(false), 0);
assert_eq!(
Menu::new().with_heading("h").chrome_height(false),
2,
"heading plus its blank line"
);
assert_eq!(
Menu::new().chrome_height(true),
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(None);
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, None);
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(Some(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, Some("qqqq"))
});
assert!(shown.contains("no matches"));
assert_eq!(menu.body_height(Some("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(Some("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(Some("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");
}
#[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, Some("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, Some(""))
});
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
);
}
}