use std::cell::Cell;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Clear, Paragraph};
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::frame::Binding;
use crate::keymap::{KeysConfig, Meta, PanelKeymap};
use crate::theme::Theme;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PickerAction {
Up,
Down,
First,
Last,
Toggle,
Close,
}
const NONE: KeyModifiers = KeyModifiers::NONE;
pub const ACTIONS: &[Meta<PickerAction>] = &[
Meta {
action: PickerAction::Up,
name: "up",
defaults: &[(KeyCode::Up, NONE), (KeyCode::Char('k'), NONE)],
label: "move",
primary: false,
joins: false,
about: "move to the panel above",
},
Meta {
action: PickerAction::Down,
name: "down",
defaults: &[(KeyCode::Down, NONE), (KeyCode::Char('j'), NONE)],
label: "move",
primary: false,
joins: true,
about: "move to the panel below",
},
Meta {
action: PickerAction::First,
name: "first",
defaults: &[(KeyCode::Home, NONE), (KeyCode::Char('g'), NONE)],
label: "first",
primary: false,
joins: false,
about: "move to the first panel",
},
Meta {
action: PickerAction::Last,
name: "last",
defaults: &[(KeyCode::End, NONE), (KeyCode::Char('G'), NONE)],
label: "last",
primary: false,
joins: true,
about: "move to the last panel",
},
Meta {
action: PickerAction::Toggle,
name: "toggle",
defaults: &[(KeyCode::Char(' '), NONE)],
label: "toggle",
primary: true,
joins: false,
about: "switch the panel under the cursor on or off",
},
Meta {
action: PickerAction::Close,
name: "close",
defaults: &[
(KeyCode::Enter, NONE),
(KeyCode::Char('q'), NONE),
(KeyCode::Char('w'), NONE),
],
label: "close",
primary: false,
joins: false,
about: "close, writing any change to the config",
},
];
pub fn keymap(keys: &KeysConfig) -> Result<PanelKeymap<PickerAction>, String> {
PanelKeymap::new("panel_picker", ACTIONS, keys)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Action {
None,
Toggle(String),
Close,
}
fn with_esc(keys: PanelKeymap<PickerAction>) -> PanelKeymap<PickerAction> {
let close = ACTIONS
.iter()
.find(|meta| meta.action == PickerAction::Close)
.expect("close is in the table");
keys.with_fixed(&[Binding::owned(crate::frame::ESC, close.label, true)])
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Step {
Up(usize),
Down(usize),
First,
Last,
}
impl Step {
pub(crate) fn apply(self, selected: usize, len: usize) -> usize {
let last = len.saturating_sub(1);
let selected = selected.min(last);
match self {
Self::Up(rows) => selected.saturating_sub(rows),
Self::Down(rows) => selected.saturating_add(rows).min(last),
Self::First => 0,
Self::Last => last,
}
}
}
#[derive(Debug)]
pub struct Picker {
selected: usize,
offset: Cell<usize>,
names: Vec<String>,
keys: PanelKeymap<PickerAction>,
}
impl Picker {
pub fn new(names: Vec<String>) -> Self {
Self {
selected: 0,
offset: Cell::new(0),
names,
keys: with_esc(PanelKeymap::defaults("panel_picker", ACTIONS)),
}
}
#[must_use]
pub fn with_keys(mut self, keys: PanelKeymap<PickerAction>) -> Self {
self.keys = with_esc(keys);
self
}
#[cfg(test)]
pub fn selected(&self) -> usize {
self.selected
}
pub fn handle_key(&mut self, key: KeyEvent) -> Action {
if key.code == KeyCode::Esc {
return Action::Close;
}
let Some(action) = self.keys.action(key) else {
return Action::None;
};
let step = match action {
PickerAction::Close => return Action::Close,
PickerAction::Toggle => {
return self
.names
.get(self.selected)
.cloned()
.map_or(Action::None, Action::Toggle);
}
PickerAction::Down => Step::Down(1),
PickerAction::Up => Step::Up(1),
PickerAction::First => Step::First,
PickerAction::Last => Step::Last,
};
self.selected = step.apply(self.selected, self.names.len());
Action::None
}
pub fn render(
&self,
frame: &mut ratatui::Frame,
area: Rect,
theme: &Theme,
placed: impl Fn(&str) -> bool,
error: Option<&str>,
) {
let height = u16::try_from(self.names.len().saturating_add(TRAILER))
.unwrap_or(u16::MAX)
.saturating_add(crate::frame::FRAME_HEIGHT);
let popup = crate::frame::centred(area, 40, height);
let interior = usize::from(popup.height.saturating_sub(crate::frame::FRAME_HEIGHT));
let (rows, spaced) = list_rows(interior, TRAILER);
let offset = window(self.selected, self.offset.get(), rows, self.names.len());
self.offset.set(offset);
let mut lines: Vec<Line> = Vec::with_capacity(rows + TRAILER);
for (index, name) in self.names.iter().enumerate().skip(offset).take(rows) {
let on = placed(name);
let here = index == self.selected;
let mark = if on { "■" } else { "□" };
let mark_style = Style::default().fg(if on { theme.accent } else { theme.track });
let name_style = if here {
Style::default()
.fg(theme.text)
.add_modifier(Modifier::REVERSED)
} else if on {
Style::default().fg(theme.text)
} else {
Style::default().fg(theme.muted)
};
lines.push(Line::from(vec![
Span::styled(
if here { " ▸ " } else { " " },
Style::default().fg(theme.accent),
),
Span::styled(format!("{mark} "), mark_style),
Span::styled(format!("{name:<10}"), name_style),
]));
}
if spaced {
lines.push(Line::from(""));
}
let inner = usize::from(popup.width.saturating_sub(crate::frame::FRAME_WIDTH));
let indent = |width: usize| if width + 2 <= inner { " " } else { "" };
let (status, colour) = match error {
Some(error) => (error, theme.error),
None => ("written to your config on close", theme.muted),
};
let status = crate::grid::truncate(status, inner);
if interior >= rows + 2 {
lines.push(Line::from(vec![
Span::raw(indent(crate::grid::display_width(&status))),
Span::styled(status, Style::default().fg(colour)),
]));
}
let footer = crate::frame::key_row(
self.keys.bindings(),
" ",
theme,
u16::try_from(inner).unwrap_or(u16::MAX),
);
lines.push(Line::from(
std::iter::once(Span::raw(indent(footer.width())))
.chain(footer.spans)
.collect::<Vec<_>>(),
));
frame.render_widget(Clear, popup);
frame.render_widget(
Paragraph::new(lines).block(crate::frame::dialog_block(theme, "panels", popup.width)),
popup,
);
}
}
const TRAILER: usize = 3;
pub(crate) fn list_rows(interior: usize, trailer: usize) -> (usize, bool) {
let rows = interior.saturating_sub(trailer).max(1);
(rows, interior >= rows + trailer)
}
pub(crate) fn window(selected: usize, offset: usize, rows: usize, len: usize) -> usize {
let offset = if selected < offset {
selected
} else if selected >= offset.saturating_add(rows) {
selected.saturating_add(1).saturating_sub(rows)
} else {
offset
};
offset.min(len.saturating_sub(rows))
}
#[cfg(test)]
mod tests {
use super::*;
fn press(picker: &mut Picker, code: KeyCode) -> Action {
picker.handle_key(KeyEvent::from(code))
}
fn picker() -> Picker {
Picker::new(
crate::widgets::WIDGET_NAMES
.iter()
.map(|name| (*name).to_string())
.collect(),
)
}
#[test]
fn the_default_picker_map_is_the_keys_it_always_had() {
let map = keymap(&KeysConfig::default()).expect("valid");
for (code, action) in [
(KeyCode::Up, PickerAction::Up),
(KeyCode::Char('k'), PickerAction::Up),
(KeyCode::Down, PickerAction::Down),
(KeyCode::Char('j'), PickerAction::Down),
(KeyCode::Home, PickerAction::First),
(KeyCode::Char('g'), PickerAction::First),
(KeyCode::End, PickerAction::Last),
(KeyCode::Char('G'), PickerAction::Last),
(KeyCode::Char(' '), PickerAction::Toggle),
(KeyCode::Enter, PickerAction::Close),
(KeyCode::Char('q'), PickerAction::Close),
(KeyCode::Char('w'), PickerAction::Close),
] {
assert_eq!(map.action(KeyEvent::from(code)), Some(action), "{code:?}");
}
}
#[test]
fn a_moved_picker_key_works_and_esc_still_closes() {
let keys =
keymap(&toml::from_str("toggle = \"x\"\nclose = []").expect("a table")).expect("valid");
let mut picker = picker().with_keys(keys);
assert_eq!(press(&mut picker, KeyCode::Char(' ')), Action::None);
assert!(matches!(
press(&mut picker, KeyCode::Char('x')),
Action::Toggle(_)
));
assert_eq!(
press(&mut picker, KeyCode::Char('q')),
Action::None,
"q was unbound"
);
assert_eq!(
press(&mut picker, KeyCode::Esc),
Action::Close,
"Esc always closes"
);
let theme = Theme::default();
let mut terminal =
ratatui::Terminal::new(ratatui::backend::TestBackend::new(60, 40)).unwrap();
terminal
.draw(|frame| picker.render(frame, frame.area(), &theme, |_| false, None))
.unwrap();
let screen: String = terminal
.backend()
.buffer()
.content()
.iter()
.map(ratatui::buffer::Cell::symbol)
.collect();
assert!(screen.contains("x toggle"), "the footer follows the key");
assert!(!screen.contains("space toggle"));
}
#[test]
fn the_picker_shows_a_mark_per_widget_and_says_where_the_change_goes() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let theme = Theme::default();
let draw = |picker: &Picker, error: Option<&str>, w: u16, h: u16| -> String {
let mut terminal = Terminal::new(TestBackend::new(w, h)).unwrap();
terminal
.draw(|frame| picker.render(frame, frame.area(), &theme, |n| n == "clocks", error))
.unwrap();
crate::widgets::testing::rows(terminal.backend().buffer()).join("\n")
};
let picker = Picker::new(vec!["clocks".into(), "weather".into(), "cpu".into()]);
let calm = draw(&picker, None, 80, 24);
assert!(
calm.contains("▸ ■ clocks"),
"the placed widget is marked and under the cursor:\n{calm}"
);
assert!(
calm.contains(" □ weather"),
"an unplaced one is hollow:\n{calm}"
);
assert!(calm.contains("written to your config on close"), "{calm}");
assert!(calm.contains("space toggle Esc close"), "{calm}");
let failing = draw(&picker, Some("no `[layout]` rows found"), 80, 24);
assert!(
failing.contains("no `[layout]` rows found"),
"the error takes the line:\n{failing}"
);
assert!(
!failing.contains("written to your config"),
"and the promise is withdrawn:\n{failing}"
);
let _ = draw(&picker, None, 12, 4);
let _ = draw(&picker, None, 1, 1);
}
#[test]
fn the_row_under_the_cursor_is_drawn_at_every_height() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let theme = Theme::default();
let names = crate::widgets::WIDGET_NAMES;
let draw = |picker: &Picker, height: u16| -> String {
let mut terminal = Terminal::new(TestBackend::new(60, height)).unwrap();
terminal
.draw(|frame| picker.render(frame, frame.area(), &theme, |_| false, None))
.unwrap();
crate::widgets::testing::rows(terminal.backend().buffer()).join("\n")
};
for height in 3..=30u16 {
let mut picker = picker();
let visit = |picker: &Picker| {
let screen = draw(picker, height);
let name = names[picker.selected()];
assert!(
screen.contains(&format!("▸ □ {name}")),
"{name} is under the cursor at height {height} and not drawn:\n{screen}"
);
if height >= 4 {
assert!(
screen.contains("Esc close"),
"the footer went before the status at height {height}:\n{screen}"
);
}
if height >= 5 {
assert!(
screen.contains("written to your config on close"),
"the status went before the blank at height {height}:\n{screen}"
);
}
};
press(&mut picker, KeyCode::End);
visit(&picker);
for _ in 0..names.len() {
press(&mut picker, KeyCode::Up);
visit(&picker);
}
}
let mut short = picker();
press(&mut short, KeyCode::End);
let _ = draw(&short, 12);
press(&mut short, KeyCode::Up);
let screen = draw(&short, 12);
let last = names[names.len() - 1];
assert!(
screen.contains(&format!(" □ {last}")),
"the window followed the cursor up:\n{screen}"
);
let tall = draw(&picker(), 30);
for name in names {
assert!(tall.contains(&format!("□ {name}")), "{name}:\n{tall}");
}
let grown = {
let mut picker = picker();
press(&mut picker, KeyCode::End);
let _ = draw(&picker, 12);
draw(&picker, 30)
};
assert!(
grown.contains(&format!("□ {}", names[0])),
"the window stayed scrolled on a taller terminal:\n{grown}"
);
}
#[test]
fn a_step_stays_inside_the_list() {
assert_eq!(Step::Up(1).apply(0, 5), 0);
assert_eq!(Step::Down(1).apply(4, 5), 4);
assert_eq!(Step::Down(1).apply(2, 5), 3);
assert_eq!(Step::Down(12).apply(2, 5), 4);
assert_eq!(Step::Up(12).apply(3, 5), 0);
assert_eq!(Step::First.apply(3, 5), 0);
assert_eq!(Step::Last.apply(0, 5), 4);
assert_eq!(Step::Down(1).apply(0, 0), 0);
assert_eq!(Step::Last.apply(0, 0), 0);
assert_eq!(Step::Up(1).apply(8, 5), 3);
assert_eq!(Step::Down(1).apply(8, 5), 4);
}
#[test]
fn the_cursor_clamps_at_both_ends() {
let last = crate::widgets::WIDGET_NAMES.len() - 1;
let mut picker = picker();
assert_eq!(picker.selected(), 0, "opens on the first widget");
press(&mut picker, KeyCode::Up);
assert_eq!(picker.selected(), 0, "the top does not wrap");
press(&mut picker, KeyCode::End);
assert_eq!(picker.selected(), last);
press(&mut picker, KeyCode::Down);
assert_eq!(picker.selected(), last, "the end does not wrap");
}
#[test]
fn space_names_the_widget_under_the_cursor() {
let mut picker = picker();
assert_eq!(
press(&mut picker, KeyCode::Char(' ')),
Action::Toggle(crate::widgets::WIDGET_NAMES[0].to_string())
);
press(&mut picker, KeyCode::Down);
assert_eq!(
press(&mut picker, KeyCode::Char(' ')),
Action::Toggle(crate::widgets::WIDGET_NAMES[1].to_string())
);
}
#[test]
fn only_the_documented_keys_close_it() {
for code in [
KeyCode::Esc,
KeyCode::Char('q'),
KeyCode::Char('w'),
KeyCode::Enter,
] {
assert_eq!(press(&mut picker(), code), Action::Close, "{code:?}");
}
for code in [KeyCode::Char('x'), KeyCode::Tab, KeyCode::Backspace] {
assert_eq!(press(&mut picker(), code), Action::None, "{code:?}");
}
}
#[test]
fn runtime_plugin_names_are_selectable() {
let mut picker = Picker::new(vec!["clocks".into(), "terminal".into()]);
press(&mut picker, KeyCode::End);
assert_eq!(
press(&mut picker, KeyCode::Char(' ')),
Action::Toggle("terminal".into())
);
}
}