use std::cell::Cell;
use crossterm::event::{KeyCode, KeyEvent};
use ratada::input::InputField;
use ratada::text::truncate;
use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Clear, Paragraph};
use crate::domain::sections::UNGROUPED;
use crate::theme::Skin;
use crate::tui::list_layout::moved_cursor;
use crate::tui::presentation::{FieldView, field_spans};
use crate::tui::skin::Colors;
use crate::tui::widgets::centered_rect;
const FILTER_LABEL: &str = "filter: ";
const FOOTER_HINTS: &[(&str, &str)] = &[
("\u{2191}\u{2193}", "move"),
("type", "filter / new"),
("enter", "choose"),
];
const WIDTH_PCT: u16 = 60;
pub enum SectionPickResult {
Pending,
Picked(Option<String>),
Cancel,
}
pub struct SectionPicker {
items: Vec<String>,
filter: InputField,
visible: Vec<usize>,
cursor: usize,
offset: Cell<usize>,
}
impl SectionPicker {
pub fn new(sections: &[String], current: Option<&str>) -> Self {
let mut items = vec![UNGROUPED.to_string()];
items.extend(sections.iter().cloned());
let cursor = current
.and_then(|name| {
items.iter().position(|i| i.eq_ignore_ascii_case(name))
})
.unwrap_or(0);
let mut picker = SectionPicker {
items,
filter: InputField::new(""),
visible: Vec::new(),
cursor,
offset: Cell::new(0),
};
picker.apply_filter();
picker.cursor = cursor.min(picker.row_count().saturating_sub(1));
picker
}
pub fn handle_key(&mut self, key: KeyEvent) -> SectionPickResult {
match key.code {
KeyCode::Esc => return SectionPickResult::Cancel,
_ if self.navigate(key) => {}
KeyCode::Enter => return self.choose(),
_ => {
if self.filter.handle_key(key) {
self.apply_filter();
}
}
}
SectionPickResult::Pending
}
pub fn paste(&mut self, text: &str) {
self.filter.paste(text);
self.apply_filter();
}
fn choose(&self) -> SectionPickResult {
if self.cursor < self.visible.len() {
let index = self.visible[self.cursor];
let value = (index != 0).then(|| self.items[index].clone());
return SectionPickResult::Picked(value);
}
match self.create_label() {
Some(name) => SectionPickResult::Picked(Some(name)),
None => SectionPickResult::Pending,
}
}
fn create_label(&self) -> Option<String> {
let query = self.filter.value().trim();
if query.is_empty() {
return None;
}
let exists = self
.items
.iter()
.any(|item| item.eq_ignore_ascii_case(query));
(!exists).then(|| query.to_string())
}
fn row_count(&self) -> usize {
self.visible.len() + usize::from(self.create_label().is_some())
}
fn navigate(&mut self, key: KeyEvent) -> bool {
match moved_cursor(key, self.cursor, self.row_count()) {
Some(cursor) => {
self.cursor = cursor;
true
}
None => false,
}
}
fn apply_filter(&mut self) {
self.visible = ratada::finder::filter(&self.items, self.filter.value());
let rows = self.row_count();
if self.cursor >= rows {
self.cursor = rows.saturating_sub(1);
}
self.offset.set(0);
}
pub fn render(&self, frame: &mut Frame, area: Rect, skin: &Skin) {
let inner_width = ((u32::from(area.width) * u32::from(WIDTH_PCT) / 100)
as usize)
.saturating_sub(2);
let footer = ratada::shortcut_hints::lines(
FOOTER_HINTS,
skin.palette.accent_dim,
inner_width,
);
let footer_h = (footer.len() as u16).max(1);
let list_h = (self.items.len() as u16 + 1).max(1);
let height = 2 + 1 + list_h + footer_h;
let rect = centered_rect(WIDTH_PCT, height, area);
frame.render_widget(Clear, rect);
let block = ratada::chrome::modal_block(skin, "Choose section");
let inner = block.inner(rect);
frame.render_widget(block, rect);
let badge =
ratada::chrome::position_badge(self.cursor, self.row_count());
ratada::chrome::render_badge(frame, rect, skin, &badge);
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Min(1),
Constraint::Length(footer_h),
])
.split(inner);
self.render_filter(frame, rows[0], skin);
self.render_list(frame, rows[1], skin);
frame.render_widget(Paragraph::new(footer), rows[2]);
}
fn render_filter(&self, frame: &mut Frame, area: Rect, skin: &Skin) {
let colors = Colors::from_palette(&skin.palette);
let mut spans =
vec![Span::styled(FILTER_LABEL, Style::default().fg(colors.dim))];
spans.extend(field_spans(FieldView {
field: &self.filter,
palette: &skin.palette,
width: (area.width as usize)
.saturating_sub(FILTER_LABEL.chars().count()),
focused: true,
}));
frame.render_widget(Paragraph::new(Line::from(spans)), area);
}
fn render_list(&self, frame: &mut Frame, area: Rect, skin: &Skin) {
let accent = Colors::from_palette(&skin.palette).accent;
let width = area.width as usize;
let mut lines: Vec<Line<'static>> = self
.visible
.iter()
.map(|&index| Line::from(truncate(&self.items[index], width)))
.collect();
if let Some(name) = self.create_label() {
lines.push(
Line::from(truncate(&format!("+ Create \"{name}\""), width))
.style(Style::default().fg(accent)),
);
}
ratada::list::render(
frame,
area,
skin,
ratada::list::ListView {
rows: lines,
selected: self.cursor,
offset: &self.offset,
},
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::KeyEvent;
fn key(ch: char) -> KeyEvent {
KeyEvent::from(KeyCode::Char(ch))
}
fn sections() -> Vec<String> {
vec!["Backend".to_string(), "Frontend".to_string()]
}
#[test]
fn lists_ungrouped_plus_sections_when_empty() {
let picker = SectionPicker::new(§ions(), None);
assert_eq!(picker.visible.len(), 3);
assert_eq!(picker.row_count(), 3);
assert!(picker.create_label().is_none());
}
#[test]
fn picks_ungrouped_at_the_top() {
let picker = SectionPicker::new(§ions(), None);
assert!(matches!(picker.choose(), SectionPickResult::Picked(None)));
}
#[test]
fn filters_and_picks_an_existing_section() {
let mut picker = SectionPicker::new(§ions(), None);
picker.handle_key(key('f'));
assert_eq!(picker.visible.len(), 1);
assert!(matches!(
picker.choose(),
SectionPickResult::Picked(Some(name)) if name == "Frontend"
));
}
#[test]
fn a_pasted_query_narrows_the_list_like_typing() {
let mut picker = SectionPicker::new(§ions(), None);
picker.paste("Frontend");
assert_eq!(picker.visible.len(), 1);
assert!(matches!(
picker.choose(),
SectionPickResult::Picked(Some(name)) if name == "Frontend"
));
}
#[test]
fn an_exact_existing_name_offers_no_create_row() {
let mut picker = SectionPicker::new(§ions(), None);
for ch in "Backend".chars() {
picker.handle_key(key(ch));
}
assert!(picker.create_label().is_none());
}
#[test]
fn creates_a_new_section_when_nothing_matches() {
let mut picker = SectionPicker::new(§ions(), None);
for ch in "Ops".chars() {
picker.handle_key(key(ch));
}
assert_eq!(picker.create_label().as_deref(), Some("Ops"));
assert!(matches!(
picker.choose(),
SectionPickResult::Picked(Some(name)) if name == "Ops"
));
}
}