use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Borders, Clear, Padding, Paragraph};
use ratatui::crossterm::event::{KeyCode, KeyEvent};
use crate::theme::Theme;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Action {
None,
Toggle(String),
Close,
}
#[derive(Debug)]
pub struct Picker {
selected: usize,
names: Vec<String>,
}
impl Picker {
pub fn new(names: Vec<String>) -> Self {
Self { selected: 0, names }
}
#[cfg(test)]
pub fn selected(&self) -> usize {
self.selected
}
pub fn handle_key(&mut self, key: KeyEvent) -> Action {
let last = self.names.len().saturating_sub(1);
match key.code {
KeyCode::Esc | KeyCode::Char('q' | 'w') | KeyCode::Enter => Action::Close,
KeyCode::Down | KeyCode::Char('j') => {
self.selected = self.selected.saturating_add(1).min(last);
Action::None
}
KeyCode::Up | KeyCode::Char('k') => {
self.selected = self.selected.saturating_sub(1);
Action::None
}
KeyCode::Home | KeyCode::Char('g') => {
self.selected = 0;
Action::None
}
KeyCode::End | KeyCode::Char('G') => {
self.selected = last;
Action::None
}
KeyCode::Char(' ') => self
.names
.get(self.selected)
.cloned()
.map_or(Action::None, Action::Toggle),
_ => Action::None,
}
}
pub fn render(
&self,
frame: &mut ratatui::Frame,
area: Rect,
theme: &Theme,
placed: impl Fn(&str) -> bool,
error: Option<&str>,
) {
let mut lines: Vec<Line> = Vec::new();
for (index, name) in self.names.iter().enumerate() {
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),
]));
}
lines.push(Line::from(""));
match error {
Some(error) => lines.push(Line::from(Span::styled(
format!(" {error}"),
Style::default().fg(theme.error),
))),
None => lines.push(Line::from(Span::styled(
" written to your config on close",
Style::default().fg(theme.muted),
))),
}
lines.push(Line::from(vec![
Span::styled(
" space",
Style::default().fg(theme.key).add_modifier(Modifier::BOLD),
),
Span::styled(" toggle ", Style::default().fg(theme.muted)),
Span::styled(
"esc",
Style::default().fg(theme.key).add_modifier(Modifier::BOLD),
),
Span::styled(" close", Style::default().fg(theme.muted)),
]));
let height = u16::try_from(lines.len())
.unwrap_or(u16::MAX)
.saturating_add(crate::frame::FRAME_HEIGHT);
let popup = crate::frame::centred(area, 40, height);
frame.render_widget(Clear, popup);
frame.render_widget(
Paragraph::new(lines).block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.border_focused))
.padding(Padding::horizontal(1))
.title_top(Line::from(Span::styled(
"PANELS",
Style::default()
.fg(theme.title)
.add_modifier(Modifier::BOLD),
))),
),
popup,
);
}
}
#[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_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();
let buffer = terminal.backend().buffer().clone();
(0..h)
.map(|y| (0..w).map(|x| buffer[(x, y)].symbol()).collect::<String>())
.collect::<Vec<_>>()
.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") && calm.contains("toggle") && calm.contains("esc"),
"{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_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())
);
}
}