use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Clear, List, ListItem, ListState};
use crate::domain::sections::UNGROUPED;
use crate::theme::Skin;
use crate::tui::list_layout::moved_cursor;
use crate::tui::skin::Colors;
use crate::tui::widgets::centered_rect;
pub enum SectionsAction {
Pending,
Close,
New,
Rename(String),
Delete(String),
Move {
from: usize,
to: usize,
},
}
pub struct SectionsModal {
names: Vec<String>,
cursor: usize,
}
impl SectionsModal {
pub fn new(names: Vec<String>, cursor: usize) -> Self {
let cursor = cursor.min(names.len().saturating_sub(1));
SectionsModal { names, cursor }
}
pub fn cursor(&self) -> usize {
self.cursor
}
pub fn handle_key(&mut self, key: KeyEvent) -> SectionsAction {
let alt = key.modifiers.contains(KeyModifiers::ALT);
match key.code {
KeyCode::Esc => SectionsAction::Close,
KeyCode::Up if alt => self.move_by(-1),
KeyCode::Down if alt => self.move_by(1),
_ if !alt && self.navigate(key) => SectionsAction::Pending,
KeyCode::Char('n') => SectionsAction::New,
KeyCode::Char('r') => self.rename_action(),
KeyCode::Char('d') => self.delete_action(),
_ => SectionsAction::Pending,
}
}
fn navigate(&mut self, key: KeyEvent) -> bool {
match moved_cursor(key, self.cursor, self.names.len()) {
Some(cursor) => {
self.cursor = cursor;
true
}
None => false,
}
}
fn move_by(&mut self, delta: isize) -> SectionsAction {
let target = self.cursor as isize + delta;
if target < 0 || target as usize >= self.names.len() {
return SectionsAction::Pending;
}
SectionsAction::Move {
from: self.cursor,
to: target as usize,
}
}
fn rename_action(&self) -> SectionsAction {
match self.names.get(self.cursor) {
Some(name) => SectionsAction::Rename(name.clone()),
None => SectionsAction::Pending,
}
}
fn delete_action(&self) -> SectionsAction {
match self.names.get(self.cursor) {
Some(name) => SectionsAction::Delete(name.clone()),
None => SectionsAction::Pending,
}
}
pub fn render(&self, frame: &mut Frame, area: Rect, skin: &Skin) {
let colors = Colors::from_palette(&skin.palette);
let height = (self.names.len() as u16 + 5).clamp(6, 18);
let rect = centered_rect(50, height, area);
frame.render_widget(Clear, rect);
let block = ratada::chrome::modal_block(skin, "Manage sections");
let inner = block.inner(rect);
frame.render_widget(block, rect);
let mut items: Vec<ListItem> = self
.names
.iter()
.map(|name| ListItem::new(Line::raw(name.clone())))
.collect();
items.push(ListItem::new(Line::from(Span::styled(
format!("{UNGROUPED} (auto)"),
Style::default().fg(colors.dim),
))));
items.push(ListItem::new(Line::raw("")));
items.push(ListItem::new(Line::from(Span::styled(
"n new · r rename · d delete · Alt+\u{2191}\u{2193} move · Esc close",
Style::default().fg(colors.dim),
))));
let total = items.len();
let viewport = inner.height as usize;
let offset = ratada::nav::keep_visible(
ratada::nav::ScrollView {
total,
offset: 0,
viewport,
},
self.cursor,
);
let list = List::new(items).highlight_style(colors.selection_style());
let mut state = ListState::default().with_offset(offset);
if !self.names.is_empty() {
state.select(Some(self.cursor));
}
frame.render_stateful_widget(list, inner, &mut state);
ratada::scroll::render_scrollbar(
frame,
inner,
skin,
ratada::nav::ScrollView {
total,
offset,
viewport,
},
);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn modal() -> SectionsModal {
SectionsModal::new(
["Work", "Personal", "Archive"]
.iter()
.map(|s| (*s).to_string())
.collect(),
0,
)
}
fn press(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn press_alt(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::ALT)
}
#[test]
fn the_cursor_is_clamped_into_range_on_construction() {
let modal = SectionsModal::new(vec!["One".to_string()], 99);
assert_eq!(modal.cursor(), 0);
assert_eq!(SectionsModal::new(Vec::new(), 3).cursor(), 0);
}
#[test]
fn the_arrows_wrap_and_the_page_keys_clamp() {
let mut modal = modal();
modal.handle_key(press(KeyCode::Up));
assert_eq!(
modal.cursor(),
2,
"up from the first row wraps to the last"
);
modal.handle_key(press(KeyCode::Down));
assert_eq!(modal.cursor(), 0);
modal.handle_key(press(KeyCode::End));
assert_eq!(modal.cursor(), 2);
modal.handle_key(press(KeyCode::PageDown));
assert_eq!(modal.cursor(), 2, "a page jump clamps at the end");
modal.handle_key(press(KeyCode::PageUp));
assert_eq!(modal.cursor(), 0, "and at the start");
}
#[test]
fn alt_arrows_reorder_instead_of_moving_the_cursor() {
let mut modal = modal();
modal.handle_key(press(KeyCode::Down));
assert_eq!(modal.cursor(), 1);
let action = modal.handle_key(press_alt(KeyCode::Up));
assert!(matches!(action, SectionsAction::Move { from: 1, to: 0 }));
assert_eq!(modal.cursor(), 1, "the cursor follows the applied move");
}
#[test]
fn a_reorder_past_either_end_is_refused() {
let mut modal = modal();
assert!(matches!(
modal.handle_key(press_alt(KeyCode::Up)),
SectionsAction::Pending
));
modal.handle_key(press(KeyCode::End));
assert!(matches!(
modal.handle_key(press_alt(KeyCode::Down)),
SectionsAction::Pending
));
}
#[test]
fn the_letter_keys_request_their_actions_for_the_cursor_section() {
let mut modal = modal();
assert!(matches!(
modal.handle_key(press(KeyCode::Char('n'))),
SectionsAction::New
));
assert!(matches!(
modal.handle_key(press(KeyCode::Char('r'))),
SectionsAction::Rename(name) if name == "Work"
));
assert!(matches!(
modal.handle_key(press(KeyCode::Char('d'))),
SectionsAction::Delete(name) if name == "Work"
));
assert!(matches!(
modal.handle_key(press(KeyCode::Esc)),
SectionsAction::Close
));
}
#[test]
fn an_empty_list_has_nothing_to_rename_or_delete() {
let mut modal = SectionsModal::new(Vec::new(), 0);
assert!(matches!(
modal.handle_key(press(KeyCode::Char('r'))),
SectionsAction::Pending
));
assert!(matches!(
modal.handle_key(press(KeyCode::Char('d'))),
SectionsAction::Pending
));
}
}