use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
use ratatui::layout::{Rect, Size};
use super::scroll::{ScrollKeyOutcome, ScrollState, handle_scroll_key};
use crate::tui::authoring::AddTaskStep;
use crate::tui::navigation::scroll_with_delta;
use crate::tui::ui::text_panel_scroll_cap;
use super::multiline::edit_multiline_input;
use super::picker::{
handle_picker_key, normalize_picker_scroll, normalize_picker_selection, picker_submit_outcome,
visible_picker_indices,
};
use super::state::{
ConfirmState, HeaderMenuState, OrderMenuState, OverlayOutcome, OverlayState, OverlaySubmit,
PickerMode, PickerState, TagComboboxState, TextPanelState,
};
use super::tag_combobox::{
handle_tag_combobox_key, normalize_tag_combobox_highlight, tag_combobox_matches,
};
use crate::tui::overlay::{confirm_layout, picker_layout, tag_combobox_layout, text_panel_layout};
use crate::tui::store::TaskOrder;
pub(crate) fn handle_generic_overlay_paste(text: &str, overlay: OverlayState) -> OverlayState {
match overlay {
OverlayState::Search(mut state) => {
state.input.insert_paste(text);
state.results.clear();
state.selected = 0;
OverlayState::Search(state)
}
OverlayState::Command { mut state } => {
state.input.insert_paste(text);
state.reset_cycle();
OverlayState::Command { state }
}
OverlayState::AddTask(mut state) => {
match state.focus {
AddTaskStep::Title => state.title.insert_paste(text),
AddTaskStep::Description => state.description.insert_paste(text),
}
OverlayState::AddTask(state)
}
OverlayState::TextInput(mut state) => {
state.input.insert_paste(text);
OverlayState::TextInput(state)
}
OverlayState::MultilineInput(mut state) => {
state.insert_paste(text);
OverlayState::MultilineInput(state)
}
OverlayState::Picker(mut state) => {
state.filter.insert_paste(text);
normalize_picker_selection(&mut state);
normalize_picker_scroll(
&mut state,
crate::tui::overlay::GENERIC_PICKER_VIEWPORT_ROWS,
);
OverlayState::Picker(state)
}
OverlayState::TagCombobox(mut state) => {
state.input.insert_paste(text);
normalize_tag_combobox_highlight(&mut state);
OverlayState::TagCombobox(state)
}
other => other,
}
}
pub(crate) fn handle_generic_overlay_mouse(
overlay: OverlayState,
mouse: MouseEvent,
terminal_size: Size,
) -> OverlayOutcome {
match overlay {
OverlayState::Picker(state) => handle_picker_mouse(state, mouse, terminal_size),
OverlayState::TagCombobox(state) => handle_tag_combobox_mouse(state, mouse, terminal_size),
OverlayState::Confirm(state) => handle_confirm_mouse(state, mouse, terminal_size),
OverlayState::TextPanel(state) => handle_text_panel_mouse(state, mouse, terminal_size),
other => OverlayOutcome::None(other),
}
}
pub(crate) fn handle_generic_overlay_key(
key: KeyEvent,
overlay: OverlayState,
help_scroll_cap: u16,
) -> OverlayOutcome {
match overlay {
OverlayState::AddTask(mut state) => match key.code {
KeyCode::Esc => OverlayOutcome::Cancelled,
KeyCode::Tab => {
state.focus = match state.focus {
AddTaskStep::Title => AddTaskStep::Description,
AddTaskStep::Description => AddTaskStep::Title,
};
OverlayOutcome::None(OverlayState::AddTask(state))
}
KeyCode::Enter if state.focus == AddTaskStep::Title => {
OverlayOutcome::Submitted(OverlaySubmit::AddTask {
title: state.title.text.clone(),
description: state.description.lines.join("\n"),
})
}
KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
OverlayOutcome::Submitted(OverlaySubmit::AddTask {
title: state.title.text.clone(),
description: state.description.lines.join("\n"),
})
}
_ => {
match state.focus {
AddTaskStep::Title => state.title.handle_key(key),
AddTaskStep::Description => edit_multiline_input(&mut state.description, key),
}
OverlayOutcome::None(OverlayState::AddTask(state))
}
},
OverlayState::TextInput(mut state) => match key.code {
KeyCode::Esc => OverlayOutcome::Cancelled,
KeyCode::Enter => OverlayOutcome::Submitted(OverlaySubmit::Text {
route: state.route,
value: state.input.text.clone(),
}),
_ => {
state.input.handle_key(key);
OverlayOutcome::None(OverlayState::TextInput(state))
}
},
OverlayState::MultilineInput(mut state) => {
if key.code == KeyCode::Char('s') && key.modifiers.contains(KeyModifiers::CONTROL) {
let value = state.lines.join("\n");
return OverlayOutcome::Submitted(OverlaySubmit::Multiline {
route: state.route,
value,
});
}
match key.code {
KeyCode::Esc => OverlayOutcome::Cancelled,
_ => {
edit_multiline_input(&mut state, key);
OverlayOutcome::None(OverlayState::MultilineInput(state))
}
}
}
OverlayState::Picker(state) => handle_picker_key(state, key),
OverlayState::TagCombobox(state) => handle_tag_combobox_key(state, key),
OverlayState::HeaderMenu(state) => handle_header_menu_key(state, key),
OverlayState::OrderMenu(state) => handle_order_menu_key(state, key),
OverlayState::Confirm(state) => match key.code {
KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => OverlayOutcome::Cancelled,
KeyCode::Char('y') | KeyCode::Char('Y') => {
OverlayOutcome::Submitted(OverlaySubmit::Confirm { route: state.route })
}
_ => OverlayOutcome::None(OverlayState::Confirm(state)),
},
OverlayState::TextPanel(mut state) => {
let cap = text_panel_scroll_cap(&state.lines);
match handle_scroll_key(
key,
ScrollState {
scroll: state.scroll,
cap,
},
&[KeyCode::Esc, KeyCode::Enter],
0,
) {
ScrollKeyOutcome::Cancelled => OverlayOutcome::Cancelled,
ScrollKeyOutcome::Continue(s) => {
state.scroll = s.scroll;
OverlayOutcome::None(OverlayState::TextPanel(state))
}
ScrollKeyOutcome::Ignored => OverlayOutcome::None(OverlayState::TextPanel(state)),
}
}
OverlayState::SyncStatus(state) => match key.code {
KeyCode::Esc | KeyCode::Enter => OverlayOutcome::Cancelled,
_ => OverlayOutcome::None(OverlayState::SyncStatus(state)),
},
OverlayState::DatabaseStats { stats, scroll } => {
match handle_scroll_key(
key,
ScrollState {
scroll,
cap: help_scroll_cap,
},
&[KeyCode::Esc, KeyCode::Enter],
0,
) {
ScrollKeyOutcome::Cancelled => OverlayOutcome::Cancelled,
ScrollKeyOutcome::Continue(s) => {
OverlayOutcome::None(OverlayState::DatabaseStats {
stats,
scroll: s.scroll,
})
}
ScrollKeyOutcome::Ignored => {
OverlayOutcome::None(OverlayState::DatabaseStats { stats, scroll })
}
}
}
OverlayState::Help { scroll } => {
match handle_scroll_key(
key,
ScrollState {
scroll,
cap: help_scroll_cap,
},
&[KeyCode::Esc, KeyCode::Enter],
0,
) {
ScrollKeyOutcome::Cancelled => OverlayOutcome::Cancelled,
ScrollKeyOutcome::Continue(s) => {
OverlayOutcome::None(OverlayState::Help { scroll: s.scroll })
}
ScrollKeyOutcome::Ignored => OverlayOutcome::None(OverlayState::Help { scroll }),
}
}
OverlayState::DetailHelp { scroll } => {
match handle_scroll_key(
key,
ScrollState {
scroll,
cap: help_scroll_cap,
},
&[KeyCode::Esc, KeyCode::Enter, KeyCode::Char('?')],
0,
) {
ScrollKeyOutcome::Cancelled => OverlayOutcome::Cancelled,
ScrollKeyOutcome::Continue(s) => {
OverlayOutcome::None(OverlayState::DetailHelp { scroll: s.scroll })
}
ScrollKeyOutcome::Ignored => {
OverlayOutcome::None(OverlayState::DetailHelp { scroll })
}
}
}
OverlayState::Detail { scroll } => {
match handle_scroll_key(
key,
ScrollState {
scroll,
cap: u16::MAX,
},
&[KeyCode::Esc, KeyCode::Enter],
0,
) {
ScrollKeyOutcome::Cancelled => OverlayOutcome::Cancelled,
ScrollKeyOutcome::Continue(s) => {
OverlayOutcome::None(OverlayState::Detail { scroll: s.scroll })
}
ScrollKeyOutcome::Ignored => OverlayOutcome::None(OverlayState::Detail { scroll }),
}
}
other => OverlayOutcome::None(other),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PickerMouseTarget {
Outside,
Filter,
Row(usize),
Interior,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ConfirmMouseTarget {
Yes,
No,
Cancel,
Interior,
Outside,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TagComboboxMouseTarget {
Outside,
Input,
Row(usize),
Interior,
}
fn handle_tag_combobox_mouse(
mut state: TagComboboxState,
mouse: MouseEvent,
terminal_size: Size,
) -> OverlayOutcome {
match mouse.kind {
MouseEventKind::ScrollDown => {
move_tag_combobox_highlight(&mut state, 1);
return OverlayOutcome::None(OverlayState::TagCombobox(state));
}
MouseEventKind::ScrollUp => {
move_tag_combobox_highlight(&mut state, -1);
return OverlayOutcome::None(OverlayState::TagCombobox(state));
}
MouseEventKind::Down(MouseButton::Left) => {}
_ => return OverlayOutcome::None(OverlayState::TagCombobox(state)),
}
match tag_combobox_mouse_target(&state, mouse.column, mouse.row, terminal_size) {
TagComboboxMouseTarget::Outside => OverlayOutcome::Cancelled,
TagComboboxMouseTarget::Input => OverlayOutcome::None(OverlayState::TagCombobox(state)),
TagComboboxMouseTarget::Row(index) => {
state.highlighted = index;
let Some(label) = state.options.get(index).cloned() else {
return OverlayOutcome::None(OverlayState::TagCombobox(state));
};
if let Some(selected) = state.selected.iter().position(|item| item == &label) {
state.selected.remove(selected);
} else {
state.selected.push(label);
}
OverlayOutcome::None(OverlayState::TagCombobox(state))
}
TagComboboxMouseTarget::Interior => OverlayOutcome::None(OverlayState::TagCombobox(state)),
}
}
fn tag_combobox_mouse_target(
state: &TagComboboxState,
column: u16,
row: u16,
terminal_size: Size,
) -> TagComboboxMouseTarget {
let view = crate::tui::overlay::TagComboboxView {
route: state.route,
title: state.title.clone(),
input: state.input.text.clone(),
input_cursor: state.input.cursor,
completion: None,
options: state.options.clone(),
selected: state.selected.clone(),
highlighted: state.highlighted,
visible_indices: tag_combobox_matches(state),
visible_start: 0,
};
let layout = tag_combobox_layout(&view, terminal_size);
if !layout
.area
.contains(ratatui::layout::Position { x: column, y: row })
{
return TagComboboxMouseTarget::Outside;
}
let inner_column = column.saturating_sub(layout.inner.x);
let inner_row = row.saturating_sub(layout.inner.y);
if inner_column >= layout.inner.width {
return TagComboboxMouseTarget::Interior;
}
if inner_row == layout.input_row {
return TagComboboxMouseTarget::Input;
}
if inner_row >= layout.list_start
&& inner_row
< layout
.list_start
.saturating_add(layout.viewport_rows as u16)
{
let offset = (inner_row - layout.list_start) as usize;
let visible = tag_combobox_matches(state);
return visible
.get(layout.visible_start + offset)
.copied()
.map(TagComboboxMouseTarget::Row)
.unwrap_or(TagComboboxMouseTarget::Interior);
}
TagComboboxMouseTarget::Interior
}
pub(crate) fn wrap_index_by_value(
indices: &[usize],
current_value: usize,
delta: isize,
) -> Option<usize> {
if indices.is_empty() {
return None;
}
let current = indices
.iter()
.position(|index| *index == current_value)
.unwrap_or(0) as isize;
let next = (current + delta).rem_euclid(indices.len() as isize) as usize;
indices.get(next).copied()
}
fn move_tag_combobox_highlight(state: &mut TagComboboxState, delta: isize) {
if let Some(next) = wrap_index_by_value(&tag_combobox_matches(state), state.highlighted, delta)
{
state.highlighted = next;
}
}
fn handle_picker_mouse(
mut state: PickerState,
mouse: MouseEvent,
terminal_size: Size,
) -> OverlayOutcome {
if mouse.kind != MouseEventKind::Down(MouseButton::Left) {
return OverlayOutcome::None(OverlayState::Picker(state));
}
match picker_mouse_target(&state, mouse.column, mouse.row, terminal_size) {
PickerMouseTarget::Outside => OverlayOutcome::Cancelled,
PickerMouseTarget::Filter => {
state.mode = PickerMode::Filter;
OverlayOutcome::None(OverlayState::Picker(state))
}
PickerMouseTarget::Row(index) => {
state.selected = index;
if state.multi {
state.items[index].selected = !state.items[index].selected;
OverlayOutcome::None(OverlayState::Picker(state))
} else {
picker_submit_outcome(state)
}
}
PickerMouseTarget::Interior => OverlayOutcome::None(OverlayState::Picker(state)),
}
}
fn picker_mouse_target(
state: &PickerState,
column: u16,
row: u16,
terminal_size: Size,
) -> PickerMouseTarget {
let view = crate::tui::overlay::PickerView {
route: state.route,
title: state.title.clone(),
filter: state.filter.text.clone(),
filter_cursor: state.filter.cursor,
items: state.items.clone(),
selected: state.selected,
multi: state.multi,
mode: state.mode,
visible_indices: visible_picker_indices(state),
scroll: state.scroll,
};
let layout = picker_layout(&view, terminal_size);
if !contains(layout.area, column, row) {
return PickerMouseTarget::Outside;
}
if !contains(layout.inner, column, row) {
return PickerMouseTarget::Interior;
}
let inner_row = row.saturating_sub(layout.inner.y);
if inner_row == 0 {
return PickerMouseTarget::Filter;
}
let Some(row_offset) = inner_row.checked_sub(layout.list_start) else {
return PickerMouseTarget::Interior;
};
if row_offset >= layout.viewport_rows as u16 {
return PickerMouseTarget::Interior;
}
let visible_position = layout.visible_start.saturating_add(row_offset as usize);
match view.visible_indices.get(visible_position) {
Some(index) => PickerMouseTarget::Row(*index),
None => PickerMouseTarget::Interior,
}
}
fn handle_confirm_mouse(
state: ConfirmState,
mouse: MouseEvent,
terminal_size: Size,
) -> OverlayOutcome {
if mouse.kind != MouseEventKind::Down(MouseButton::Left) {
return OverlayOutcome::None(OverlayState::Confirm(state));
}
match confirm_mouse_target(&state.prompt, mouse.column, mouse.row, terminal_size) {
ConfirmMouseTarget::Yes => {
OverlayOutcome::Submitted(OverlaySubmit::Confirm { route: state.route })
}
ConfirmMouseTarget::No | ConfirmMouseTarget::Cancel | ConfirmMouseTarget::Outside => {
OverlayOutcome::Cancelled
}
ConfirmMouseTarget::Interior => OverlayOutcome::None(OverlayState::Confirm(state)),
}
}
fn confirm_mouse_target(
prompt: &str,
column: u16,
row: u16,
terminal_size: Size,
) -> ConfirmMouseTarget {
let layout = confirm_layout(terminal_size, prompt);
if !contains(layout.area, column, row) {
return ConfirmMouseTarget::Outside;
}
if !contains(layout.inner, column, row) {
return ConfirmMouseTarget::Interior;
}
if row.saturating_sub(layout.inner.y) != layout.hint_row {
return ConfirmMouseTarget::Interior;
}
match column.saturating_sub(layout.inner.x) {
0..=4 => ConfirmMouseTarget::Yes,
7..=10 => ConfirmMouseTarget::No,
13..=22 => ConfirmMouseTarget::Cancel,
_ => ConfirmMouseTarget::Interior,
}
}
fn handle_text_panel_mouse(
mut state: TextPanelState,
mouse: MouseEvent,
terminal_size: Size,
) -> OverlayOutcome {
let cap = text_panel_scroll_cap(&state.lines);
match mouse.kind {
MouseEventKind::ScrollDown => {
state.scroll = scroll_with_delta(state.scroll, 1, cap);
OverlayOutcome::None(OverlayState::TextPanel(state))
}
MouseEventKind::ScrollUp => {
state.scroll = scroll_with_delta(state.scroll, -1, cap);
OverlayOutcome::None(OverlayState::TextPanel(state))
}
MouseEventKind::Down(MouseButton::Left) => {
let layout = text_panel_layout(terminal_size, state.lines.len());
if contains(layout.area, mouse.column, mouse.row) {
OverlayOutcome::None(OverlayState::TextPanel(state))
} else {
OverlayOutcome::Cancelled
}
}
_ => OverlayOutcome::None(OverlayState::TextPanel(state)),
}
}
fn contains(area: Rect, column: u16, row: u16) -> bool {
column >= area.x
&& column < area.x.saturating_add(area.width)
&& row >= area.y
&& row < area.y.saturating_add(area.height)
}
fn handle_header_menu_key(mut state: HeaderMenuState, key: KeyEvent) -> OverlayOutcome {
match key.code {
KeyCode::Esc => OverlayOutcome::Cancelled,
KeyCode::Enter => match state.selected_action() {
Some(action) => OverlayOutcome::Submitted(OverlaySubmit::HeaderMenu { action }),
None => OverlayOutcome::Cancelled,
},
KeyCode::Char('j') | KeyCode::Down => {
if !state.items.is_empty() {
state.selected = (state.selected + 1) % state.items.len();
}
OverlayOutcome::None(OverlayState::HeaderMenu(state))
}
KeyCode::Char('k') | KeyCode::Up => {
if !state.items.is_empty() {
state.selected = state
.selected
.checked_sub(1)
.unwrap_or(state.items.len().saturating_sub(1));
}
OverlayOutcome::None(OverlayState::HeaderMenu(state))
}
KeyCode::Char(ch) => match state.items.iter().find(|item| item.key == ch.to_string()) {
Some(item) => OverlayOutcome::Submitted(OverlaySubmit::HeaderMenu {
action: item.action.clone(),
}),
None => OverlayOutcome::None(OverlayState::HeaderMenu(state)),
},
_ => OverlayOutcome::None(OverlayState::HeaderMenu(state)),
}
}
fn handle_order_menu_key(mut state: OrderMenuState, key: KeyEvent) -> OverlayOutcome {
match key.code {
KeyCode::Esc => OverlayOutcome::Cancelled,
KeyCode::Enter => OverlayOutcome::Submitted(OverlaySubmit::Order {
order: state.selected,
}),
KeyCode::Char('j') | KeyCode::Down => {
state.selected = next_order(state.selected);
OverlayOutcome::None(OverlayState::OrderMenu(state))
}
KeyCode::Char('k') | KeyCode::Up => {
state.selected = previous_order(state.selected);
OverlayOutcome::None(OverlayState::OrderMenu(state))
}
KeyCode::Char('c') => OverlayOutcome::Submitted(OverlaySubmit::Order {
order: TaskOrder::Created,
}),
KeyCode::Char('u') => OverlayOutcome::Submitted(OverlaySubmit::Order {
order: TaskOrder::Updated,
}),
KeyCode::Char('p') => OverlayOutcome::Submitted(OverlaySubmit::Order {
order: TaskOrder::Priority,
}),
KeyCode::Char('g') => OverlayOutcome::Submitted(OverlaySubmit::Order {
order: TaskOrder::Project,
}),
KeyCode::Char('t') => OverlayOutcome::Submitted(OverlaySubmit::Order {
order: TaskOrder::Title,
}),
_ => OverlayOutcome::None(OverlayState::OrderMenu(state)),
}
}
fn next_order(order: TaskOrder) -> TaskOrder {
match order {
TaskOrder::Created => TaskOrder::Updated,
TaskOrder::Updated => TaskOrder::Priority,
TaskOrder::Priority => TaskOrder::Project,
TaskOrder::Project => TaskOrder::Title,
TaskOrder::Title => TaskOrder::Created,
}
}
fn previous_order(order: TaskOrder) -> TaskOrder {
match order {
TaskOrder::Created => TaskOrder::Title,
TaskOrder::Updated => TaskOrder::Created,
TaskOrder::Priority => TaskOrder::Updated,
TaskOrder::Project => TaskOrder::Priority,
TaskOrder::Title => TaskOrder::Project,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tui::overlay::{
LineEdit, MultilineInputState, OverlayRoute, PickerItem, TextInputState,
};
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn ctrl(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::CONTROL)
}
fn add_task_state(focus: AddTaskStep) -> crate::tui::overlay::AddTaskState {
crate::tui::overlay::AddTaskState {
title: LineEdit::blank(),
description: MultilineInputState::blank(
OverlayRoute::AddTaskDescription,
"Add task: description",
"",
),
focus,
project: "aven".to_string(),
status: "inbox".to_string(),
priority: "none".to_string(),
}
}
fn handle(key: KeyEvent, overlay: OverlayState) -> OverlayOutcome {
handle_generic_overlay_key(key, overlay, 100)
}
#[test]
fn add_task_description_paste_preserves_newlines() {
let outcome = handle_generic_overlay_paste(
"one\ntwo",
OverlayState::AddTask(add_task_state(AddTaskStep::Description)),
);
let OverlayState::AddTask(state) = outcome else {
panic!("expected add task state");
};
assert_eq!(
state.description.lines,
vec!["one".to_string(), "two".to_string()]
);
assert_eq!(state.description.row, 1);
assert_eq!(state.description.column, 3);
}
#[test]
fn add_task_title_paste_flattens_newlines() {
let outcome = handle_generic_overlay_paste(
"one\ntwo",
OverlayState::AddTask(add_task_state(AddTaskStep::Title)),
);
let OverlayState::AddTask(state) = outcome else {
panic!("expected add task state");
};
assert_eq!(state.title.text, "one two");
assert_eq!(state.title.cursor, 7);
}
#[test]
fn multiline_ctrl_s_submits() {
let state = MultilineInputState {
route: OverlayRoute::MessageOnly,
title: "Notes".to_string(),
prompt: "Body".to_string(),
lines: vec!["line".to_string()],
row: 0,
column: 4,
};
let outcome = handle(
ctrl(KeyCode::Char('s')),
OverlayState::MultilineInput(state),
);
assert!(matches!(
outcome,
OverlayOutcome::Submitted(OverlaySubmit::Multiline { .. })
));
}
#[test]
fn text_panel_closes_on_enter_and_esc() {
let state = TextPanelState {
title: "Conflicts".to_string(),
lines: vec!["field=title".to_string()],
scroll: 0,
};
assert!(matches!(
handle(key(KeyCode::Enter), OverlayState::TextPanel(state.clone())),
OverlayOutcome::Cancelled
));
assert!(matches!(
handle(key(KeyCode::Esc), OverlayState::TextPanel(state)),
OverlayOutcome::Cancelled
));
}
#[test]
fn text_panel_scrolls_with_navigation_keys() {
let state = TextPanelState {
title: "Conflicts".to_string(),
lines: (0..20)
.map(|index| format!("line {index}"))
.collect::<Vec<_>>(),
scroll: 0,
};
let OverlayOutcome::None(OverlayState::TextPanel(state)) =
handle(key(KeyCode::Down), OverlayState::TextPanel(state))
else {
panic!("expected scrolled text panel");
};
assert_eq!(state.scroll, 1);
let OverlayOutcome::None(OverlayState::TextPanel(state)) =
handle(key(KeyCode::Up), OverlayState::TextPanel(state))
else {
panic!("expected scrolled text panel");
};
assert_eq!(state.scroll, 0);
}
#[test]
fn text_panel_navigation_scroll_is_capped() {
let mut state = TextPanelState {
title: "Conflicts".to_string(),
lines: (0..20)
.map(|index| format!("line {index}"))
.collect::<Vec<_>>(),
scroll: 0,
};
let expected = crate::tui::ui::text_panel_scroll_cap(&state.lines);
for _ in 0..30 {
let OverlayOutcome::None(OverlayState::TextPanel(next)) =
handle(key(KeyCode::Down), OverlayState::TextPanel(state))
else {
panic!("expected scrolled text panel");
};
state = next;
}
assert_eq!(state.scroll, expected);
let OverlayOutcome::None(OverlayState::TextPanel(next)) =
handle(key(KeyCode::Up), OverlayState::TextPanel(state))
else {
panic!("expected scrolled text panel");
};
assert_eq!(next.scroll, expected.saturating_sub(1));
}
#[test]
fn detail_scrolls_with_line_navigation_keys() {
let OverlayOutcome::None(OverlayState::Detail { scroll }) =
handle(key(KeyCode::Char('j')), OverlayState::Detail { scroll: 0 })
else {
panic!("expected scrolled detail");
};
assert_eq!(scroll, 1);
let OverlayOutcome::None(OverlayState::Detail { scroll }) =
handle(key(KeyCode::Char('k')), OverlayState::Detail { scroll })
else {
panic!("expected scrolled detail");
};
assert_eq!(scroll, 0);
}
#[test]
fn esc_cancels_all_generic_overlay_variants() {
let overlays = vec![
OverlayState::TextInput(TextInputState::new(
OverlayRoute::MessageOnly,
"Title",
"Prompt",
"value".to_string(),
)),
OverlayState::MultilineInput(MultilineInputState {
route: OverlayRoute::MessageOnly,
title: "Body".to_string(),
prompt: "Prompt".to_string(),
lines: vec!["value".to_string()],
row: 0,
column: 5,
}),
OverlayState::Picker(PickerState {
route: OverlayRoute::MessageOnly,
title: "Pick".to_string(),
filter: LineEdit::blank(),
items: vec![PickerItem {
label: "One".to_string(),
value: "one".to_string(),
selected: false,
}],
selected: 0,
scroll: 0,
multi: false,
mode: PickerMode::Navigate,
}),
OverlayState::Confirm(ConfirmState {
route: OverlayRoute::MessageOnly,
title: "Confirm".to_string(),
prompt: "Continue?".to_string(),
}),
OverlayState::TextPanel(TextPanelState {
title: "Panel".to_string(),
lines: vec!["line".to_string()],
scroll: 0,
}),
];
for overlay in overlays {
assert!(matches!(
handle(key(KeyCode::Esc), overlay),
OverlayOutcome::Cancelled
));
}
}
#[test]
fn help_scroll_stops_at_cap() {
let OverlayOutcome::None(OverlayState::Help { scroll }) =
handle_generic_overlay_key(key(KeyCode::Down), OverlayState::Help { scroll: 2 }, 2)
else {
panic!("expected help overlay state");
};
assert_eq!(scroll, 2);
}
#[test]
fn confirm_yes_and_no() {
let state = ConfirmState {
route: OverlayRoute::MessageOnly,
title: "Delete".to_string(),
prompt: "Sure?".to_string(),
};
assert!(matches!(
handle(
key(KeyCode::Char('y')),
OverlayState::Confirm(state.clone())
),
OverlayOutcome::Submitted(OverlaySubmit::Confirm {
route: OverlayRoute::MessageOnly,
..
})
));
assert!(matches!(
handle(key(KeyCode::Char('n')), OverlayState::Confirm(state)),
OverlayOutcome::Cancelled
));
}
#[test]
fn generic_submit_variants_propagate_route() {
let text = handle(
key(KeyCode::Enter),
OverlayState::TextInput(TextInputState::new(
OverlayRoute::AddProject,
"Add project",
"name:",
"app".to_string(),
)),
);
assert!(matches!(
text,
OverlayOutcome::Submitted(OverlaySubmit::Text {
route: OverlayRoute::AddProject,
..
})
));
let multiline = handle(
ctrl(KeyCode::Char('s')),
OverlayState::MultilineInput(MultilineInputState {
route: OverlayRoute::AddNote,
title: "Add note".to_string(),
prompt: "body:".to_string(),
lines: vec!["note".to_string()],
row: 0,
column: 4,
}),
);
assert!(matches!(
multiline,
OverlayOutcome::Submitted(OverlaySubmit::Multiline {
route: OverlayRoute::AddNote,
..
})
));
let picker = handle(
key(KeyCode::Enter),
OverlayState::Picker(PickerState {
route: OverlayRoute::EditStatus,
title: "Edit task: status".to_string(),
filter: LineEdit::blank(),
items: vec![PickerItem {
label: "Todo".to_string(),
value: "todo".to_string(),
selected: false,
}],
selected: 0,
scroll: 0,
multi: false,
mode: PickerMode::Navigate,
}),
);
assert!(matches!(
picker,
OverlayOutcome::Submitted(OverlaySubmit::Picker {
route: OverlayRoute::EditStatus,
..
})
));
}
}