use std::any::Any;
use crossterm::event::KeyEvent;
use ratatui::layout::Rect;
use ratatui::text::{Line, Span};
use ratatui::Frame;
use unicode_width::UnicodeWidthStr;
use super::types::{esc_rect_hit, Modal, ModalOutcome, ModalRenderCtx};
use crate::app::tips::{Tip, ALL_TIPS};
use crate::app::App;
use crate::config::Theme;
use crate::ui::modal_row::truncate_to_cells;
use crate::ui::searchable_list::{
draw_searchable_list_modal, ListEvent, ListModalOpts, RowCtx, SearchableList,
};
const PLACEHOLDER: &str = "Type to filter tips…";
const VERTICAL_PAD: u16 = 4;
const SHORT_TERMINAL_ROWS: u16 = 20;
const MIN_CONTENT_WIDTH: u16 = 24;
fn tip_title<'a>(t: &'a &'static Tip) -> &'a str {
t.title
}
pub struct TipsIndexModal {
list: SearchableList<&'static Tip>,
esc_button_rect: Option<Rect>,
}
impl TipsIndexModal {
pub fn new() -> Self {
Self {
list: SearchableList::new(ALL_TIPS.iter().collect(), tip_title),
esc_button_rect: None,
}
}
fn outcome_for(&self, event: ListEvent) -> ModalOutcome {
match event {
ListEvent::Continue | ListEvent::FocusChanged(_) => ModalOutcome::Continue,
ListEvent::Cancelled => ModalOutcome::Close,
ListEvent::Submitted(i) => {
let tip: &'static Tip = self.list.items()[i];
ModalOutcome::ContinueAnd(Box::new(move |app| app.open_tip(tip)))
}
}
}
}
impl Default for TipsIndexModal {
fn default() -> Self {
Self::new()
}
}
impl Modal for TipsIndexModal {
fn render(&mut self, frame: &mut Frame<'_>, area: Rect, ctx: &ModalRenderCtx<'_>) {
let bottom_rows = crate::ui::BottomRegion::height();
let area = Rect {
height: area.height.saturating_sub(bottom_rows),
..area
};
let vertical_pad = if area.height < SHORT_TERMINAL_ROWS {
0
} else {
VERTICAL_PAD
};
let content_width = self
.list
.items()
.iter()
.map(|t| UnicodeWidthStr::width(t.title) as u16)
.max()
.unwrap_or(0)
.saturating_add(4) .max(MIN_CONTENT_WIDTH)
.max(PLACEHOLDER.chars().count() as u16 + 2);
let theme = ctx.theme;
self.esc_button_rect = draw_searchable_list_modal(
&mut self.list,
area,
frame.buffer_mut(),
ListModalOpts {
title: "Browse Tips",
content_width,
max_list_rows: u16::MAX,
vertical_pad,
theme,
cursor_visible: ctx.cursor_visible,
placeholder: PLACEHOLDER,
empty_text: "(no matches)",
},
|row| match row {
RowCtx::Item {
item,
focused,
width,
} => format_tip_row(item, focused, theme, width),
RowCtx::Header { title, .. } => {
Line::from(Span::styled(title.to_owned(), theme.modal_item))
}
},
);
}
fn handle_key(
&mut self,
key: KeyEvent,
_app: &mut App,
_doc_height: usize,
_doc_width: usize,
) -> ModalOutcome {
let event = self.list.handle_key(&key);
self.outcome_for(event)
}
fn handle_paste(&mut self, text: &str) -> ModalOutcome {
let event = self.list.paste(text);
self.outcome_for(event)
}
fn handle_wheel(&mut self, delta: i32) {
self.list.scroll_by(delta);
}
fn handle_click(&mut self, col: u16, row: u16, _app: &mut App) -> ModalOutcome {
if esc_rect_hit(self.esc_button_rect, col, row) {
return ModalOutcome::Close;
}
let event = self.list.handle_click(col, row);
self.outcome_for(event)
}
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
}
fn format_tip_row(tip: &Tip, focused: bool, theme: &Theme, width: u16) -> Line<'static> {
let marker = if focused { "› " } else { " " };
let text_budget = (width as usize).saturating_sub(UnicodeWidthStr::width(marker));
let label = format!("{marker}{}", truncate_to_cells(tip.title, text_budget));
if focused {
let pad = (width as usize).saturating_sub(UnicodeWidthStr::width(label.as_str()));
Line::from(Span::styled(
format!("{label}{}", " ".repeat(pad)),
theme.modal_item_selected,
))
} else {
Line::from(Span::styled(label, theme.modal_item))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::test_utils::make_app;
use crossterm::event::{KeyCode, KeyModifiers};
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
#[test]
fn the_index_lists_every_tip() {
let modal = TipsIndexModal::new();
assert_eq!(modal.list.items().len(), ALL_TIPS.len());
}
#[test]
fn enter_opens_the_focused_tip_and_keeps_the_index() {
let _iso = crate::test_env::config_isolation();
let mut app = make_app();
let mut modal = TipsIndexModal::new();
match modal.handle_key(key(KeyCode::Enter), &mut app, 0, 0) {
ModalOutcome::ContinueAnd(f) => f(&mut app),
_ => panic!("Enter should stack a tip on top of the index"),
}
assert!(app.modal_stack.contains::<super::super::DailyTipModal>());
}
#[test]
fn esc_closes_the_index() {
let mut app = make_app();
let mut modal = TipsIndexModal::new();
assert!(matches!(
modal.handle_key(key(KeyCode::Esc), &mut app, 0, 0),
ModalOutcome::Close
));
}
#[test]
fn filtering_narrows_the_list() {
let mut modal = TipsIndexModal::new();
for c in "vim".chars() {
modal.list.handle_key(&key(KeyCode::Char(c)));
}
assert_eq!(modal.list.match_count(), 1, "only the Vim mode tip matches");
}
}