use std::cell::{Cell, RefCell};
use std::io;
use std::path::PathBuf;
use std::time::{Duration, Instant};
use crossterm::event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind,
KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
};
use crossterm::execute;
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Position, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
use crate::assignee::{git_identity, resolve_me};
use crate::commands::{self, mv};
use crate::config::{BoardConfiguration, Config};
use crate::error::{Result, TixError};
use crate::keybindings::{EffectiveBindings, InputContext};
use crate::model::{Issue, Priority, Status, today};
use crate::planning::Planner;
use crate::search::{self, Corpus, FilterField};
use crate::storage::{IssueSelector, Store};
use crate::editor;
use crate::ui::board::{self, BoardState, HitTarget};
use crate::ui::commandbar;
use crate::ui::context_menu::{ContextMenu, MenuEvent, MenuItem, MenuItemKind};
use crate::ui::date_picker::{DatePicker, DatePickerEvent, DatePickerHit};
use crate::ui::detail::{self, DetailState};
use crate::ui::dialog::{DialogEvent, RolloverDialog};
use crate::ui::form::{self, FormState};
use crate::ui::pickers::{DropdownState, PickerEvent, PickerItem, PickerMode, SelectionModel};
use crate::ui::planning::{
PlanningForm, PlanningKind, PlanningModal, PlanningPrompt, PlanningRow, PlanningSegment,
};
use crate::ui::settings::{SettingsModal, SettingsPrompt};
use crate::ui::tabs::BoardTabs;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Action {
None,
Quit,
OpenEditor(String),
OpenAgent(String),
SwitchBoard(usize),
ConfirmDeleteYes,
ConfirmDeleteNo,
FilterModalFocus(FilterField),
SettingsRow(usize),
DatePickerHit(DatePickerHit),
HelpClose,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Mode {
Board,
BulkModal,
TriageInbox,
RolloverDialog,
Detail,
Form,
ConfirmDelete,
MoveIntoDone {
target_col: usize,
buffer: String,
},
ConfirmBlockedMove {
target_col: usize,
warning: String,
},
TransitionReason {
target_col: usize,
reason: String,
buffer: String,
},
Help {
return_to: Box<Mode>,
},
ContextMenu {
return_to: Box<Mode>,
},
Filter {
buffer: String,
},
FilterModal,
Settings,
PlanningManagement,
PlanningForm,
DatePicker {
return_to: Box<Mode>,
},
Comment {
buffer: String,
},
Asset {
buffer: String,
},
EditLintWarning {
filename: String,
message: String,
},
LinkCreate {
buffer: String,
step: LinkStep,
target: Option<u32>,
},
LinkEdit {
index: usize,
buffer: String,
step: LinkStep,
target: Option<u32>,
kind: Option<crate::relationships::RelationKind>,
},
ConfirmRemoveRelationship {
index: usize,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LinkStep {
Target,
Kind,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HitRegion {
pub rect: Rect,
pub action: Action,
}
#[derive(Debug)]
struct MouseDown {
col: usize,
card: Option<usize>,
at: Instant,
}
#[derive(Debug)]
struct Drag {
col: usize,
card: Option<usize>,
}
#[derive(Debug)]
pub struct MouseState {
last_down: Option<MouseDown>,
drag: Option<Drag>,
pub double_click_window: Duration,
}
impl Default for MouseState {
fn default() -> Self {
MouseState {
last_down: None,
drag: None,
double_click_window: Duration::from_millis(500),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FormPickerField {
Labels,
Assignee,
Cycles,
Milestones,
Reviewer,
Parent,
}
#[derive(Debug, Clone)]
struct FormPicker {
field: FormPickerField,
state: DropdownState,
}
const WHEEL_ROWS: u16 = 5;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
struct HelpMetrics {
visible: u16,
max_scroll: u16,
}
#[allow(clippy::struct_excessive_bools)]
pub struct App {
pub mode: Mode,
pub board: BoardState,
pub detail: Option<DetailState>,
pub form: Option<FormState>,
pub confirm_delete_target: Option<String>,
pub store: Store,
pub status_msg: Option<String>,
pub quit: bool,
pub bindings: EffectiveBindings,
pub active_config: usize,
pub tabs: BoardTabs,
filter: Option<String>,
corpus: Corpus,
pub show_archived: bool,
help_scroll: u16,
help_query: String,
help_searching: bool,
help_metrics: std::cell::Cell<HelpMetrics>,
board_page: std::cell::Cell<usize>,
pub filter_modal: Option<crate::ui::filter_modal::FilterModal>,
pub settings: Option<SettingsModal>,
pub planning: Option<PlanningModal>,
pub planning_form: Option<PlanningForm>,
pub date_picker: Option<DatePicker>,
structured: search::StructuredFilter,
rollover_dialog: Option<RolloverDialog>,
form_picker: Option<FormPicker>,
one_shot: bool,
frame_hit_regions: RefCell<Vec<HitRegion>>,
mouse: MouseState,
context_menu: Option<ContextMenu>,
pub bulk: Option<crate::ui::bulk::BulkModal>,
pub triage: Option<crate::ui::triage::TriageState>,
drag_preview_col: Option<usize>,
frame_size: Cell<Rect>,
}
impl App {
pub fn note_disclosure(&mut self, disclosure: String) {
self.status_msg = Some(match self.status_msg.take() {
Some(existing) if !existing.is_empty() => format!("{disclosure} — {existing}"),
_ => disclosure,
});
}
pub fn new(config: &Config) -> Result<Self> {
let store = Store::open(config)?;
let tabs = BoardTabs::new(config.configs().map(|(name, _)| name.to_string()).collect());
let board = BoardState::build(&store, config.default_config());
let corpus = Corpus::build(&store)?;
let planner = Planner::from_config(config.clone(), std::path::PathBuf::from("."));
let rollover_plan = planner.rollover_plan(&store, &today());
let rollover_dialog = if rollover_plan.is_empty() {
None
} else {
Some(RolloverDialog::new(
rollover_plan
.into_iter()
.map(|p| (p.from_cycle, p.to_cycle, p.issues.len()))
.collect(),
))
};
Ok(App {
mode: if rollover_dialog.is_some() {
Mode::RolloverDialog
} else {
Mode::Board
},
board,
show_archived: false,
help_scroll: 0,
help_query: String::new(),
help_searching: false,
help_metrics: std::cell::Cell::new(HelpMetrics::default()),
board_page: std::cell::Cell::new(0),
detail: None,
form: None,
confirm_delete_target: None,
store,
status_msg: None,
quit: false,
bindings: config.effective_keybindings()?,
active_config: 0,
tabs,
filter: None,
corpus,
filter_modal: None,
settings: None,
planning: None,
planning_form: None,
date_picker: None,
structured: search::StructuredFilter::default(),
rollover_dialog,
form_picker: None,
one_shot: false,
frame_hit_regions: RefCell::new(Vec::new()),
mouse: MouseState::default(),
context_menu: None,
bulk: None,
triage: None,
drag_preview_col: None,
frame_size: Cell::new(Rect::new(0, 0, 0, 0)),
})
}
fn active_board_config(&self) -> &BoardConfiguration {
self.store
.config
.configs()
.nth(self.active_config)
.map(|(_, board)| board)
.unwrap_or_else(|| self.store.config.default_config())
}
fn workflow_resolution(&self) -> crate::workflow::CategoryResolution {
let name = self
.store
.config
.configs()
.nth(self.active_config)
.map(|(name, _)| name)
.unwrap_or("default");
crate::workflow::resolution(&self.store.config, self.active_board_config(), name)
}
fn category_of_issue(
&self,
issue: &crate::model::Issue,
) -> Option<(String, Option<crate::workflow::ColorHint>)> {
let resolution = self.workflow_resolution();
crate::workflow::category_of(issue, &resolution)
.map(|c| (c.label().to_string(), c.color_hint()))
}
pub fn handle_key(&mut self, key: KeyEvent) -> Result<Action> {
if key.kind != KeyEventKind::Press {
return Ok(Action::None);
}
let context = self.input_context();
let key = self
.bindings
.action(context, key)
.map(|action| self.bindings.canonical_event(context, action))
.unwrap_or_else(|| {
if Self::allows_text_fallback(context) {
key
} else {
KeyEvent::new(KeyCode::F(12), KeyModifiers::NONE)
}
});
self.handle_resolved_key(context, key)
}
fn allows_text_fallback(context: InputContext) -> bool {
matches!(
context,
InputContext::Form
| InputContext::FormPicker
| InputContext::Filter
| InputContext::FilterModal
| InputContext::HelpSearch
| InputContext::PlanningForm
| InputContext::Comment
| InputContext::Asset
| InputContext::MoveIntoDone
| InputContext::LinkPrompt
| InputContext::SettingsPrompt
| InputContext::BulkModal
| InputContext::Triage
)
}
fn input_context(&self) -> InputContext {
match &self.mode {
Mode::Board => InputContext::Board,
Mode::RolloverDialog => InputContext::Rollover,
Mode::Detail => InputContext::Detail,
Mode::Form if self.form_picker.is_some() => InputContext::FormPicker,
Mode::Form => InputContext::Form,
Mode::ConfirmDelete => InputContext::ConfirmDelete,
Mode::MoveIntoDone { .. } => InputContext::MoveIntoDone,
Mode::TransitionReason { .. } => InputContext::MoveIntoDone,
Mode::ConfirmBlockedMove { .. } => InputContext::ConfirmBlockedMove,
Mode::Help { .. } if self.help_searching => InputContext::HelpSearch,
Mode::Help { .. } => InputContext::Help,
Mode::ContextMenu { .. } => InputContext::ContextMenu,
Mode::Filter { .. } => InputContext::Filter,
Mode::FilterModal => InputContext::FilterModal,
Mode::Settings
if self
.settings
.as_ref()
.is_some_and(|modal| modal.prompt.is_some()) =>
{
InputContext::SettingsPrompt
}
Mode::Settings => InputContext::Settings,
Mode::PlanningManagement
if self
.planning
.as_ref()
.is_some_and(|modal| modal.prompt.is_some()) =>
{
InputContext::PlanningConfirm
}
Mode::PlanningManagement => InputContext::PlanningManagement,
Mode::PlanningForm => InputContext::PlanningForm,
Mode::DatePicker { .. } => InputContext::DatePicker,
Mode::Comment { .. } => InputContext::Comment,
Mode::Asset { .. } => InputContext::Asset,
Mode::EditLintWarning { .. } => InputContext::EditWarning,
Mode::LinkCreate { .. } | Mode::LinkEdit { .. } => InputContext::LinkPrompt,
Mode::ConfirmRemoveRelationship { .. } => InputContext::ConfirmDelete,
Mode::BulkModal => InputContext::BulkModal,
Mode::TriageInbox => InputContext::Triage,
}
}
fn open_bulk_modal(&mut self, action: crate::ui::bulk::BatchAction, mark: bool) {
if mark {
let number = self.board.get_selected_card().map(|c| c.number);
let mut modal = self
.bulk
.take()
.unwrap_or_else(|| crate::ui::bulk::BulkModal::new(action));
if let Some(number) = number {
if let Err(problem) = modal.selection.toggle(number) {
modal.note = Some(problem.to_string());
}
}
self.bulk = Some(modal);
} else if self.bulk.is_none() {
self.bulk = Some(crate::ui::bulk::BulkModal::new(action));
}
self.mode = Mode::BulkModal;
}
fn open_triage_inbox(&mut self) -> Result<Action> {
match crate::ui::triage::TriageState::open(&self.store) {
Ok(state) => {
self.triage = Some(state);
self.mode = Mode::TriageInbox;
}
Err(e) => self.status_msg = Some(e.to_string()),
}
Ok(Action::None)
}
fn handle_triage_key(&mut self, key: KeyEvent) -> Result<Action> {
use crate::ui::triage::ModalState;
let Some(mut state) = self.triage.take() else {
self.mode = Mode::Board;
return Ok(Action::None);
};
let editing = state.modal.as_ref().is_some_and(|m| m.editing);
let armed = state.modal.as_ref().is_some_and(|m| m.is_armed());
let previewed = matches!(
state.modal.as_ref().map(|m| &m.state),
Some(ModalState::Preview(_))
);
let mut closed = false;
let mut wrote = false;
if state.modal.is_some() {
match key.code {
KeyCode::Esc if editing => {
if let Some(modal) = state.modal.as_mut() {
modal.abort_edit();
}
}
KeyCode::Esc => {
let carried = match state.modal.as_ref().map(|m| &m.state) {
Some(ModalState::Done(text)) | Some(ModalState::Refused(text)) => {
Some(text.lines().next().unwrap_or(text.as_str()).to_string())
}
_ => None,
};
state.close_modal();
if carried.is_some() {
state.note = carried;
}
}
KeyCode::Enter if editing => {
if let Some(modal) = state.modal.as_mut() {
modal.commit_edit();
}
}
KeyCode::Enter => {
if let Some(modal) = state.modal.as_mut() {
modal.begin_edit();
}
}
KeyCode::Backspace if editing => {
if let Some(modal) = state.modal.as_mut() {
modal.pop_char();
}
}
KeyCode::Char(c) if editing => {
if let Some(modal) = state.modal.as_mut() {
modal.push_char(c);
}
}
KeyCode::Tab | KeyCode::Down | KeyCode::Char('j') if !editing => {
if let Some(modal) = state.modal.as_mut() {
modal.move_cursor(1);
}
}
KeyCode::BackTab | KeyCode::Up | KeyCode::Char('k') if !editing => {
if let Some(modal) = state.modal.as_mut() {
modal.move_cursor(-1);
}
}
KeyCode::Char('t') if !editing => {
if let Some(modal) = state.modal.as_mut() {
let wanted = if modal.kind == crate::triage::DecisionKind::Accept {
crate::ui::triage::DecisionField::Target
} else {
crate::ui::triage::DecisionField::DuplicateOf
};
if let Some(index) = modal.fields.iter().position(|f| *f == wanted) {
modal.cursor = index;
}
}
}
KeyCode::Char('a') => {
if let Some(modal) = state.modal.as_mut() {
modal.cycle_kind();
}
}
KeyCode::Char('p') => state.preview(&self.store)?,
KeyCode::Char('y') => {
if armed {
state.confirm(&self.store)?;
wrote = state.modal_finished();
} else if previewed {
state.confirm(&self.store)?;
} else {
state.note = Some("press `p` to preview first".to_string());
}
}
_ => {}
}
if wrote {
state.refresh(&self.store)?;
}
} else {
match key.code {
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => closed = true,
KeyCode::Up | KeyCode::Char('k') => state.move_cursor(-1),
KeyCode::Down | KeyCode::Char('j') => state.move_cursor(1),
KeyCode::Char(' ') => state.toggle_current(),
KeyCode::Char('X') => state.clear_selection(),
KeyCode::Char('A') => {
state.by_age = !state.by_age;
state.refresh(&self.store)?;
}
KeyCode::Char('a') => {
state.open_single(crate::triage::DecisionKind::Accept);
}
KeyCode::Char('d') => {
state.open_single(crate::triage::DecisionKind::Decline);
}
KeyCode::Char('u') => {
state.open_single(crate::triage::DecisionKind::Duplicate);
}
KeyCode::Char('f') => {
state.open_single(crate::triage::DecisionKind::Defer);
}
KeyCode::Char('p') => {
state.open_batch(crate::triage::DecisionKind::Accept);
}
_ => {}
}
}
if closed {
let note = state.note.clone();
self.triage = None;
self.mode = Mode::Board;
self.rebuild_board();
if note.is_some() {
self.status_msg = note;
}
return Ok(Action::None);
}
self.triage = Some(state);
self.mode = Mode::TriageInbox;
Ok(Action::None)
}
fn handle_bulk_modal_key(&mut self, key: KeyEvent) -> Result<Action> {
use crate::ui::bulk::{BulkFocus, BulkState};
let Some(mut modal) = self.bulk.take() else {
self.mode = Mode::Board;
return Ok(Action::None);
};
let issues = crate::ui::bulk::visible_issues(&self.board, &self.store)?;
let result = if modal.focus == BulkFocus::Edit {
match key.code {
KeyCode::Esc => {
modal.abort_edit();
Ok(())
}
KeyCode::Enter => modal.commit_edit(),
KeyCode::Backspace => {
modal.draft.pop();
Ok(())
}
KeyCode::Char(c) => {
modal.draft.push(c);
Ok(())
}
_ => Ok(()),
}
} else {
match key.code {
KeyCode::Esc => {
self.bulk = None;
self.mode = Mode::Board;
self.rebuild_board();
return Ok(Action::None);
}
KeyCode::Tab | KeyCode::Down => {
modal.move_cursor(1);
Ok(())
}
KeyCode::BackTab | KeyCode::Up => {
modal.move_cursor(-1);
Ok(())
}
KeyCode::Enter => {
if modal.row_names().is_empty() {
modal.preview(&self.store, &issues)
} else {
modal.begin_edit();
Ok(())
}
}
KeyCode::Char('p') => modal.preview(&self.store, &issues),
KeyCode::Char('y') => {
if modal.is_armed() {
modal.confirm(&self.store)
} else {
modal.arm();
Ok(())
}
}
KeyCode::Char('X') => {
modal.selection.clear();
modal.mark_stale();
Ok(())
}
KeyCode::Char(' ') | KeyCode::Char('x') => {
match self.board.get_selected_card().map(|c| c.number) {
None => Ok(()),
Some(card) => {
let outcome = if key.code == KeyCode::Char(' ') {
modal.selection.toggle(card).map(|_| true)
} else if modal.selection.remove(card) {
Ok(true)
} else {
Err(crate::ui::bulk::SelectionProblem::AlreadySelected)
};
match outcome {
Ok(_) => {
modal.mark_stale();
Ok(())
}
Err(problem) => {
modal.note = Some(if key.code == KeyCode::Char('x') {
format!("#{card} was not selected")
} else {
problem.to_string()
});
Ok(())
}
}
}
}
}
KeyCode::Char('a') => {
modal.action = modal.action.next();
modal.state = BulkState::Selecting;
modal.plan_stale = true;
modal.cursor = 0;
Ok(())
}
KeyCode::Char('t') => {
if let Some(card) = self.board.get_selected_card() {
modal.target = Some(card.number.to_string());
modal.mark_stale();
}
Ok(())
}
KeyCode::Char('c') => {
let names = modal.row_names();
match names.iter().position(|row| *row == "clear") {
None => {
modal.note = Some(format!(
"a {} has no clearable fields — nothing to clear",
modal.action.as_str()
));
Ok(())
}
Some(clear_row) => {
let name = modal.row_name(modal.cursor);
if name == "clear" {
let current = modal.row_value(clear_row);
if current == "—" {
modal.note = Some(
"no field is named — press c on the field you \
want to empty"
.to_string(),
);
Ok(())
} else {
modal.set_row(clear_row, current)
}
} else {
modal.set_row(clear_row, name.to_string())
}
}
}
}
_ => Ok(()),
}
};
if let Err(e) = &result {
modal.note = Some(e.to_string());
}
let finished = matches!(modal.state, BulkState::Done(..)) && result.is_ok();
self.bulk = Some(modal);
if finished {
if let Some(note) = self.bulk.as_ref().and_then(|m| m.note.clone()) {
self.status_msg = Some(note);
}
self.mode = Mode::Board;
self.rebuild_board();
} else {
self.mode = Mode::BulkModal;
}
let _ = result;
Ok(Action::None)
}
fn handle_resolved_key(&mut self, _context: InputContext, key: KeyEvent) -> Result<Action> {
match self.mode.clone() {
Mode::Board => self.handle_board_key(key),
Mode::RolloverDialog => self.handle_rollover_dialog_key(key),
Mode::Detail => self.handle_detail_key(key),
Mode::Form => self.handle_form_key(key),
Mode::ConfirmDelete => self.handle_confirm_delete_key(key),
Mode::MoveIntoDone { target_col, .. } => {
self.handle_move_into_done_key(key, target_col)
}
Mode::TransitionReason { target_col, .. } => {
self.handle_transition_reason_key(key, target_col)
}
Mode::ConfirmBlockedMove {
target_col,
warning,
} => self.handle_confirm_blocked_move_key(key, target_col, &warning),
Mode::Help { .. } => self.handle_help_key(key),
Mode::ContextMenu { .. } => self.handle_context_menu_key(key),
Mode::Filter { .. } => self.handle_filter_key(key),
Mode::FilterModal => self.handle_filter_modal_key(key),
Mode::BulkModal => self.handle_bulk_modal_key(key),
Mode::TriageInbox => self.handle_triage_key(key),
Mode::Settings => self.handle_settings_key(key),
Mode::PlanningManagement => self.handle_planning_key(key),
Mode::PlanningForm => self.handle_planning_form_key(key),
Mode::DatePicker { .. } => self.handle_date_picker_key(key),
Mode::Comment { .. } => self.handle_comment_key(key),
Mode::Asset { .. } => self.handle_asset_key(key),
Mode::EditLintWarning { .. } => self.handle_edit_lint_warning_key(key),
Mode::LinkCreate { .. } => self.handle_link_prompt_key(key),
Mode::LinkEdit { .. } => self.handle_link_prompt_key(key),
Mode::ConfirmRemoveRelationship { .. } => self.handle_remove_relationship_key(key),
}
}
pub fn handle_mouse(&mut self, mouse: MouseEvent) -> Result<Action> {
let mut action = match self.mode.clone() {
Mode::Board => self.handle_board_mouse(mouse),
Mode::Detail => self.handle_detail_mouse(mouse),
Mode::ConfirmDelete => self.handle_confirm_delete_mouse(mouse),
Mode::Help { .. } => self.handle_help_mouse(mouse),
Mode::ContextMenu { .. } => self.handle_context_menu_mouse(mouse),
Mode::FilterModal => self.handle_filter_modal_mouse(mouse),
Mode::Settings => self.handle_settings_mouse(mouse),
Mode::DatePicker { .. } => self.handle_date_picker_mouse(mouse),
_ => Ok(Action::None),
}?;
if action == Action::None
&& matches!(mouse.kind, MouseEventKind::Down(_))
&& !matches!(self.mode, Mode::ContextMenu { .. })
{
if let Some(region_action) = self.resolve_region(mouse.column, mouse.row) {
action = region_action;
}
}
Ok(action)
}
pub fn record_region(&self, rect: Rect, action: Action) {
self.frame_hit_regions
.borrow_mut()
.push(HitRegion { rect, action });
}
fn resolve_region(&self, x: u16, y: u16) -> Option<Action> {
self.frame_hit_regions
.borrow()
.iter()
.rev()
.find(|region| region.rect.contains(Position::new(x, y)))
.map(|region| region.action.clone())
}
fn handle_board_mouse(&mut self, mouse: MouseEvent) -> Result<Action> {
match mouse.kind {
MouseEventKind::Down(MouseButton::Left) => {
self.drag_preview_col = None;
let hit = self.board_hit_at(mouse.column, mouse.row);
self.mouse_down_left(hit)
}
MouseEventKind::Down(MouseButton::Right) => {
self.drag_preview_col = None;
let hit = self.board_hit_at(mouse.column, mouse.row);
self.open_context_menu(hit)
}
MouseEventKind::Up(MouseButton::Left) => {
self.drag_preview_col = None;
self.mouse_up_left(mouse.column, mouse.row)
}
MouseEventKind::Drag(_) => {
self.mouse_drag();
self.update_drag_preview(mouse.column, mouse.row);
Ok(Action::None)
}
MouseEventKind::ScrollUp => {
self.board.move_up();
Ok(Action::None)
}
MouseEventKind::ScrollDown => {
self.board.move_down();
Ok(Action::None)
}
MouseEventKind::Moved => {
self.drag_preview_col = None;
if let Some(tab) = self.tab_hover_at(mouse.column, mouse.row) {
self.tabs.hovered = Some(tab);
self.board.hover = None;
} else {
self.tabs.hovered = None;
self.mouse_hover(mouse.column, mouse.row);
}
Ok(Action::None)
}
_ => Ok(Action::None),
}
}
fn tab_hover_at(&self, x: u16, y: u16) -> Option<usize> {
if !self.tabs.needs_bar() {
return None;
}
let area = self.frame_size.get();
if y < area.y || y >= area.y + TAB_HEIGHT {
return None;
}
let tabs_area = Rect {
y: area.y,
height: TAB_HEIGHT,
..area
};
(0..self.tabs.len()).find(|&index| {
self.tabs
.tab_rect(index, tabs_area)
.is_some_and(|rect| rect.contains(Position::new(x, y)))
})
}
fn mouse_hover(&mut self, x: u16, y: u16) {
self.board.hover = match self.board_hit_at(x, y) {
Some(HitTarget::Card { col, row }) => Some((col, row)),
_ => None,
};
}
fn update_drag_preview(&mut self, x: u16, y: u16) {
match self.board_hit_at(x, y) {
Some(HitTarget::Card { col, row }) => {
self.drag_preview_col = Some(col);
self.board.hover = Some((col, row));
}
Some(HitTarget::Column { col }) if col < self.board.real_cols() => {
self.drag_preview_col = Some(col);
self.board.hover = None;
}
_ => {
self.drag_preview_col = None;
self.board.hover = None;
}
}
}
fn handle_detail_mouse(&mut self, mouse: MouseEvent) -> Result<Action> {
match mouse.kind {
MouseEventKind::ScrollUp => {
if let Some(d) = self.detail.as_mut() {
d.scroll_by(-i32::from(WHEEL_ROWS));
}
}
MouseEventKind::ScrollDown => {
if let Some(d) = self.detail.as_mut() {
d.scroll_by(i32::from(WHEEL_ROWS));
}
}
_ => {}
}
Ok(Action::None)
}
fn board_mouse_area(&self, area: Rect) -> Option<Rect> {
let tab_h: u16 = if self.tabs.needs_bar() { TAB_HEIGHT } else { 0 };
let filter_h: u16 = if self.filter.is_some() || self.structured.has_active_criteria() {
FILTER_HEIGHT
} else {
0
};
let hint_h: u16 = if self.one_shot { 0 } else { HINT_HEIGHT };
if area.height <= hint_h + tab_h + HOVER_HEIGHT + filter_h {
return None;
}
Some(Rect {
y: area.y + tab_h + HOVER_HEIGHT + filter_h,
height: area.height - tab_h - HOVER_HEIGHT - filter_h - hint_h,
..area
})
}
fn board_hit_at(&self, x: u16, y: u16) -> Option<HitTarget> {
let frame = self.frame_size.get();
let board_area = self.board_mouse_area(frame)?;
self.board.hit_test(
board_area,
board::BoardDisplay::from(self.active_board_config()),
self.status_msg.is_some(),
x,
y,
)
}
fn select_at(&mut self, hit: HitTarget) {
match hit {
HitTarget::Card { col, row } => {
self.board.active_col = col;
self.board.selected = row;
}
HitTarget::Column { col } => {
self.board.active_col = col;
self.board.clamp_selection();
}
}
}
fn mouse_down_left(&mut self, hit: Option<HitTarget>) -> Result<Action> {
let Some(target) = hit else {
return Ok(Action::None);
};
self.select_at(target);
let (col, card) = match target {
HitTarget::Card { col, row } => (col, Some(row)),
HitTarget::Column { col } => (col, None),
};
let double_click = match target {
HitTarget::Card { col, row } => match self.mouse.last_down.as_ref() {
Some(prev) => {
prev.at.elapsed() < self.mouse.double_click_window
&& prev.col == col
&& prev.card == Some(row)
}
None => false,
},
HitTarget::Column { .. } => false,
};
self.mouse.last_down = Some(MouseDown {
col,
card,
at: Instant::now(),
});
if double_click {
return self.open_detail_for(target);
}
Ok(Action::None)
}
fn open_detail_for(&mut self, hit: HitTarget) -> Result<Action> {
if let HitTarget::Card { col, row } = hit {
if let Some(card) = self.board.column_cards(col).get(row).cloned() {
let issue = self.store.get(IssueSelector::Filename(card.filename))?;
let relationships = self.store.relationships_for(issue.number)?;
let (all, done_column) = self.store.graph_source()?;
let graph = commands::graph_over(&all, &done_column);
let number = issue.number;
let category = self.category_of_issue(&issue);
self.detail = Some(DetailState {
issue,
category,
relationships,
chain: graph.dependency_chain(number),
blocked: commands::blocked_note(&graph.blocked_state(number)),
hierarchy: commands::parent::facts_over(&all, &done_column, number),
scroll_offset: 0,
viewport: std::cell::Cell::new(0),
max_scroll: std::cell::Cell::new(0),
rel_cursor: 0,
});
self.mode = Mode::Detail;
}
}
Ok(Action::None)
}
fn open_context_menu(&mut self, hit: Option<HitTarget>) -> Result<Action> {
let target = match hit {
Some(target @ HitTarget::Card { .. }) => target,
_ => return Ok(Action::None),
};
self.select_at(target);
let mut items = vec![
MenuItem {
kind: MenuItemKind::OpenDetail,
label: "Open detail".to_string(),
},
MenuItem {
kind: MenuItemKind::EditInEditor,
label: "Edit in editor".to_string(),
},
];
for col in 0..self.board.real_cols() {
if col != self.board.active_col {
let name = self.board.column_name(col);
items.push(MenuItem {
kind: MenuItemKind::MoveTo { column: col },
label: format!("Move to {name}"),
});
}
}
items.push(MenuItem {
kind: MenuItemKind::Delete,
label: "Delete".to_string(),
});
items.push(MenuItem {
kind: MenuItemKind::RefreshBoard,
label: "Refresh board".to_string(),
});
self.context_menu = Some(ContextMenu::new(items));
self.mode = Mode::ContextMenu {
return_to: Box::new(Mode::Board),
};
Ok(Action::None)
}
fn close_context_menu(&mut self) {
if let Mode::ContextMenu { return_to } = std::mem::replace(&mut self.mode, Mode::Board) {
self.mode = *return_to;
}
self.context_menu = None;
}
fn handle_context_menu_key(&mut self, key: KeyEvent) -> Result<Action> {
let Some(menu) = self.context_menu.as_mut() else {
self.close_context_menu();
return Ok(Action::None);
};
match menu.handle_key(key.code) {
MenuEvent::None => Ok(Action::None),
MenuEvent::Dismissed => {
self.close_context_menu();
Ok(Action::None)
}
MenuEvent::Selected(index) => {
let kind = menu.item(index).map(|item| item.kind.clone());
let Some(kind) = kind else {
return Ok(Action::None);
};
self.activate_context_item(kind)
}
}
}
fn handle_context_menu_mouse(&mut self, mouse: MouseEvent) -> Result<Action> {
match mouse.kind {
MouseEventKind::ScrollUp => {
if let Some(menu) = self.context_menu.as_mut() {
let _ = menu.handle_key(KeyCode::Up);
}
Ok(Action::None)
}
MouseEventKind::ScrollDown => {
if let Some(menu) = self.context_menu.as_mut() {
let _ = menu.handle_key(KeyCode::Down);
}
Ok(Action::None)
}
MouseEventKind::Down(_) => {
let Some(menu) = self.context_menu.take() else {
self.close_context_menu();
return Ok(Action::None);
};
let bounds = menu.bounds(self.frame_size.get());
let in_popup = mouse.column >= bounds.x
&& mouse.column < bounds.x + bounds.width
&& mouse.row >= bounds.y
&& mouse.row < bounds.y + bounds.height;
if !in_popup {
self.close_context_menu();
return Ok(Action::None);
}
let row_hit = mouse.row.checked_sub(bounds.y + 1);
let index = match row_hit {
Some(offset) => usize::from(offset),
None => return Ok(Action::None),
};
let kind = menu.item(index).map(|item| item.kind.clone());
self.context_menu = Some(menu);
match kind {
Some(kind) => self.activate_context_item(kind),
None => Ok(Action::None),
}
}
_ => Ok(Action::None),
}
}
fn activate_context_item(&mut self, kind: MenuItemKind) -> Result<Action> {
self.close_context_menu();
match kind {
MenuItemKind::OpenDetail => {
let hit = HitTarget::Card {
col: self.board.active_col,
row: self.board.selected,
};
self.open_detail_for(hit)
}
MenuItemKind::EditInEditor => {
if let Some(card) = self.board.get_selected_card() {
return Ok(Action::OpenEditor(card.filename.clone()));
}
Ok(Action::None)
}
MenuItemKind::MoveTo { column } => self.move_selected_to(column),
MenuItemKind::Delete => {
if let Some(card) = self.board.get_selected_card().cloned() {
self.confirm_delete_target = Some(card.filename);
self.mode = Mode::ConfirmDelete;
}
Ok(Action::None)
}
MenuItemKind::RefreshBoard => {
self.rebuild_board();
self.status_msg = Some("Refreshed".to_string());
Ok(Action::None)
}
}
}
fn mouse_drag(&mut self) {
if self.mouse.drag.is_none() {
if let Some(down) = &self.mouse.last_down {
if down.card.is_some() {
self.mouse.drag = Some(Drag {
col: down.col,
card: down.card,
});
}
}
}
}
fn mouse_up_left(&mut self, x: u16, y: u16) -> Result<Action> {
self.board.hover = None;
if let Some(drag) = self.mouse.drag.take() {
self.mouse.last_down = None;
if drag.card.is_none() {
return Ok(Action::None);
}
let target = match self.board_hit_at(x, y) {
Some(target) => target,
None => return Ok(Action::None),
};
match target {
HitTarget::Card { col, row } if col == drag.col => {
if Some(row) == drag.card {
return Ok(Action::None);
}
return self.reorder_selected_within_column(col, drag.card.unwrap(), row);
}
HitTarget::Column { col } if col == drag.col => {
return self.reorder_selected_within_column(
col,
drag.card.unwrap(),
self.board.column_cards(col).len().saturating_sub(1),
);
}
HitTarget::Card { col, .. } | HitTarget::Column { col }
if col < self.board.real_cols() && col != drag.col =>
{
return self.move_selected_to(col);
}
_ => return Ok(Action::None),
}
}
Ok(Action::None)
}
fn persist_card_order(&mut self) -> bool {
let board_name = self.active_board_name().to_string();
let mut order = Vec::new();
let active_config = self.active_board_config().clone();
order.extend(
self.board
.columns
.iter()
.flat_map(|col| board::sparse_card_order(&active_config, &col.cards)),
);
order.extend(
self.board
.hidden
.iter()
.flat_map(|col| board::sparse_card_order(&active_config, &col.cards)),
);
order.extend(board::sparse_card_order(
&active_config,
&self.board.mismatch.cards,
));
let Some(board_config) = self.store.config.kanban.configurations.get_mut(&board_name)
else {
return false;
};
board_config.card_order = order;
self.persist_config()
}
fn reorder_selected_within_column(
&mut self,
col: usize,
from_row: usize,
to_row: usize,
) -> Result<Action> {
let (filename, number, column_name) = {
let Some(column) = self.board.columns.get_mut(col) else {
return Ok(Action::None);
};
if from_row >= column.cards.len() || to_row >= column.cards.len() || from_row == to_row
{
return Ok(Action::None);
}
let card = column.cards.remove(from_row);
let filename = card.filename.clone();
let number = card.number;
let insert_at = to_row;
column.cards.insert(insert_at, card);
(filename, number, column.name.clone())
};
if !self.persist_card_order() {
return Ok(Action::None);
}
self.rebuild_board();
self.select_card_by_filename(&filename);
self.status_msg = Some(format!("Reordered #{} in {}", number, column_name));
Ok(Action::None)
}
fn move_selected_within_column(&mut self, delta: isize) -> Result<Action> {
let col = self.board.active_col;
let column_name = if col < self.board.columns.len() {
self.board.columns[col].name.clone()
} else if col == self.board.columns.len() && self.board.show_mismatch {
self.board.mismatch.name.clone()
} else {
return Ok(Action::None);
};
let (filename, number) = {
let cards = if col < self.board.columns.len() {
&mut self.board.columns[col].cards
} else {
&mut self.board.mismatch.cards
};
let len = cards.len();
if len < 2 {
return Ok(Action::None);
}
let from_row = self.board.selected;
if from_row >= len {
return Ok(Action::None);
}
let to_row = if delta < 0 {
from_row.saturating_sub(delta.unsigned_abs())
} else {
usize::min(from_row + delta as usize, len - 1)
};
if to_row == from_row {
return Ok(Action::None);
}
let card = cards.remove(from_row);
let filename = card.filename.clone();
let number = card.number;
let insert_at = to_row;
cards.insert(insert_at, card);
(filename, number)
};
if !self.persist_card_order() {
return Ok(Action::None);
}
self.rebuild_board();
self.select_card_by_filename(&filename);
self.status_msg = Some(format!(
"Moved #{} {} in {}",
number,
if delta < 0 { "up" } else { "down" },
column_name
));
Ok(Action::None)
}
#[allow(clippy::unused_self, clippy::unnecessary_wraps)]
fn handle_confirm_delete_mouse(&mut self, _mouse: MouseEvent) -> Result<Action> {
Ok(Action::None)
}
#[allow(clippy::unnecessary_wraps)]
fn handle_help_mouse(&mut self, mouse: MouseEvent) -> Result<Action> {
match mouse.kind {
MouseEventKind::ScrollUp => self.scroll_help(-i32::from(WHEEL_ROWS)),
MouseEventKind::ScrollDown => self.scroll_help(i32::from(WHEEL_ROWS)),
_ => {}
}
Ok(Action::None)
}
#[allow(clippy::unused_self, clippy::unnecessary_wraps)]
fn handle_filter_modal_mouse(&mut self, _mouse: MouseEvent) -> Result<Action> {
Ok(Action::None)
}
#[allow(clippy::unused_self, clippy::unnecessary_wraps)]
fn handle_settings_mouse(&mut self, _mouse: MouseEvent) -> Result<Action> {
Ok(Action::None)
}
#[allow(clippy::unused_self, clippy::unnecessary_wraps)]
fn handle_date_picker_mouse(&mut self, _mouse: MouseEvent) -> Result<Action> {
Ok(Action::None)
}
fn handle_board_key(&mut self, key: KeyEvent) -> Result<Action> {
if key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL)
{
match key.code {
KeyCode::Up => return self.move_selected_within_column(-1),
KeyCode::Down => return self.move_selected_within_column(1),
_ => {}
}
}
match key.code {
KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc => {
self.quit = true;
Ok(Action::Quit)
}
KeyCode::Tab => {
self.tabs.next();
self.active_config = self.tabs.selected;
self.switch_config();
Ok(Action::None)
}
KeyCode::BackTab => {
self.tabs.previous();
self.active_config = self.tabs.selected;
self.switch_config();
Ok(Action::None)
}
KeyCode::Left | KeyCode::Char('h') => {
self.board.move_left();
Ok(Action::None)
}
KeyCode::Right | KeyCode::Char('l') => {
self.board.move_right();
Ok(Action::None)
}
KeyCode::Up | KeyCode::Char('k') => {
self.board.move_up();
Ok(Action::None)
}
KeyCode::Down | KeyCode::Char('j') => {
self.board.move_down();
Ok(Action::None)
}
KeyCode::Enter => {
if let Some(card) = self.board.get_selected_card().cloned() {
let issue = self.store.get(IssueSelector::Filename(card.filename))?;
let relationships = self.store.relationships_for(issue.number)?;
let (all, done_column) = self.store.graph_source()?;
let graph = commands::graph_over(&all, &done_column);
let number = issue.number;
let category = self.category_of_issue(&issue);
self.detail = Some(DetailState {
issue,
category,
relationships,
chain: graph.dependency_chain(number),
blocked: commands::blocked_note(&graph.blocked_state(number)),
hierarchy: commands::parent::facts_over(&all, &done_column, number),
scroll_offset: 0,
viewport: std::cell::Cell::new(0),
max_scroll: std::cell::Cell::new(0),
rel_cursor: 0,
});
self.mode = Mode::Detail;
}
Ok(Action::None)
}
KeyCode::Char('e') => {
if let Some(card) = self.board.get_selected_card() {
Ok(Action::OpenEditor(card.filename.clone()))
} else {
Ok(Action::None)
}
}
KeyCode::Char('A') => {
if let Some(card) = self.board.get_selected_card() {
Ok(Action::OpenAgent(card.filename.clone()))
} else {
Ok(Action::None)
}
}
KeyCode::Char('c') => {
self.toggle_column_visibility();
Ok(Action::None)
}
KeyCode::Char('f') => {
self.open_filter_modal();
Ok(Action::None)
}
KeyCode::Char(' ') => {
self.open_bulk_modal(crate::ui::bulk::BatchAction::Edit, true);
Ok(Action::None)
}
KeyCode::Char('x') | KeyCode::Char('X') => {
if self.bulk.is_some() {
let Some(card) = self.board.get_selected_card().map(|c| c.number) else {
return Ok(Action::None);
};
if let Some(modal) = self.bulk.as_mut() {
if !modal.selection.remove(card) {
modal.note = Some(format!("#{card} was not selected"));
}
}
} else {
self.open_bulk_modal(crate::ui::bulk::BatchAction::Edit, true);
}
Ok(Action::None)
}
KeyCode::Char('B') => {
let action = self
.bulk
.as_ref()
.map(|m| m.action)
.unwrap_or(crate::ui::bulk::BatchAction::Edit);
self.open_bulk_modal(action, false);
Ok(Action::None)
}
KeyCode::Char('<') => {
let real_cols = self.board.real_cols();
if real_cols > 0 {
let target = if self.board.active_col == 0 || self.board.active_col >= real_cols
{
real_cols - 1
} else {
self.board.active_col - 1
};
self.move_selected_to(target)
} else {
Ok(Action::None)
}
}
KeyCode::Char('>') => {
let real_cols = self.board.real_cols();
if real_cols > 0 {
let target = if self.board.active_col >= real_cols {
0
} else {
(self.board.active_col + 1) % real_cols
};
self.move_selected_to(target)
} else {
Ok(Action::None)
}
}
KeyCode::Char('n') => {
self.open_new_issue_form();
Ok(Action::None)
}
KeyCode::Char('C') => {
if let Some(card) = self.board.get_selected_card().cloned() {
match crate::commands::view::get(&self.store, &card.filename) {
Ok(issue) => {
self.status_msg = Some(format!("Cloning #{}", issue.number));
self.form = Some(FormState::from_issue(&issue));
self.mode = Mode::Form;
}
Err(err) => self.status_msg = Some(format!("Cannot clone: {err}")),
}
} else {
self.status_msg = Some("No issue selected to clone".to_string());
}
Ok(Action::None)
}
KeyCode::Char('a') => {
self.show_archived = !self.show_archived;
self.refresh_corpus()?;
self.status_msg = Some(
if self.show_archived {
"Showing archived issues"
} else {
"Hiding archived issues"
}
.to_string(),
);
Ok(Action::None)
}
KeyCode::PageDown => {
self.move_selection_by_page(1);
Ok(Action::None)
}
KeyCode::PageUp => {
self.move_selection_by_page(-1);
Ok(Action::None)
}
KeyCode::Char('d') => {
if let Some(card) = self.board.get_selected_card().cloned() {
self.confirm_delete_target = Some(card.filename);
self.mode = Mode::ConfirmDelete;
}
Ok(Action::None)
}
KeyCode::Char('r') => {
self.rebuild_board();
self.status_msg = Some("Refreshed".to_string());
Ok(Action::None)
}
KeyCode::Char('s') => {
self.open_settings(None);
Ok(Action::None)
}
KeyCode::Char('t') => self.open_triage_inbox(),
KeyCode::Char('m') | KeyCode::Char('M') => {
self.open_planning_modal();
Ok(Action::None)
}
KeyCode::Char('b') => {
self.open_settings(Some(SettingsPrompt::NewBoard(String::new())));
Ok(Action::None)
}
KeyCode::Char('?') => {
self.open_help(Mode::Board);
Ok(Action::None)
}
KeyCode::Char('/') => {
let buffer = self.filter.clone().unwrap_or_default();
self.mode = Mode::Filter { buffer };
Ok(Action::None)
}
_ => Ok(Action::None),
}
}
fn move_selected_to(&mut self, target_col: usize) -> Result<Action> {
self.move_selected_to_impl(target_col, None, false)
}
fn move_selected_to_impl(
&mut self,
target_col: usize,
override_reason: Option<String>,
acknowledged: bool,
) -> Result<Action> {
let card = match self.board.get_selected_card().cloned() {
Some(c) => c,
None => return Ok(Action::None),
};
let target_name = self.board.column_name(target_col).to_string();
let (_, resolution) = crate::workflow::active_resolution(&self.store.config);
let is_done_target = resolution
.resolve(&target_name)
.is_some_and(crate::workflow::WorkflowCategory::is_complete);
let current_col_name = self.board.column_name(self.board.active_col);
let was_done = resolution
.resolve(current_col_name)
.is_some_and(crate::workflow::WorkflowCategory::is_complete);
let issue = self
.store
.get(IssueSelector::Filename(card.filename.clone()))?;
if override_reason.is_none()
&& let crate::workflow::TransitionOutcome::Rejected(diagnostic) =
crate::workflow::validate_transition(
&issue,
&target_name,
&resolution,
&self.store.config,
None,
)
{
self.mode = Mode::TransitionReason {
target_col,
reason: diagnostic.to_string(),
buffer: String::new(),
};
return Ok(Action::None);
}
let guard = mv::blocked_move_guard(&self.store.config, &self.store, &issue, &target_name)?;
if guard.needs_acknowledgement() && !acknowledged {
self.mode = Mode::ConfirmBlockedMove {
target_col,
warning: guard.warning().unwrap_or_default(),
};
return Ok(Action::None);
}
if is_done_target && card.commit.is_none() {
self.mode = Mode::MoveIntoDone {
target_col,
buffer: String::new(),
};
return Ok(Action::None);
}
let commit = if was_done { None } else { card.commit.clone() };
mv::apply_guarded(
&self.store.config,
&self.store,
&card.filename,
&target_name,
commit.as_deref(),
override_reason.as_deref(),
acknowledged,
)?;
self.rebuild_board();
self.board.select_column(target_col);
self.status_msg = Some(format!("Moved #{} to {target_name}", card.number));
Ok(Action::None)
}
fn handle_transition_reason_key(&mut self, key: KeyEvent, target_col: usize) -> Result<Action> {
match key.code {
KeyCode::Enter => {
let reason = match &self.mode {
Mode::TransitionReason { buffer, .. } => buffer.clone(),
_ => String::new(),
};
if reason.trim().is_empty() {
self.status_msg = Some(
"A reason is required: the decision is recorded in the issue's \
history and has to say why"
.to_string(),
);
return Ok(Action::None);
}
self.mode = Mode::Board;
self.move_selected_to_impl(target_col, Some(reason), false)
}
KeyCode::Esc => {
self.mode = Mode::Board;
Ok(Action::None)
}
KeyCode::Char(c) => {
if let Mode::TransitionReason { ref mut buffer, .. } = self.mode {
buffer.push(c);
}
Ok(Action::None)
}
KeyCode::Backspace => {
if let Mode::TransitionReason { ref mut buffer, .. } = self.mode {
buffer.pop();
}
Ok(Action::None)
}
_ => Ok(Action::None),
}
}
fn handle_detail_key(&mut self, key: KeyEvent) -> Result<Action> {
match key.code {
KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc => {
self.mode = Mode::Board;
self.detail = None;
Ok(Action::None)
}
KeyCode::Char('e') => {
if let Some(ref d) = self.detail {
let filename = d.issue.filename.clone();
Ok(Action::OpenEditor(filename))
} else {
Ok(Action::None)
}
}
KeyCode::PageUp => {
if let Some(d) = self.detail.as_mut() {
let page = d.page();
d.scroll_by(-i32::from(page));
}
Ok(Action::None)
}
KeyCode::PageDown => {
if let Some(d) = self.detail.as_mut() {
let page = d.page();
d.scroll_by(i32::from(page));
}
Ok(Action::None)
}
KeyCode::Char('?') => {
self.open_help(Mode::Detail);
Ok(Action::None)
}
KeyCode::Char('c') => {
self.mode = Mode::Comment {
buffer: String::new(),
};
Ok(Action::None)
}
KeyCode::Char('a') => {
self.mode = Mode::Asset {
buffer: String::new(),
};
Ok(Action::None)
}
KeyCode::Char('n') => {
self.mode = Mode::LinkCreate {
buffer: String::new(),
step: LinkStep::Target,
target: None,
};
Ok(Action::None)
}
KeyCode::Char('E') => {
match self.selected_relationship_id() {
Some(id) => {
let index = self.detail.as_ref().map(|d| d.rel_cursor).unwrap_or(0);
self.mode = Mode::LinkEdit {
index,
buffer: String::new(),
step: LinkStep::Target,
target: None,
kind: None,
};
self.status_msg = Some(format!("Re-kind {id}"));
}
None => {
self.status_msg =
Some("no relationship selected — press j or k first".to_string())
}
}
Ok(Action::None)
}
KeyCode::Char('d') => {
match self.selected_relationship().cloned() {
Some(rel) => {
let line = crate::commands::link::relationship_line(&rel);
let index = self.detail.as_ref().map(|d| d.rel_cursor).unwrap_or(0);
self.status_msg = Some(format!("Remove {line}? (y to confirm)"));
self.mode = Mode::ConfirmRemoveRelationship { index };
}
None => {
self.status_msg =
Some("no relationship selected — press j or k first".to_string())
}
}
Ok(Action::None)
}
KeyCode::Char('j') | KeyCode::Down => {
self.move_rel_cursor(1);
Ok(Action::None)
}
KeyCode::Char('k') | KeyCode::Up => {
self.move_rel_cursor(-1);
Ok(Action::None)
}
_ => Ok(Action::None),
}
}
fn handle_form_key(&mut self, key: KeyEvent) -> Result<Action> {
if self.form_picker.is_some() {
return self.handle_form_picker_key(key);
}
let form = match self.form.as_mut() {
Some(f) => f,
None => return Ok(Action::None),
};
match key.code {
KeyCode::Esc => {
self.form = None;
self.mode = Mode::Board;
Ok(Action::None)
}
KeyCode::Enter => {
if form.is_date_focus() && !form.focused_date_is_complete() {
self.open_date_picker(Mode::Form);
return Ok(Action::None);
}
if form.focus.is_parent() && form.parent.trim().is_empty() {
self.open_form_picker();
return Ok(Action::None);
}
if form.title.trim().is_empty() {
self.status_msg = Some("Title is required".to_string());
return Ok(Action::None);
}
let start_date = if form.start_date.trim().is_empty() {
None
} else {
Some(form.start_date.trim().to_string())
};
let due_date = if form.due_date.trim().is_empty() {
None
} else {
Some(form.due_date.trim().to_string())
};
if let Some(start) = start_date.as_deref()
&& !crate::model::validate_date(start)
{
self.status_msg = Some("Start date must be YYYY-MM-DD".to_string());
return Ok(Action::None);
}
if let Some(due) = due_date.as_deref()
&& !crate::model::validate_date(due)
{
self.status_msg = Some("Due date must be YYYY-MM-DD".to_string());
return Ok(Action::None);
}
if let (Some(start), Some(due)) = (start_date.as_deref(), due_date.as_deref())
&& due < start
{
self.status_msg = Some("Due date must be >= start date".to_string());
return Ok(Action::None);
}
let title = form.title.trim().to_string();
let status_str = if form.status.trim().is_empty() {
self.store
.config
.default_config()
.columns
.first()
.cloned()
.unwrap_or_else(|| "todo".to_string())
} else {
form.status.trim().to_string()
};
let priority: Priority = form.priority.parse().unwrap_or_default();
let identity = git_identity();
let assignee = if form.assignee.trim().is_empty() {
None
} else {
resolve_me(form.assignee.trim(), identity.as_ref())
};
let labels: Vec<String> = form
.labels
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
let estimate = if form.estimate.trim().is_empty() {
None
} else {
Some(form.estimate.trim().to_string())
};
let cycles = {
let values: Vec<String> = form
.cycles
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect();
(!values.is_empty()).then_some(values)
};
let milestones = {
let values: Vec<String> = form
.milestones
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect();
(!values.is_empty()).then_some(values)
};
let reviewer = if form.reviewer.trim().is_empty() {
None
} else {
Some(form.reviewer.trim().to_string())
};
let parent_number = match form.parent.trim() {
"" => None,
raw => match self.store.resolve_issue(raw).and_then(|target| {
let (index, _) = self.store.hierarchy_index()?;
crate::hierarchy::validate_new_parent(&index, target.number)?;
Ok(target.number)
}) {
Ok(number) => Some(number),
Err(e) => {
self.status_msg = Some(e.to_string());
return Ok(Action::None);
}
},
};
let mut issue = Issue {
number: 0,
title,
status: Status::new(status_str),
labels,
priority,
assignee,
estimate,
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
relationships: vec![],
files: vec![],
cycles,
milestones,
reviewer,
start_date,
due_date,
created: today(),
updated: today(),
filename: String::new(),
body: String::new(),
history: crate::model::History::new(),
has_retired_links_key: false,
parent: parent_number,
extra_frontmatter: std::collections::BTreeMap::new(),
archived: false,
entered_at: None,
source: None,
};
if let Some(number) = parent_number {
issue.history.append_now(
&crate::storage::actor_identity(),
crate::model::HistoryEventKind::ParentSet,
&crate::hierarchy::change_detail(None, Some(number)),
);
}
crate::triage::record_entry_if_inbox(&self.store.config, &mut issue, "new")?;
self.store.create(&mut issue, "", None)?;
let filename = issue.filename.clone();
let basename = filename.rsplit('/').next().unwrap_or(&filename).to_string();
self.status_msg = Some(format!("Created {basename}"));
self.form = None;
self.mode = Mode::Board;
self.refresh_corpus()?;
self.select_card_by_filename(&filename);
Ok(Action::None)
}
KeyCode::Tab | KeyCode::Down => {
form.move_focus_next();
Ok(Action::None)
}
KeyCode::BackTab | KeyCode::Up => {
form.move_focus_prev();
Ok(Action::None)
}
KeyCode::F(2) => {
if form.is_date_focus() {
self.open_date_picker(Mode::Form);
} else {
self.open_form_picker();
}
Ok(Action::None)
}
KeyCode::Char(c) => {
form.insert_char(c);
Ok(Action::None)
}
KeyCode::Backspace => {
form.delete_char();
Ok(Action::None)
}
_ => Ok(Action::None),
}
}
fn open_date_picker(&mut self, return_to: Mode) {
let current = match return_to {
Mode::Form => self.form.as_ref().map(|f| f.focused_value().to_string()),
Mode::PlanningForm => self
.planning_form
.as_ref()
.map(|f| f.focused_value().to_string()),
Mode::FilterModal => self.filter_modal.as_ref().map(|m| m.focused_value()),
_ => None,
};
self.date_picker = Some(DatePicker::new(current.as_deref().unwrap_or("")));
self.mode = Mode::DatePicker {
return_to: Box::new(return_to),
};
}
fn handle_date_picker_key(&mut self, key: KeyEvent) -> Result<Action> {
let Some(picker) = self.date_picker.as_mut() else {
self.mode = Mode::Board;
return Ok(Action::None);
};
match picker.handle_key(key.code) {
DatePickerEvent::None => {}
DatePickerEvent::Cancelled => self.close_date_picker(),
DatePickerEvent::Selected(value) => {
self.apply_date_selection(&value);
self.close_date_picker();
}
DatePickerEvent::Cleared => {
self.apply_date_selection("");
self.close_date_picker();
}
}
Ok(Action::None)
}
fn close_date_picker(&mut self) {
let return_to = match std::mem::replace(&mut self.mode, Mode::Board) {
Mode::DatePicker { return_to } => *return_to,
_ => Mode::Board,
};
self.date_picker = None;
self.mode = return_to;
}
fn apply_date_selection(&mut self, value: &str) {
let return_to = match &self.mode {
Mode::DatePicker { return_to } => (**return_to).clone(),
_ => return,
};
match return_to {
Mode::Form => {
if let Some(form) = self.form.as_mut() {
form.set_focused_date(value);
}
}
Mode::PlanningForm => {
if let Some(form) = self.planning_form.as_mut() {
form.set_focused_date(value);
}
}
Mode::FilterModal => {
if let Some(modal) = self.filter_modal.as_mut() {
modal.set_focused_date(value);
}
self.rebuild_board();
}
_ => {}
}
}
fn handle_form_picker_key(&mut self, key: KeyEvent) -> Result<Action> {
if matches!(key.code, KeyCode::Esc | KeyCode::F(2)) {
self.form_picker = None;
return Ok(Action::None);
}
let event = match self.form_picker.as_mut() {
Some(picker) => picker.state.handle_key(key.code),
None => return Ok(Action::None),
};
match event {
PickerEvent::None => {}
PickerEvent::Cancelled => {
self.form_picker = None;
}
PickerEvent::Selected(ids) => {
self.apply_picker_selection(ids);
}
PickerEvent::Created(value) => {
self.apply_picker_created(value)?;
}
}
Ok(Action::None)
}
fn open_form_picker(&mut self) {
let Some(form) = self.form.as_ref() else {
return;
};
let field = match form.focus {
form::FormFocus::Labels => FormPickerField::Labels,
form::FormFocus::Assignee => FormPickerField::Assignee,
form::FormFocus::Cycles => FormPickerField::Cycles,
form::FormFocus::Milestones => FormPickerField::Milestones,
form::FormFocus::Reviewer => FormPickerField::Reviewer,
form::FormFocus::Parent => FormPickerField::Parent,
_ => return,
};
let options = self.picker_options(field);
let mut state = match field {
FormPickerField::Labels | FormPickerField::Cycles | FormPickerField::Milestones => {
DropdownState::multi(options)
}
FormPickerField::Assignee | FormPickerField::Reviewer | FormPickerField::Parent => {
DropdownState::single(options)
}
};
self.seed_picker_selection(field, &mut state);
self.form_picker = Some(FormPicker { field, state });
}
fn picker_options(&self, field: FormPickerField) -> Vec<PickerItem> {
match field {
FormPickerField::Cycles => {
let root = self.project_root();
let planner = Planner::from_config(self.store.config.clone(), root);
planner
.cycle_names()
.into_iter()
.map(|name| PickerItem {
id: name.clone(),
label: name,
})
.collect()
}
FormPickerField::Milestones => {
let root = self.project_root();
let planner = Planner::from_config(self.store.config.clone(), root);
planner
.milestone_names()
.unwrap_or_default()
.into_iter()
.map(|name| PickerItem {
id: name.clone(),
label: name,
})
.collect()
}
FormPickerField::Labels => {
let mut out = std::collections::BTreeSet::new();
for issue in self.store.list().unwrap_or_default() {
for label in issue.labels {
if !label.trim().is_empty() {
out.insert(label);
}
}
}
out.into_iter()
.map(|label| PickerItem {
id: label.clone(),
label,
})
.collect()
}
FormPickerField::Parent => {
self.store
.list()
.unwrap_or_default()
.into_iter()
.map(|issue| PickerItem {
id: issue.number.to_string(),
label: format!("#{} {}", issue.number, issue.title),
})
.collect()
}
FormPickerField::Assignee | FormPickerField::Reviewer => {
let mut out = std::collections::BTreeSet::new();
for team in self.store.config.teams.values() {
for member in &team.members {
if !member.trim().is_empty() {
out.insert(member.trim().to_string());
}
}
}
if let Some(id) = git_identity() {
out.insert(id.to_string());
}
if let Some(form) = self.form.as_ref() {
let current = match field {
FormPickerField::Assignee => form.assignee.trim(),
FormPickerField::Reviewer => form.reviewer.trim(),
_ => "",
};
if !current.is_empty() {
out.insert(current.to_string());
}
}
out.into_iter()
.map(|member| PickerItem {
id: member.clone(),
label: member,
})
.collect()
}
}
}
fn seed_picker_selection(&self, field: FormPickerField, picker: &mut DropdownState) {
let Some(form) = self.form.as_ref() else {
return;
};
let values: Vec<String> = match field {
FormPickerField::Labels => form
.labels
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect(),
FormPickerField::Cycles => form
.cycles
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect(),
FormPickerField::Milestones => form
.milestones
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect(),
FormPickerField::Assignee => form
.assignee
.trim()
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.take(1)
.collect(),
FormPickerField::Reviewer => form
.reviewer
.trim()
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.take(1)
.collect(),
FormPickerField::Parent => (!form.parent.trim().is_empty())
.then(|| form.parent.trim().to_string())
.into_iter()
.collect(),
};
match &mut picker.selection {
SelectionModel::Single(current) => {
*current = values.first().cloned();
}
SelectionModel::Multi(current) => {
current.clear();
current.extend(values);
}
}
}
fn apply_picker_selection(&mut self, values: Vec<String>) {
let Some(form) = self.form.as_mut() else {
return;
};
let Some(field) = self.form_picker.as_ref().map(|p| p.field) else {
return;
};
match field {
FormPickerField::Labels => form.labels = values.join(","),
FormPickerField::Assignee => {
form.assignee = values.first().cloned().unwrap_or_default()
}
FormPickerField::Cycles => form.cycles = values.join(","),
FormPickerField::Milestones => form.milestones = values.join(","),
FormPickerField::Reviewer => {
form.reviewer = values.first().cloned().unwrap_or_default()
}
FormPickerField::Parent => form.parent = values.first().cloned().unwrap_or_default(),
}
}
fn apply_picker_created(&mut self, value: String) -> Result<()> {
let value = value.trim().to_string();
if value.is_empty() {
return Ok(());
}
let Some(field) = self.form_picker.as_ref().map(|p| p.field) else {
return Ok(());
};
match field {
FormPickerField::Cycles => {
let root = self.project_root();
let mut planner = Planner::from_config(self.store.config.clone(), root.clone());
match planner.add_cycle(Some(value.clone())) {
Ok(_) => {
self.reload_config_from(root.as_path())?;
}
Err(e) => {
self.status_msg = Some(e.to_string());
return Ok(());
}
}
}
FormPickerField::Milestones => {
let root = self.project_root();
let planner = Planner::from_config(self.store.config.clone(), root.clone());
match planner.create_milestone_file(&value, None, None) {
Ok(_) => {
self.reload_config_from(root.as_path())?;
}
Err(e) => {
self.status_msg = Some(e.to_string());
return Ok(());
}
}
}
FormPickerField::Assignee | FormPickerField::Reviewer => {
let root = self.project_root();
let mut planner = Planner::from_config(self.store.config.clone(), root.clone());
let team = self
.store
.config
.planning
.default_team
.clone()
.unwrap_or_else(|| "default".to_string());
match planner.add_team_member(&team, &value) {
Ok(()) => {
self.reload_config_from(root.as_path())?;
}
Err(e) => {
self.status_msg = Some(e.to_string());
return Ok(());
}
}
}
FormPickerField::Labels | FormPickerField::Parent => {}
}
let next_options = self.picker_options(field);
if let Some(picker) = self.form_picker.as_mut() {
picker.state.options = next_options;
picker.state.filter.clear();
picker.state.cursor = 0;
picker.state.mode = PickerMode::Idle;
picker.state.create_buffer.clear();
match &mut picker.state.selection {
SelectionModel::Single(current) => *current = Some(value.clone()),
SelectionModel::Multi(current) => {
current.insert(value.clone());
}
}
let values = match &picker.state.selection {
SelectionModel::Single(current) => current.iter().cloned().collect(),
SelectionModel::Multi(current) => current.iter().cloned().collect(),
};
self.apply_picker_selection(values);
}
Ok(())
}
fn project_root(&self) -> PathBuf {
Config::project_root(&self.store.issues_dir).unwrap_or_else(|| PathBuf::from("."))
}
fn reload_config_from(&mut self, root: &std::path::Path) -> Result<()> {
let cfg = Config::load_with_warning(&root.join("tix.toml"), |_| {})?;
self.bindings = cfg.effective_keybindings()?;
self.store.config = cfg;
Ok(())
}
fn handle_confirm_delete_key(&mut self, key: KeyEvent) -> Result<Action> {
match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => self.confirm_delete_yes(),
_ => self.confirm_delete_no(),
}
}
fn confirm_delete_yes(&mut self) -> Result<Action> {
if let Some(filename) = self.confirm_delete_target.take() {
self.store.delete(&filename)?;
self.status_msg = Some(format!("Deleted {filename}"));
}
self.mode = Mode::Board;
self.rebuild_board();
Ok(Action::None)
}
fn confirm_delete_no(&mut self) -> Result<Action> {
self.confirm_delete_target = None;
self.mode = Mode::Board;
Ok(Action::None)
}
fn handle_confirm_blocked_move_key(
&mut self,
key: KeyEvent,
target_col: usize,
warning: &str,
) -> Result<Action> {
let confirmed = matches!(
key.code,
KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter
);
if !confirmed {
self.mode = Mode::Board;
self.status_msg = Some(format!("Move cancelled; {warning}"));
return Ok(Action::None);
}
self.mode = Mode::Board;
self.move_selected_to_impl(target_col, None, true)
}
fn handle_move_into_done_key(&mut self, key: KeyEvent, target_col: usize) -> Result<Action> {
match key.code {
KeyCode::Enter => {
let buffer = match &self.mode {
Mode::MoveIntoDone { buffer, .. } => buffer.clone(),
_ => unreachable!(),
};
let card = match self.board.get_selected_card().cloned() {
Some(c) => c,
None => {
self.mode = Mode::Board;
return Ok(Action::None);
}
};
let target_name = self.board.column_name(target_col).to_string();
let trimmed = buffer.trim();
let moved = mv::apply_guarded(
&self.store.config,
&self.store,
&card.filename,
&target_name,
if trimmed.is_empty() {
None
} else {
Some(trimmed)
},
None,
true,
)?;
self.rebuild_board();
self.board.select_column(target_col);
let ack = if moved.acknowledged {
", blocked move acknowledged"
} else {
""
};
self.status_msg = Some(match trimmed {
"" => format!(
"Moved #{} to {target_name} (no commit recorded{ack})",
moved.issue.number
),
_ => format!(
"Moved #{} to {target_name} (commit: {}{ack})",
moved.issue.number, trimmed
),
});
self.mode = Mode::Board;
Ok(Action::None)
}
KeyCode::Esc => {
self.mode = Mode::Board;
Ok(Action::None)
}
KeyCode::Char(c) => {
if let Mode::MoveIntoDone { ref mut buffer, .. } = self.mode {
buffer.push(c);
}
Ok(Action::None)
}
KeyCode::Backspace => {
if let Mode::MoveIntoDone { ref mut buffer, .. } = self.mode {
buffer.pop();
}
Ok(Action::None)
}
_ => Ok(Action::None),
}
}
fn handle_help_key(&mut self, key: KeyEvent) -> Result<Action> {
if self.help_searching {
match key.code {
KeyCode::Enter => self.help_searching = false,
KeyCode::Esc => {
self.help_query.clear();
self.help_searching = false;
}
KeyCode::Backspace => {
self.help_query.pop();
}
KeyCode::Char(c) => self.help_query.push(c),
_ => {}
}
self.help_scroll = 0;
return Ok(Action::None);
}
match key.code {
KeyCode::Char('?') | KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc => {
self.close_help();
}
KeyCode::Char('/') => self.help_searching = true,
KeyCode::Down | KeyCode::Char('j') => self.scroll_help(1),
KeyCode::Up | KeyCode::Char('k') => self.scroll_help(-1),
KeyCode::PageDown => self.scroll_help(i32::from(self.help_page())),
KeyCode::PageUp => self.scroll_help(-i32::from(self.help_page())),
_ => {}
}
Ok(Action::None)
}
fn help_page(&self) -> u16 {
self.help_metrics.get().visible.saturating_sub(1).max(1)
}
fn open_help(&mut self, return_to: Mode) {
self.help_scroll = 0;
self.help_query.clear();
self.help_searching = false;
self.mode = Mode::Help {
return_to: Box::new(return_to),
};
}
fn scroll_help(&mut self, rows: i32) {
let max = i32::from(self.help_metrics.get().max_scroll);
let next = i32::from(self.help_scroll) + rows;
self.help_scroll = next.clamp(0, max) as u16;
}
fn close_help(&mut self) {
if let Mode::Help { return_to } = std::mem::replace(&mut self.mode, Mode::Board) {
self.mode = *return_to;
}
}
fn handle_filter_key(&mut self, key: KeyEvent) -> Result<Action> {
match key.code {
KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc => {
self.filter = None;
self.board = BoardState::build(&self.store, self.active_board_config());
self.status_msg = None;
self.mode = Mode::Board;
}
KeyCode::Enter => {
self.mode = Mode::Board;
}
KeyCode::Char(c) => {
let mut next = match &self.mode {
Mode::Filter { buffer } => buffer.clone(),
_ => String::new(),
};
next.push(c);
self.mode = Mode::Filter {
buffer: next.clone(),
};
self.apply_filter(&next);
}
KeyCode::Backspace => {
let mut next = match &self.mode {
Mode::Filter { buffer } => buffer.clone(),
_ => String::new(),
};
next.pop();
self.mode = Mode::Filter {
buffer: next.clone(),
};
self.apply_filter(&next);
}
_ => {}
}
Ok(Action::None)
}
fn active_structured_filter(&self) -> &search::StructuredFilter {
self.filter_modal
.as_ref()
.map(|m| m.active_filter())
.unwrap_or(&self.structured)
}
fn open_filter_modal(&mut self) {
self.filter_modal = Some(crate::ui::filter_modal::FilterModal::with_filter(
self.structured.clone(),
));
self.mode = Mode::FilterModal;
}
fn handle_filter_modal_key(&mut self, key: KeyEvent) -> Result<Action> {
let Some(modal) = self.filter_modal.as_mut() else {
self.mode = Mode::Board;
return Ok(Action::None);
};
match key.code {
KeyCode::Esc => {
self.filter_modal = None;
self.mode = Mode::Board;
self.rebuild_board();
}
KeyCode::Tab | KeyCode::Down => modal.focus_next(),
KeyCode::BackTab | KeyCode::Up => modal.focus_prev(),
KeyCode::Char('r') | KeyCode::Char('R') => modal.clear(),
KeyCode::Enter => {
if modal.is_date_focus() && !modal.focused_date_is_complete() {
self.open_date_picker(Mode::FilterModal);
} else {
let mut filter = modal.active_filter().clone();
filter.normalize();
self.structured = filter;
self.filter_modal = None;
self.mode = Mode::Board;
self.rebuild_board();
}
}
KeyCode::Backspace => {
modal.backspace();
self.rebuild_board();
}
KeyCode::Char(c) => {
modal.type_char(c);
self.rebuild_board();
}
KeyCode::F(2) => {
if modal.is_date_focus() {
self.open_date_picker(Mode::FilterModal);
}
}
_ => {}
}
Ok(Action::None)
}
fn handle_comment_key(&mut self, key: KeyEvent) -> Result<Action> {
match key.code {
KeyCode::Esc => {
self.mode = Mode::Detail;
Ok(Action::None)
}
KeyCode::Enter => self.submit_comment(),
KeyCode::Char(c) => {
if let Mode::Comment { ref mut buffer } = self.mode {
buffer.push(c);
}
Ok(Action::None)
}
KeyCode::Backspace => {
if let Mode::Comment { ref mut buffer } = self.mode {
buffer.pop();
}
Ok(Action::None)
}
_ => Ok(Action::None),
}
}
fn move_selection_by_page(&mut self, direction: i32) {
let page = self.board_page.get();
if page == 0 {
return;
}
let step = i32::try_from(page).unwrap_or(i32::MAX);
self.board.move_selection_by(step * direction);
}
pub fn open_new_issue_form(&mut self) {
let default_status = self
.store
.config
.default_config()
.columns
.first()
.cloned()
.unwrap_or_else(|| "todo".to_string());
let default_assignee = crate::assignee::git_identity()
.map(|id| id.to_string())
.unwrap_or_default();
self.form = Some(FormState::with_default_assignee(
&default_status,
&default_assignee,
));
self.mode = Mode::Form;
}
fn open_settings(&mut self, prompt: Option<SettingsPrompt>) {
self.settings = Some(SettingsModal {
selected: 0,
prompt,
});
self.mode = Mode::Settings;
}
fn handle_settings_key(&mut self, key: KeyEvent) -> Result<Action> {
if self.settings.is_none() {
self.mode = Mode::Board;
return Ok(Action::None);
}
let prompt_active = self.settings.as_ref().and_then(|m| m.prompt.clone());
let Some(p) = prompt_active else {
return self.handle_settings_browse(key);
};
match key.code {
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => {
if let Some(ref mut m) = self.settings {
m.prompt = None;
}
Ok(Action::None)
}
KeyCode::Enter | KeyCode::Char('y') | KeyCode::Char('Y') => {
match p {
SettingsPrompt::AddColumn(name) => self.settings_commit_add_column(name),
SettingsPrompt::NewBoard(name) => self.settings_commit_new_board(name),
SettingsPrompt::ConfirmDeleteBoard(name) => {
self.settings_commit_delete_board(name)
}
}
Ok(Action::None)
}
KeyCode::Char(c) => {
if let Some(ref mut m) = self.settings
&& let Some(SettingsPrompt::AddColumn(ref mut b))
| Some(SettingsPrompt::NewBoard(ref mut b)) = m.prompt
{
b.push(c);
}
Ok(Action::None)
}
KeyCode::Backspace => {
if let Some(ref mut m) = self.settings
&& let Some(SettingsPrompt::AddColumn(ref mut b))
| Some(SettingsPrompt::NewBoard(ref mut b)) = m.prompt
{
b.pop();
}
Ok(Action::None)
}
_ => Ok(Action::None),
}
}
fn handle_settings_browse(&mut self, key: KeyEvent) -> Result<Action> {
match key.code {
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => {
self.settings = None;
self.mode = Mode::Board;
self.rebuild_board();
Ok(Action::None)
}
KeyCode::Up | KeyCode::Char('k') => {
let count =
self.active_board_config().columns.len() + crate::ui::settings::DISPLAY_ROWS;
if let Some(ref mut m) = self.settings {
m.move_selected(-1, count);
}
Ok(Action::None)
}
KeyCode::Down | KeyCode::Char('j') => {
let count =
self.active_board_config().columns.len() + crate::ui::settings::DISPLAY_ROWS;
if let Some(ref mut m) = self.settings {
m.move_selected(1, count);
}
Ok(Action::None)
}
KeyCode::Tab => {
self.settings_next_board();
Ok(Action::None)
}
KeyCode::BackTab => {
self.settings_prev_board();
Ok(Action::None)
}
KeyCode::Char(' ') | KeyCode::Enter => self.settings_toggle_selected(),
KeyCode::Char('a') => {
if let Some(ref mut m) = self.settings {
m.prompt = Some(SettingsPrompt::AddColumn(String::new()));
}
Ok(Action::None)
}
KeyCode::Char('n') => {
if let Some(ref mut m) = self.settings {
m.prompt = Some(SettingsPrompt::NewBoard(String::new()));
}
Ok(Action::None)
}
KeyCode::Char('x') | KeyCode::Char('X') => {
if self.store.config.configs().count() <= 1 {
self.status_msg = Some("Cannot delete the last board".to_string());
return Ok(Action::None);
}
let name = self.active_board_name().to_string();
if let Some(ref mut m) = self.settings {
m.prompt = Some(SettingsPrompt::ConfirmDeleteBoard(name));
}
Ok(Action::None)
}
_ => Ok(Action::None),
}
}
fn settings_next_board(&mut self) {
self.tabs.next();
self.active_config = self.tabs.selected;
self.switch_config();
if let Some(ref mut m) = self.settings {
m.selected = 0;
}
}
fn settings_prev_board(&mut self) {
self.tabs.previous();
self.active_config = self.tabs.selected;
self.switch_config();
if let Some(ref mut m) = self.settings {
m.selected = 0;
}
}
fn open_planning_modal(&mut self) {
let modal = self.planning.get_or_insert_with(PlanningModal::new);
modal.prompt = None;
modal.selected = 0;
self.mode = Mode::PlanningManagement;
}
fn planning_rows(&self) -> (Vec<PlanningRow>, Vec<PlanningRow>) {
let root = self.project_root();
let planner = Planner::from_config(self.store.config.clone(), root);
let issues = self.store.list().unwrap_or_default();
let mut cycles = Vec::new();
for name in planner.cycle_names() {
let cycle = planner.cycle(&name);
let issues = issues
.iter()
.filter(|issue| {
issue
.cycles
.as_ref()
.is_some_and(|cs| cs.iter().any(|c| c == &name))
})
.count();
cycles.push(PlanningRow {
name,
description: cycle
.and_then(|c| c.description.clone())
.unwrap_or_default(),
date_a: cycle.and_then(|c| c.start_date.clone()).unwrap_or_default(),
date_b: Some(cycle.and_then(|c| c.due_date.clone()).unwrap_or_default()),
issues,
});
}
let mut milestones = Vec::new();
if let Ok(list) = planner.list_milestones() {
for ms in list {
let issues = issues
.iter()
.filter(|issue| {
issue
.milestones
.as_ref()
.is_some_and(|ml| ml.iter().any(|x| x == &ms.name))
})
.count();
milestones.push(PlanningRow {
name: ms.name,
description: ms.description,
date_a: ms.target_date,
date_b: None,
issues,
});
}
}
(cycles, milestones)
}
fn planning_rows_len(&self) -> usize {
let (cycles, milestones) = self.planning_rows();
match self.planning.as_ref().map(|m| m.segment) {
Some(PlanningSegment::Cycles) => cycles.len(),
Some(PlanningSegment::Milestones) => milestones.len(),
None => 0,
}
}
fn handle_planning_key(&mut self, key: KeyEvent) -> Result<Action> {
if self.planning.is_none() {
self.mode = Mode::Board;
return Ok(Action::None);
}
if let Some(prompt) = self.planning.as_ref().and_then(|m| m.prompt.clone()) {
return self.handle_planning_confirm_key(key, prompt);
}
match key.code {
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => {
self.planning = None;
self.mode = Mode::Board;
Ok(Action::None)
}
KeyCode::Up | KeyCode::Char('k') => {
let len = self.planning_rows_len();
if let Some(ref mut m) = self.planning {
m.move_selected(-1, len);
}
Ok(Action::None)
}
KeyCode::Down | KeyCode::Char('j') => {
let len = self.planning_rows_len();
if let Some(ref mut m) = self.planning {
m.move_selected(1, len);
}
Ok(Action::None)
}
KeyCode::Tab | KeyCode::BackTab => {
if let Some(ref mut m) = self.planning {
m.switch_segment();
}
Ok(Action::None)
}
KeyCode::Char('n') => {
self.planning_open_create();
Ok(Action::None)
}
KeyCode::Char('e') => self.planning_open_edit(),
KeyCode::Char('x') | KeyCode::Char('X') => self.planning_confirm_delete(),
_ => Ok(Action::None),
}
}
fn handle_planning_confirm_key(
&mut self,
key: KeyEvent,
prompt: PlanningPrompt,
) -> Result<Action> {
let PlanningPrompt::ConfirmDelete { name, .. } = prompt;
match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
match self.planning_delete(&name) {
Ok(()) => {
self.status_msg = Some(format!("Deleted {name}"));
}
Err(e) => {
self.status_msg = Some(format!("Cannot delete {name}: {e}"));
}
}
if let Some(ref mut m) = self.planning {
m.prompt = None;
}
Ok(Action::None)
}
_ => {
if let Some(ref mut m) = self.planning {
m.prompt = None;
}
Ok(Action::None)
}
}
}
fn planning_open_create(&mut self) {
let kind = match self.planning.as_ref().map(|m| m.segment) {
Some(PlanningSegment::Milestones) => PlanningKind::Milestone,
_ => PlanningKind::Cycle,
};
self.planning_form = Some(PlanningForm::new(kind));
self.mode = Mode::PlanningForm;
}
fn planning_open_edit(&mut self) -> Result<Action> {
let Some(modal) = self.planning.as_ref() else {
return Ok(Action::None);
};
let root = self.project_root();
let planner = Planner::from_config(self.store.config.clone(), root);
let form = match modal.segment {
PlanningSegment::Cycles => {
let name = match planner.cycle_names().into_iter().nth(modal.selected) {
Some(name) => name,
None => return Ok(Action::None),
};
let Some(cycle) = planner.cycle(&name) else {
return Ok(Action::None);
};
PlanningForm::from_cycle(&name, cycle)
}
PlanningSegment::Milestones => {
let list = planner.list_milestones().unwrap_or_default();
let Some(ms) = list.into_iter().nth(modal.selected) else {
return Ok(Action::None);
};
PlanningForm::from_milestone(&ms)
}
};
self.planning_form = Some(form);
self.mode = Mode::PlanningForm;
Ok(Action::None)
}
fn planning_confirm_delete(&mut self) -> Result<Action> {
let Some(modal) = self.planning.as_ref() else {
return Ok(Action::None);
};
if modal.prompt.is_some() {
return Ok(Action::None);
}
let root = self.project_root();
let planner = Planner::from_config(self.store.config.clone(), root);
let (name, referencing) = match modal.segment {
PlanningSegment::Cycles => {
let Some(name) = planner.cycle_names().into_iter().nth(modal.selected) else {
return Ok(Action::None);
};
let referencing = planner
.delete_impact(&self.store, &name)
.map(|i| i.referencing_issues.len())
.unwrap_or(0);
(name, referencing)
}
PlanningSegment::Milestones => {
let list = planner.list_milestones().unwrap_or_default();
let Some(name) = list.into_iter().nth(modal.selected).map(|m| m.name) else {
return Ok(Action::None);
};
let referencing = planner
.milestone_delete_impact(&self.store, &name)
.map(|i| i.referencing_issues.len())
.unwrap_or(0);
(name, referencing)
}
};
if let Some(ref mut m) = self.planning {
m.prompt = Some(PlanningPrompt::ConfirmDelete { name, referencing });
}
Ok(Action::None)
}
fn planning_delete(&mut self, name: &str) -> Result<()> {
let root = self.project_root();
let mut planner = Planner::from_config(self.store.config.clone(), root.clone());
match self.planning.as_ref().map(|m| m.segment) {
Some(PlanningSegment::Cycles) => {
planner.delete_cycle(name)?;
self.reload_config_from(&root)?;
}
Some(PlanningSegment::Milestones) => {
planner.delete_milestone(name)?;
}
None => {}
}
self.refresh_corpus()?;
Ok(())
}
fn handle_planning_form_key(&mut self, key: KeyEvent) -> Result<Action> {
if self.planning_form.is_none() {
self.mode = Mode::PlanningManagement;
return Ok(Action::None);
}
match key.code {
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => {
self.planning_form = None;
self.mode = Mode::PlanningManagement;
Ok(Action::None)
}
_ => self.handle_planning_form_edit_key(key),
}
}
fn handle_planning_form_edit_key(&mut self, key: KeyEvent) -> Result<Action> {
if self.planning_form.is_none() {
return Ok(Action::None);
}
match key.code {
KeyCode::Enter => {
if self
.planning_form
.as_ref()
.is_some_and(|f| f.focused_is_date() && !f.focused_date_is_complete())
{
self.open_date_picker(Mode::PlanningForm);
Ok(Action::None)
} else {
self.planning_form_apply()
}
}
KeyCode::Tab | KeyCode::Down => {
if let Some(ref mut f) = self.planning_form {
f.focus_next();
}
Ok(Action::None)
}
KeyCode::BackTab | KeyCode::Up => {
if let Some(ref mut f) = self.planning_form {
f.focus_prev();
}
Ok(Action::None)
}
KeyCode::Backspace => {
if let Some(ref mut f) = self.planning_form {
f.delete_char();
}
Ok(Action::None)
}
KeyCode::Char(c) => {
if let Some(ref mut f) = self.planning_form {
f.insert_char(c);
}
Ok(Action::None)
}
KeyCode::F(2) => {
if self
.planning_form
.as_ref()
.is_some_and(|f| f.focused_is_date())
{
self.open_date_picker(Mode::PlanningForm);
}
Ok(Action::None)
}
_ => Ok(Action::None),
}
}
fn planning_form_apply(&mut self) -> Result<Action> {
let Some(form) = self.planning_form.clone() else {
self.mode = Mode::PlanningManagement;
return Ok(Action::None);
};
let result = self.planning_form_commit(&form);
match result {
Ok(()) => {
self.planning_form = None;
self.mode = Mode::PlanningManagement;
Ok(Action::None)
}
Err(e) => {
self.status_msg = Some(format!("Cannot save {}: {e}", form.kind.noun()));
Ok(Action::None)
}
}
}
fn planning_form_commit(&mut self, form: &PlanningForm) -> Result<()> {
let root = self.project_root();
let mut planner = Planner::from_config(self.store.config.clone(), root.clone());
let name = form.name().trim().to_string();
if name.is_empty() {
return Err(TixError::InvalidInput("a name is required".to_string()));
}
match form.kind {
PlanningKind::Cycle => {
if let Some(old) = form.editing.as_deref() {
if old != name {
planner.rename_cycle_cascade(&self.store, old, &name)?;
}
planner.set_cycle_fields(
&name,
Some(form.description()),
Some(form.date_a()),
Some(form.date_b()),
)?;
} else {
planner.add_cycle(Some(name.clone()))?;
planner.set_cycle_fields(
&name,
Some(form.description()),
Some(form.date_a()),
Some(form.date_b()),
)?;
}
self.reload_config_from(&root)?;
}
PlanningKind::Milestone => {
let target = if form.date_a().trim().is_empty() {
None
} else {
Some(form.date_a())
};
if let Some(old) = form.editing.as_deref() {
if old != name {
let old_milestone = planner.milestone(old)?;
planner.rename_milestone_cascade(&self.store, &old_milestone, &name)?;
}
planner.update_milestone(&name, target, Some(form.description()))?;
} else {
planner.create_milestone_file(&name, target, Some(form.description()))?;
}
}
}
self.refresh_corpus()?;
Ok(())
}
fn settings_toggle_selected(&mut self) -> Result<Action> {
let col_count = self.active_board_config().columns.len();
let selected = self.settings.as_ref().map(|m| m.selected).unwrap_or(0);
if selected >= col_count {
return self.settings_toggle_display_row(selected - col_count);
}
let col = self
.settings
.as_ref()
.and_then(|m| self.active_board_config().columns.get(m.selected))
.cloned();
let Some(col) = col else {
return Ok(Action::None);
};
let board_name = self.active_board_name().to_string();
let now_hidden = {
let board_config = self.store.config.kanban.configurations.get_mut(&board_name);
match board_config {
Some(bc) => {
if let Some(pos) = bc.hidden_columns.iter().position(|h| h == &col) {
bc.hidden_columns.remove(pos);
false
} else {
bc.hidden_columns.push(col.clone());
true
}
}
None => return Ok(Action::None),
}
};
if !self.persist_config() {
return Ok(Action::None);
}
self.rebuild_board();
let last = self.active_board_config().columns.len().saturating_sub(1);
if let Some(ref mut m) = self.settings
&& m.selected > last
{
m.selected = last;
}
self.status_msg = Some(match now_hidden {
true => format!("'{col}' hidden"),
false => format!("'{col}' shown"),
});
Ok(Action::None)
}
fn settings_toggle_display_row(&mut self, row: usize) -> Result<Action> {
let board_name = self.active_board_name().to_string();
let action_msg = {
let board_config = self.store.config.kanban.configurations.get_mut(&board_name);
let Some(board_config) = board_config else {
return Ok(Action::None);
};
match row {
0 => {
board_config.wrap_titles = !board_config.wrap_titles;
if board_config.wrap_titles {
"Wrap titles: on".to_string()
} else {
"Wrap titles: off".to_string()
}
}
1 => {
board_config.title_lines = board_config.title_lines % 5 + 1;
format!("Title lines: {}", board_config.title_lines)
}
2 => {
board_config.order_by = board_config.order_by.next();
format!("Order by: {}", board_config.order_by.as_str())
}
_ => return Ok(Action::None),
}
};
if !self.persist_config() {
return Ok(Action::None);
}
self.rebuild_board();
self.status_msg = Some(action_msg);
Ok(Action::None)
}
fn settings_commit_add_column(&mut self, name: String) {
let name = name.trim().to_string();
if name.is_empty() {
self.status_msg = Some("Column name is required".to_string());
return;
}
let board_name = self.active_board_name().to_string();
let board_config = self.store.config.kanban.configurations.get_mut(&board_name);
let Some(board_config) = board_config else {
self.status_msg = Some("board not found".to_string());
return;
};
if board_config.columns.iter().any(|c| c == &name) {
self.status_msg = Some(format!("Column '{name}' already exists"));
return;
}
board_config.columns.push(name.clone());
if !self.persist_config() {
return;
}
self.rebuild_board();
let last = self.active_board_config().columns.len().saturating_sub(1);
if let Some(ref mut m) = self.settings {
m.prompt = None;
m.selected = last;
}
self.status_msg = Some(format!("Added column '{name}'"));
}
fn settings_commit_new_board(&mut self, name: String) {
let name = name.trim().to_string();
if name.is_empty() {
self.status_msg = Some("Board name is required".to_string());
return;
}
if self.store.config.kanban.configurations.contains_key(&name) {
self.status_msg = Some(format!("Board '{name}' already exists"));
return;
}
self.store
.config
.kanban
.configurations
.insert(name.clone(), crate::config::BoardConfiguration::default());
if !self.persist_config() {
return;
}
self.reload_tabs();
let idx = self
.store
.config
.configs()
.position(|(n, _)| n == name.as_str())
.unwrap_or(0);
self.active_config = idx;
self.tabs.selected = idx;
self.switch_config();
if let Some(ref mut m) = self.settings {
m.prompt = None;
m.selected = 0;
}
self.status_msg = Some(format!("Added board '{name}'"));
}
fn settings_commit_delete_board(&mut self, name: String) {
if self.store.config.kanban.configurations.len() <= 1 {
self.status_msg = Some("Cannot delete the last board".to_string());
return;
}
let idx = self.active_config;
let removed = self.store.config.kanban.configurations.shift_remove(&name);
if removed.is_none() {
self.status_msg = Some(format!("Board '{name}' not found"));
return;
}
if !self.persist_config() {
return;
}
self.reload_tabs();
let count = self.store.config.kanban.configurations.len();
let target = idx.min(count.saturating_sub(1));
self.active_config = target;
self.tabs.selected = target;
self.switch_config();
if let Some(ref mut m) = self.settings {
m.prompt = None;
m.selected = 0;
}
self.status_msg = Some(format!("Deleted board '{name}'"));
}
fn persist_config(&mut self) -> bool {
match self.store.config_path() {
Ok(path) => match self.store.config.save(&path) {
Ok(()) => true,
Err(e) => {
self.status_msg = Some(format!("settings save: {e}"));
false
}
},
Err(e) => {
self.status_msg = Some(format!("settings path: {e}"));
false
}
}
}
fn reload_tabs(&mut self) {
let names: Vec<String> = self
.store
.config
.configs()
.map(|(name, _)| name.to_string())
.collect();
self.tabs = BoardTabs::new(names);
}
fn active_board_name(&self) -> &str {
self.store
.config
.configs()
.nth(self.active_config)
.map(|(name, _)| name)
.unwrap_or("default")
}
fn submit_comment(&mut self) -> Result<Action> {
let buffer = match &self.mode {
Mode::Comment { buffer } => buffer.clone(),
_ => return Ok(Action::None),
};
let text = buffer.trim().to_string();
if text.is_empty() {
self.status_msg = Some("Comment discarded (empty)".to_string());
self.mode = Mode::Detail;
return Ok(Action::None);
}
let filename = match self.detail.as_ref() {
Some(d) => d.issue.filename.clone(),
None => {
self.mode = Mode::Detail;
return Ok(Action::None);
}
};
let author = crate::assignee::git_identity()
.map(|id| id.to_string())
.unwrap_or_else(|| "anonymous".to_string());
let mut issue = self.store.get(IssueSelector::Filename(filename.clone()))?;
issue.add_comment(&author, &text);
self.store.update(&filename, &issue)?;
if let Some(ref mut d) = self.detail {
d.issue = issue;
d.scroll_offset = 0;
}
self.refresh_corpus()?;
self.status_msg = Some(format!("Commented by {author}"));
self.mode = Mode::Detail;
Ok(Action::None)
}
fn selected_relationship(&self) -> Option<&crate::relationships::Relationship> {
self.detail
.as_ref()
.and_then(|d| d.relationships.get(d.rel_cursor))
}
fn selected_relationship_id(&self) -> Option<String> {
self.selected_relationship().map(|r| r.id.clone())
}
fn move_rel_cursor(&mut self, delta: isize) {
let Some(d) = self.detail.as_mut() else {
return;
};
if d.relationships.is_empty() {
return;
}
let last = d.relationships.len() as isize - 1;
let next = (d.rel_cursor as isize + delta).clamp(0, last);
d.rel_cursor = next as usize;
}
fn handle_link_prompt_key(&mut self, key: KeyEvent) -> Result<Action> {
match key.code {
KeyCode::Esc => {
self.mode = Mode::Detail;
self.status_msg = Some("relationship: cancelled".to_string());
Ok(Action::None)
}
KeyCode::Enter => {
let prompt = match self.mode.clone() {
Mode::LinkCreate { buffer, step, .. } => Some((buffer, step)),
Mode::LinkEdit { buffer, step, .. } => Some((buffer, step)),
_ => None,
};
match prompt {
Some((buffer, step)) => self.submit_link_prompt(buffer, step),
None => Ok(Action::None),
}
}
KeyCode::Backspace => {
if let Mode::LinkCreate { ref mut buffer, .. } = self.mode {
buffer.pop();
} else if let Mode::LinkEdit { ref mut buffer, .. } = self.mode {
buffer.pop();
}
Ok(Action::None)
}
KeyCode::Char(c) => {
if let Mode::LinkCreate { ref mut buffer, .. } = self.mode {
buffer.push(c);
} else if let Mode::LinkEdit { ref mut buffer, .. } = self.mode {
buffer.push(c);
}
Ok(Action::None)
}
_ => Ok(Action::None),
}
}
fn submit_link_prompt(&mut self, buffer: String, step: LinkStep) -> Result<Action> {
let trimmed = buffer.trim().to_string();
let subject = self.detail.as_ref().map(|d| d.issue.number).unwrap_or(0);
let (is_edit, index) = match &self.mode {
Mode::LinkEdit { index, .. } => (true, *index),
_ => (false, 0),
};
match step {
LinkStep::Target => {
if trimmed.is_empty() {
self.status_msg = Some("relationship: cancelled (no issue given)".into());
self.mode = Mode::Detail;
return Ok(Action::None);
}
let selector = match crate::commands::parse_selector(&self.store, &trimmed) {
Ok(selector) => selector,
Err(e) => {
self.status_msg = Some(e.to_string());
return Ok(Action::None);
}
};
let target = match self.store.get(selector) {
Ok(issue) => issue.number,
Err(e) => {
self.status_msg = Some(e.to_string());
return Ok(Action::None);
}
};
if target == subject {
self.status_msg =
Some("relationship: an issue cannot be related to itself".into());
return Ok(Action::None);
}
self.mode = if is_edit {
Mode::LinkEdit {
index,
buffer: String::new(),
step: LinkStep::Kind,
target: Some(target),
kind: None,
}
} else {
Mode::LinkCreate {
buffer: String::new(),
step: LinkStep::Kind,
target: Some(target),
}
};
Ok(Action::None)
}
LinkStep::Kind => {
let kind = match crate::commands::link::parse_kind(&trimmed) {
Ok(kind) => kind,
Err(e) => {
self.status_msg = Some(e.to_string());
return Ok(Action::None);
}
};
if is_edit {
self.apply_relationship_edit(kind)
} else {
self.apply_relationship_create(subject, kind)
}
}
}
}
fn apply_relationship_create(
&mut self,
subject: u32,
kind: crate::relationships::RelationKind,
) -> Result<Action> {
let Some(target) = (match &self.mode {
Mode::LinkCreate { target, .. } => *target,
_ => None,
}) else {
self.status_msg = Some("relationship: no target resolved".into());
self.mode = Mode::Detail;
return Ok(Action::None);
};
match self
.store
.link(&subject.to_string(), &target.to_string(), kind)
{
Ok(outcome) => {
self.status_msg = Some(crate::commands::link::link_message(&outcome));
self.mode = Mode::Detail;
self.refresh_detail()?;
}
Err(e) => {
self.status_msg = Some(e.to_string());
self.mode = Mode::Detail;
}
}
Ok(Action::None)
}
fn apply_relationship_edit(
&mut self,
kind: crate::relationships::RelationKind,
) -> Result<Action> {
let (id, to) = match &self.mode {
Mode::LinkEdit { index, target, .. } => (
self.detail
.as_ref()
.and_then(|d| d.relationships.get(*index))
.map(|r| r.id.clone()),
*target,
),
_ => (None, None),
};
let Some(id) = id else {
self.status_msg = Some("relationship: nothing selected to edit".into());
self.mode = Mode::Detail;
return Ok(Action::None);
};
match self.store.edit_relation(&id, to, Some(kind)) {
Ok(outcome) => {
self.status_msg = Some(crate::commands::link::edit_message(&outcome));
self.mode = Mode::Detail;
self.refresh_detail()?;
}
Err(e) => {
self.status_msg = Some(e.to_string());
self.mode = Mode::Detail;
}
}
Ok(Action::None)
}
fn handle_remove_relationship_key(&mut self, key: KeyEvent) -> Result<Action> {
match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => {
let index = match &self.mode {
Mode::ConfirmRemoveRelationship { index } => *index,
_ => return Ok(Action::None),
};
let id = match self
.detail
.as_ref()
.and_then(|d| d.relationships.get(index))
{
Some(rel) => rel.id.clone(),
None => {
self.mode = Mode::Detail;
return Ok(Action::None);
}
};
match self.store.remove_relation(&id) {
Ok(outcome) => {
self.status_msg = Some(crate::commands::link::remove_message(&outcome));
self.mode = Mode::Detail;
self.refresh_detail()?;
}
Err(e) => {
self.status_msg = Some(e.to_string());
self.mode = Mode::Detail;
}
}
Ok(Action::None)
}
_ => {
self.mode = Mode::Detail;
self.status_msg = Some("relationship: removal cancelled".to_string());
Ok(Action::None)
}
}
}
fn handle_asset_key(&mut self, key: KeyEvent) -> Result<Action> {
match key.code {
KeyCode::Esc => {
self.mode = Mode::Detail;
Ok(Action::None)
}
KeyCode::Enter => self.submit_asset(),
KeyCode::Char(c) => {
if let Mode::Asset { ref mut buffer } = self.mode {
buffer.push(c);
}
Ok(Action::None)
}
KeyCode::Backspace => {
if let Mode::Asset { ref mut buffer } = self.mode {
buffer.pop();
}
Ok(Action::None)
}
_ => Ok(Action::None),
}
}
fn submit_asset(&mut self) -> Result<Action> {
let buffer = match &self.mode {
Mode::Asset { buffer } => buffer.clone(),
_ => return Ok(Action::None),
};
let path = std::path::PathBuf::from(buffer.trim());
let filename = match self.detail.as_ref() {
Some(d) => d.issue.filename.clone(),
None => {
self.mode = Mode::Detail;
return Ok(Action::None);
}
};
match self.store.add_asset(&filename, &path) {
Ok(added) => {
if let Ok(issue) = self.store.get(IssueSelector::Filename(filename))
&& let Some(ref mut d) = self.detail
{
d.issue = issue;
d.scroll_offset = 0;
}
self.status_msg = Some(format!("Asset added: {}", added.link));
self.refresh_corpus()?;
self.mode = Mode::Detail;
Ok(Action::None)
}
Err(e) => {
self.status_msg = Some(format!("asset: {e}"));
Ok(Action::None)
}
}
}
fn toggle_column_visibility(&mut self) {
let col_name = self.board.active_column_name().to_string();
let config_name = self.tabs.names[self.active_config].clone();
let board_config = self
.store
.config
.kanban
.configurations
.get_mut(&config_name);
let board_config = match board_config {
Some(bc) => bc,
None => {
self.status_msg = Some("toggle: config not found".into());
return;
}
};
let now_hidden = if let Some(pos) = board_config
.hidden_columns
.iter()
.position(|h| h == &col_name)
{
board_config.hidden_columns.remove(pos);
false
} else {
board_config.hidden_columns.push(col_name.clone());
true
};
match self.store.config_path() {
Ok(path) => {
if let Err(e) = self.store.config.save(&path) {
self.status_msg = Some(format!("toggle save: {e}"));
return;
}
}
Err(e) => {
self.status_msg = Some(format!("toggle path: {e}"));
return;
}
}
self.rebuild_board();
let visible = self.board.columns.len();
self.board.active_col = if now_hidden {
visible
+ self
.board
.hidden
.iter()
.position(|c| c.name == col_name)
.unwrap_or_default()
} else {
self.board
.columns
.iter()
.position(|c| c.name == col_name)
.unwrap_or_default()
};
self.board.clamp_selection();
self.status_msg = Some(format!("Toggled '{col_name}' visibility"));
}
fn switch_board(&mut self, index: usize) {
if index < self.tabs.len() {
self.tabs.selected = index;
self.tabs.hovered = None;
self.active_config = index;
self.switch_config();
}
}
fn switch_config(&mut self) {
let selected_filename = self.board.get_selected_card().map(|c| c.filename.clone());
self.rebuild_board();
self.board.active_col = 0;
if let Some(filename) = selected_filename
&& self
.board
.column_cards(0)
.iter()
.any(|c| c.filename == filename)
{
self.board.selected = self
.board
.column_cards(0)
.iter()
.position(|c| c.filename == filename)
.unwrap_or(0);
} else {
self.board.selected = 0;
}
self.board.clamp_selection();
self.status_msg = Some("Switched board".to_string());
}
fn rebuild_board(&mut self) {
let active_col = self.board.active_col;
let selected = self.board.selected;
let board_config = self.active_board_config().clone();
let active_filter = self.active_structured_filter().clone();
let q = self.filter.clone().unwrap_or_default();
if !q.is_empty() || active_filter.has_active_criteria() {
self.board = BoardState::build_filtered(
&board_config,
&self.store.config,
&self.corpus,
&q,
&active_filter,
&self.workflow_resolution(),
);
} else {
self.board =
BoardState::build_with_archived(&self.store, &board_config, self.show_archived);
}
self.board.active_col = active_col;
self.board.selected = selected;
self.board.clamp_selection();
}
fn select_card_by_filename(&mut self, filename: &str) {
let mut columns: Vec<&[crate::ui::board::Card]> = self
.board
.columns
.iter()
.map(|c| c.cards.as_slice())
.collect();
if self.board.show_mismatch {
columns.push(self.board.mismatch.cards.as_slice());
}
if let Some((col_idx, card_idx)) =
columns.iter().enumerate().find_map(|(col_idx, cards)| {
cards
.iter()
.position(|card| card.filename == filename)
.map(|card_idx| (col_idx, card_idx))
})
{
self.board.active_col = col_idx;
self.board.selected = card_idx;
}
}
fn apply_filter(&mut self, buffer: &str) {
if buffer.trim().is_empty() {
self.filter = None;
} else {
self.filter = Some(buffer.trim().to_string());
}
let board_config = self.active_board_config().clone();
let active_filter = self.active_structured_filter().clone();
let q = self.filter.clone().unwrap_or_default();
if !q.is_empty() || active_filter.has_active_criteria() {
self.board = BoardState::build_filtered(
&board_config,
&self.store.config,
&self.corpus,
&q,
&active_filter,
&self.workflow_resolution(),
);
self.status_msg = if self.board.is_empty() {
Some("No matches for current filter".to_string())
} else {
None
};
} else {
self.board = BoardState::build(&self.store, &board_config);
self.status_msg = None;
}
}
fn refresh_detail(&mut self) -> Result<()> {
let Some(detail) = self.detail.as_ref() else {
return Ok(());
};
let number = detail.issue.number;
let filename = detail.issue.filename.clone();
let Some(mut next) = self.store.get(IssueSelector::Filename(filename)).ok() else {
return Ok(());
};
let relationships = self.store.relationships_for(number)?;
let (all, done_column) = self.store.graph_source()?;
let graph = commands::graph_over(&all, &done_column);
let cursor = detail.rel_cursor;
if let Some(d) = self.detail.as_mut() {
d.issue = std::mem::replace(&mut next, d.issue.clone());
d.relationships = relationships;
d.chain = graph.dependency_chain(number);
d.blocked = commands::blocked_note(&graph.blocked_state(number));
d.rel_cursor = cursor;
d.clamp_rel_cursor();
}
self.refresh_corpus()
}
fn refresh_corpus(&mut self) -> Result<()> {
let c = Corpus::build_with(&self.store, self.show_archived)?;
self.corpus = c;
self.rebuild_board();
Ok(())
}
pub fn refresh_from_disk(&mut self) -> Result<()> {
self.refresh_corpus()
}
pub fn after_editor(&mut self, filename: &str) {
match self
.store
.get(IssueSelector::Filename(filename.to_string()))
{
Ok(mut issue) => {
issue.bump_updated();
let location = match issue.assignee {
Some(ref a) => match self.store.reassign(filename, Some(a)) {
Ok(moved) => moved.to_string_lossy().to_string(),
Err(e) => {
self.status_msg = Some(format!("editor: {e}"));
return;
}
},
None => filename.to_string(),
};
if let Err(e) = self.store.update(&location, &issue) {
self.status_msg = Some(format!("editor: {e}"));
} else {
if let Some(ref mut d) = self.detail
&& (d.issue.filename == filename || d.issue.filename == location)
{
d.issue = issue;
d.scroll_offset = 0;
}
let _ = self.refresh_corpus();
if let Err(e) = self.warn_if_linted_issue_invalid(&location) {
self.status_msg = Some(format!("editor: {e}"));
} else {
self.status_msg = Some("Edited".to_string());
}
}
}
Err(e) => {
self.status_msg = Some(format!("editor: {e}"));
}
}
}
fn warn_if_linted_issue_invalid(&mut self, filename: &str) -> Result<()> {
let findings =
crate::commands::lint::collect_file(&self.store.config, &self.store, filename)?;
if let Some(finding) = findings.into_iter().next() {
let message = format!("{}: {}", finding.kind, finding.message);
self.mode = Mode::EditLintWarning {
filename: filename.to_string(),
message,
};
}
Ok(())
}
fn handle_edit_lint_warning_key(&mut self, key: KeyEvent) -> Result<Action> {
let (filename, _) = match &self.mode {
Mode::EditLintWarning { filename, message } => (filename.clone(), message.clone()),
_ => return Ok(Action::None),
};
match key.code {
KeyCode::Char('r') | KeyCode::Char('R') => {
self.mode = Mode::Board;
Ok(Action::OpenEditor(filename))
}
KeyCode::Char('i') | KeyCode::Char('I') | KeyCode::Enter | KeyCode::Esc => {
self.mode = Mode::Board;
Ok(Action::None)
}
_ => Ok(Action::None),
}
}
fn handle_rollover_dialog_key(&mut self, key: KeyEvent) -> Result<Action> {
let Some(dialog) = self.rollover_dialog.as_mut() else {
self.mode = Mode::Board;
return Ok(Action::None);
};
match dialog.handle_key(key.code) {
DialogEvent::ConfirmDo => {
crate::commands::rollover::run(&self.store.config, &self.store, None, true)?;
self.rebuild_board();
self.status_msg = Some("Rollover applied".to_string());
self.rollover_dialog = None;
self.mode = Mode::Board;
}
DialogEvent::ConfirmIgnore | DialogEvent::Dismiss => {
self.rollover_dialog = None;
self.mode = Mode::Board;
}
DialogEvent::None => {}
}
Ok(Action::None)
}
}
const HINT_HEIGHT: u16 = 1;
const TAB_HEIGHT: u16 = 2;
const FILTER_HEIGHT: u16 = 1;
const HOVER_HEIGHT: u16 = 1;
const RENDER_FALLBACK_WIDTH: u16 = 80;
const RENDER_FALLBACK_HEIGHT: u16 = 24;
const RENDER_BOTTOM_MARGIN: u16 = 4;
pub fn render(app: &App, f: &mut ratatui::Frame, area: Rect) {
app.frame_size.set(area);
app.frame_hit_regions.borrow_mut().clear();
match &app.mode {
Mode::Board => render_board(app, f, area, commandbar::MODE_BOARD),
Mode::BulkModal => {
render_board(app, f, area, commandbar::MODE_BOARD);
if let Some(modal) = app.bulk.as_ref() {
crate::ui::bulk::render(f, area, modal);
}
}
Mode::TriageInbox => {
render_board(app, f, area, commandbar::MODE_BOARD);
if let Some(state) = app.triage.as_ref() {
crate::ui::triage::render(f, area, state);
}
}
Mode::RolloverDialog => {
render_board(app, f, area, commandbar::MODE_BOARD);
if let Some(dialog) = app.rollover_dialog.as_ref() {
let rect = crate::ui::modal::modal_rect(area);
f.render_widget(Clear, rect);
let mut lines = Vec::new();
lines.push(Line::from(Span::styled(
"Rollover needed",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)));
for (from, to, count) in &dialog.plans {
lines.push(Line::from(format!(
"{count} issue(s): \"{from}\" -> \"{to}\""
)));
}
lines.push(Line::from(""));
lines.push(Line::from(format!(
"{}: Do rollover | {}: Toggle choice | {}: Ignore",
app.bindings.label(
InputContext::Rollover,
crate::keybindings::TuiAction::Submit
),
app.bindings.label(
InputContext::Rollover,
crate::keybindings::TuiAction::Previous
),
app.bindings.label(
InputContext::Rollover,
crate::keybindings::TuiAction::Cancel
),
)));
lines.push(Line::from(match dialog.choice {
crate::ui::dialog::DialogChoice::DoRollover => "> Do rollover | Ignore",
crate::ui::dialog::DialogChoice::Ignore => "Do rollover | > Ignore",
}));
let para = Paragraph::new(lines).block(
Block::default()
.borders(Borders::ALL)
.title("Rollover needed")
.style(Style::default().fg(Color::Yellow)),
);
f.render_widget(para, rect);
}
}
Mode::Detail => render_detail(app, f, area, commandbar::MODE_DETAIL),
Mode::Form => {
if let Some(fr) = &app.form {
render_board(app, f, area, commandbar::MODE_BOARD);
let rect = crate::ui::modal::modal_rect(area);
f.render_widget(Clear, rect);
form::render_with_bindings(fr, f, rect, &app.bindings, InputContext::Form);
if let Some(picker) = &app.form_picker {
render_form_picker(picker, f, area, &app.bindings);
} else {
form::place_caret(fr, f, rect);
}
}
}
Mode::ConfirmDelete => {
if area.height < 2 {
return;
}
board::render(
&app.board,
f,
area,
&None,
app.drag_preview_col,
board::BoardDisplay::from(app.active_board_config()),
);
let title = app.confirm_delete_target.as_deref().unwrap_or("?");
let prompt_area = confirm_delete_prompt(area);
let prompt = Paragraph::new(vec![
Line::from(Span::styled(
format!("Delete '{title}'? [y/N]"),
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(" [Yes] [No] "),
])
.block(
Block::default()
.borders(Borders::ALL)
.title("Confirm")
.style(Style::default().fg(Color::Red)),
);
f.render_widget(prompt, prompt_area);
let (yes, no) = confirm_delete_buttons(prompt_area);
app.record_region(yes, Action::ConfirmDeleteYes);
app.record_region(no, Action::ConfirmDeleteNo);
}
Mode::ConfirmBlockedMove { warning, .. } => {
if area.height < 3 {
return;
}
board::render(
&app.board,
f,
area,
&None,
app.drag_preview_col,
board::BoardDisplay::from(app.active_board_config()),
);
let prompt_area = Rect {
y: area.y + area.height - 2,
height: 2,
..area
};
let prompt = Paragraph::new(vec![
Line::from(Span::styled(
format!("{warning} — move anyway? [y/N]"),
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
)),
Line::from(" The decision is recorded in the issue's History."),
])
.block(
Block::default()
.borders(Borders::ALL)
.title("Blocked move")
.style(Style::default().fg(Color::Red)),
);
f.render_widget(prompt, prompt_area);
}
Mode::MoveIntoDone { buffer, .. } => {
if area.height < 3 {
return;
}
let prompt_area = Rect {
y: area.y + area.height - 1,
height: 1,
..area
};
let block_area = Rect {
height: area.height - 1,
..area
};
board::render(
&app.board,
f,
block_area,
&app.status_msg,
app.drag_preview_col,
board::BoardDisplay::from(app.active_board_config()),
);
let prompt_text = format!("Commit hash: {buffer}_");
let prompt = Paragraph::new(Line::from(Span::styled(
prompt_text,
Style::default().fg(Color::Yellow),
)));
f.render_widget(prompt, prompt_area);
}
Mode::TransitionReason { buffer, reason, .. } => {
if area.height < 3 {
return;
}
let block_area = Rect {
height: area.height - 1,
..area
};
board::render(
&app.board,
f,
block_area,
&app.status_msg,
app.drag_preview_col,
board::BoardDisplay::from(app.active_board_config()),
);
let prompt_area = Rect {
y: area.y + area.height - 1,
height: 1,
..area
};
let prompt = Paragraph::new(Line::from(Span::styled(
format!("Reason (required): {buffer}_"),
Style::default().fg(Color::Yellow),
)));
f.render_widget(prompt, prompt_area);
if area.height >= 5 {
let notice = Rect {
y: area.y,
height: 3,
..area
};
let text = Paragraph::new(vec![
Line::from(Span::styled(
"Blocked by the transition policy",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
)),
Line::from(reason.clone()),
])
.block(
Block::default()
.borders(Borders::ALL)
.title("Override required"),
);
f.render_widget(text, notice);
}
}
Mode::Help { return_to } => render_help(app, f, area, return_to),
Mode::ContextMenu { return_to } => render_context_menu(app, f, area, return_to),
Mode::Filter { buffer } => render_board_input(app, f, area, "Filter", buffer),
Mode::FilterModal => {
render_board(app, f, area, commandbar::MODE_BOARD);
if let Some(m) = app.filter_modal.as_ref() {
m.render_with_bindings(f, area, &app.bindings, InputContext::FilterModal);
let rect = crate::ui::modal::modal_rect(area);
for (row, field) in FilterField::ALL.iter().enumerate() {
let hit = Rect {
x: rect.x + 1,
y: rect.y + 1 + row as u16,
width: rect.width.saturating_sub(2),
height: 1,
};
app.record_region(hit, Action::FilterModalFocus(*field));
}
}
}
Mode::Settings => {
render_board(app, f, area, commandbar::MODE_BOARD);
if let Some(m) = app.settings.as_ref() {
let board = app.active_board_config();
m.render_with_bindings(
f,
area,
app.active_board_name(),
board,
&app.bindings,
InputContext::Settings,
);
let rect = crate::ui::modal::modal_rect(area);
let total = board.columns.len() + crate::ui::settings::DISPLAY_ROWS;
for row in 0..total {
let hit = Rect {
x: rect.x + 1,
y: rect.y + 1 + row as u16,
width: rect.width.saturating_sub(2),
height: 1,
};
app.record_region(hit, Action::SettingsRow(row));
}
}
}
Mode::PlanningManagement => {
render_board(app, f, area, commandbar::MODE_BOARD);
if let Some(m) = app.planning.as_ref() {
let (cycles, milestones) = app.planning_rows();
m.render_with_bindings(
f,
area,
&cycles,
&milestones,
&app.bindings,
InputContext::PlanningManagement,
);
}
}
Mode::PlanningForm => {
render_board(app, f, area, commandbar::MODE_BOARD);
if let Some(fm) = app.planning_form.as_ref() {
fm.render_with_bindings(f, area, &app.bindings, InputContext::PlanningForm);
}
}
Mode::DatePicker { return_to } => {
render_board(app, f, area, commandbar::MODE_BOARD);
match return_to.as_ref() {
Mode::Form => {
if let Some(fr) = app.form.as_ref() {
let rect = crate::ui::modal::modal_rect(area);
f.render_widget(Clear, rect);
form::render_with_bindings(fr, f, rect, &app.bindings, InputContext::Form);
}
}
Mode::PlanningForm => {
if let Some(fm) = app.planning_form.as_ref() {
fm.render_with_bindings(f, area, &app.bindings, InputContext::PlanningForm);
}
}
Mode::FilterModal => {
if let Some(m) = app.filter_modal.as_ref() {
m.render_with_bindings(f, area, &app.bindings, InputContext::FilterModal);
}
}
_ => {}
}
if let Some(picker) = app.date_picker.as_ref() {
picker.render_with_bindings(f, area, &app.bindings, InputContext::DatePicker);
let rect = picker.popup_rect(area);
let days_y = rect.y + 3;
let weeks = rect.height.saturating_sub(6);
for row in 0..weeks {
for col in 0..7u16 {
let x = rect.x + 1 + col * 3;
let y = days_y + row;
if let Some(date) = picker.date_at(area, x + 1, y) {
let hit = Rect {
x,
y,
width: 3,
height: 1,
};
app.record_region(
hit,
Action::DatePickerHit(DatePickerHit::Day(
crate::ui::date_picker::format_date(date),
)),
);
}
}
}
let header_y = rect.y + 1;
let header_w = rect.width.saturating_sub(2);
let split = header_w / 2;
if split > 0 {
app.record_region(
Rect {
x: rect.x + 1,
y: header_y,
width: split,
height: 1,
},
Action::DatePickerHit(DatePickerHit::PrevMonth),
);
app.record_region(
Rect {
x: rect.x + 1 + split,
y: header_y,
width: header_w - split,
height: 1,
},
Action::DatePickerHit(DatePickerHit::NextMonth),
);
}
app.record_region(
Rect {
x: rect.x,
y: rect.y + rect.height.saturating_sub(1),
width: rect.width,
height: 1,
},
Action::DatePickerHit(DatePickerHit::Submit),
);
}
}
Mode::Comment { buffer } => render_detail_input(app, f, area, "Comment", buffer),
Mode::Asset { buffer } => render_detail_input(app, f, area, "Asset", buffer),
Mode::EditLintWarning { filename, message } => {
render_edit_lint_warning(app, f, area, filename, message)
}
Mode::LinkCreate { buffer, step, .. } | Mode::LinkEdit { buffer, step, .. } => {
let label = match (step, matches!(app.mode, Mode::LinkEdit { .. })) {
(LinkStep::Target, false) => "Link to issue",
(LinkStep::Target, true) => "Re-target to issue (blank keeps)",
(LinkStep::Kind, false) => "Kind (required)",
(LinkStep::Kind, true) => "Kind (required)",
};
render_detail_input(app, f, area, label, buffer)
}
Mode::ConfirmRemoveRelationship { .. } => {
if let Some(ref d) = app.detail {
detail::render(d, f, area);
}
render_input_line(
f,
Rect {
y: area.y + area.height.saturating_sub(1),
height: 1,
..area
},
"Remove relationship (y to confirm)",
"",
);
}
}
}
fn render_form_picker(
picker: &FormPicker,
f: &mut ratatui::Frame,
area: Rect,
bindings: &EffectiveBindings,
) {
let rect = crate::ui::modal::modal_rect(area);
let picker_rect = Rect::new(
rect.x.saturating_add(2),
rect.y.saturating_add(2),
rect.width.saturating_sub(4).max(10),
rect.height.saturating_sub(4).max(5),
);
f.render_widget(Clear, picker_rect);
let mut lines = Vec::new();
lines.push(Line::from(format!("Filter: {}", picker.state.filter)));
lines.push(Line::from(""));
if picker.state.mode == PickerMode::InlineCreate {
lines.push(Line::from(Span::styled(
format!("Create: {}_", picker.state.create_buffer),
Style::default().fg(Color::Yellow),
)));
} else {
let visible = picker.state.visible_indices();
for (pos, idx) in visible.into_iter().take(8).enumerate() {
let item = &picker.state.options[idx];
let selected = match &picker.state.selection {
SelectionModel::Single(current) => current.as_deref() == Some(item.id.as_str()),
SelectionModel::Multi(current) => current.contains(&item.id),
};
let cursor = if pos == picker.state.cursor { ">" } else { " " };
let mark = if selected { "[x]" } else { "[ ]" };
lines.push(Line::from(format!("{cursor} {mark} {}", item.label)));
}
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!(
"{}/{} close | {} select | {} create",
bindings.label(
InputContext::FormPicker,
crate::keybindings::TuiAction::OpenPicker
),
bindings.label(
InputContext::FormPicker,
crate::keybindings::TuiAction::Cancel
),
bindings.label(
InputContext::FormPicker,
crate::keybindings::TuiAction::Submit
),
bindings.label(
InputContext::FormPicker,
crate::keybindings::TuiAction::Create
),
),
Style::default().fg(Color::DarkGray),
)));
let title = match picker.field {
FormPickerField::Labels => "Labels picker",
FormPickerField::Assignee => "Assignee picker",
FormPickerField::Cycles => "Cycles picker",
FormPickerField::Milestones => "Milestones picker",
FormPickerField::Reviewer => "Reviewer picker",
FormPickerField::Parent => "Parent picker",
};
let para = Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title(title));
f.render_widget(para, picker_rect);
}
fn render_edit_lint_warning(
app: &App,
f: &mut ratatui::Frame,
area: Rect,
filename: &str,
message: &str,
) {
render_board(app, f, area, commandbar::MODE_BOARD);
let rect = crate::ui::modal::modal_rect(area);
f.render_widget(Clear, rect);
let lines = vec![
Line::from(Span::styled("Lint failed", Style::default().fg(Color::Red))),
Line::from(Span::raw(filename.to_string())),
Line::from(Span::styled(
message.to_string(),
Style::default().fg(Color::Yellow),
)),
Line::from(""),
Line::from(Span::styled(
format!(
"{}: re-edit · {}: ignore",
app.bindings.label(
InputContext::EditWarning,
crate::keybindings::TuiAction::Reedit
),
app.bindings.label(
InputContext::EditWarning,
crate::keybindings::TuiAction::Ignore
),
),
Style::default().fg(Color::DarkGray),
)),
];
let para = Paragraph::new(lines).block(
Block::default()
.borders(Borders::ALL)
.title("Edit Warning")
.style(Style::default().fg(Color::Red)),
);
f.render_widget(para, rect);
}
fn render_hover_line(app: &App, f: &mut ratatui::Frame, area: Rect) {
let tab_h: u16 = if app.tabs.needs_bar() { TAB_HEIGHT } else { 0 };
let hover_area = Rect {
y: area.y + tab_h,
height: HOVER_HEIGHT,
..area
};
let line = match app.board.get_selected_card() {
Some(card) => Line::from(vec![
Span::styled(
format!("#{}", card.number),
Style::default().fg(Color::Cyan),
),
Span::raw(" "),
Span::styled(card.title.clone(), Style::default()),
]),
None => Line::from(Span::styled("—", Style::default().fg(Color::DarkGray))),
};
f.render_widget(Paragraph::new(line), hover_area);
}
fn render_board(app: &App, f: &mut ratatui::Frame, area: Rect, tag: &str) {
let tab_h: u16 = if app.tabs.needs_bar() { TAB_HEIGHT } else { 0 };
let filter_h: u16 = if app.filter.is_some() || app.structured.has_active_criteria() {
FILTER_HEIGHT
} else {
0
};
let hint_h: u16 = if app.one_shot { 0 } else { HINT_HEIGHT };
if area.height <= hint_h + tab_h + HOVER_HEIGHT + filter_h {
return;
}
let hint_area = Rect {
y: area.y + area.height - hint_h,
height: hint_h,
..area
};
let tabs_area = Rect {
y: area.y,
height: tab_h,
..area
};
let filter_area = Rect {
y: area.y + tab_h + HOVER_HEIGHT,
height: filter_h,
..area
};
let content = Rect {
y: area.y + tab_h + HOVER_HEIGHT + filter_h,
height: area.height - tab_h - HOVER_HEIGHT - filter_h - hint_h,
..area
};
if tab_h > 0 {
app.tabs.render(f, tabs_area);
for index in 0..app.tabs.len() {
if let Some(rect) = app.tabs.tab_rect(index, tabs_area) {
app.record_region(rect, Action::SwitchBoard(index));
}
}
}
render_hover_line(app, f, area);
if filter_h > 0 {
filter_row(app).render(f, filter_area);
}
let page_cards = board::render(
&app.board,
f,
content,
&app.status_msg,
app.drag_preview_col,
board::BoardDisplay::from(app.active_board_config()),
);
app.board_page.set(page_cards);
if hint_h > 0 {
render_hint(f, hint_area, &app.bindings, tag);
}
}
fn render_detail(app: &App, f: &mut ratatui::Frame, area: Rect, tag: &str) {
let status_h: u16 = if app.status_msg.is_some() { 1 } else { 0 };
if area.height <= HINT_HEIGHT + status_h {
return;
}
let hint_area = Rect {
y: area.y + area.height - HINT_HEIGHT,
height: HINT_HEIGHT,
..area
};
let content = Rect {
height: area.height - status_h - HINT_HEIGHT,
..area
};
if let Some(ref d) = app.detail {
detail::render(d, f, content);
}
if let Some(msg) = &app.status_msg {
let status_area = Rect {
y: area.y + area.height - status_h - HINT_HEIGHT,
height: status_h,
..area
};
let para = Paragraph::new(Line::from(Span::raw(msg.clone())))
.style(Style::default().fg(Color::Yellow));
f.render_widget(para, status_area);
}
render_hint(f, hint_area, &app.bindings, tag);
}
fn render_board_input(app: &App, f: &mut ratatui::Frame, area: Rect, label: &str, buffer: &str) {
let tab_h: u16 = if app.tabs.needs_bar() { TAB_HEIGHT } else { 0 };
if area.height < tab_h + HOVER_HEIGHT + 1 {
return;
}
let filter_h: u16 = if app.filter.is_some() || app.structured.has_active_criteria() {
FILTER_HEIGHT
} else {
0
};
let input_area = Rect {
y: area.y + area.height - 1,
height: 1,
..area
};
let tabs_area = Rect {
y: area.y,
height: tab_h,
..area
};
let filter_area = Rect {
y: area.y + tab_h + HOVER_HEIGHT,
height: filter_h,
..area
};
let content = Rect {
y: area.y + tab_h + HOVER_HEIGHT + filter_h,
height: area
.height
.saturating_sub(tab_h + HOVER_HEIGHT + filter_h + 1),
..area
};
if tab_h > 0 {
app.tabs.render(f, tabs_area);
for index in 0..app.tabs.len() {
if let Some(rect) = app.tabs.tab_rect(index, tabs_area) {
app.record_region(rect, Action::SwitchBoard(index));
}
}
}
render_hover_line(app, f, area);
if filter_h > 0 {
filter_row(app).render(f, filter_area);
}
let page_cards = board::render(
&app.board,
f,
content,
&app.status_msg,
app.drag_preview_col,
board::BoardDisplay::from(app.active_board_config()),
);
app.board_page.set(page_cards);
render_input_line(f, input_area, label, buffer);
}
fn render_detail_input(app: &App, f: &mut ratatui::Frame, area: Rect, label: &str, buffer: &str) {
if area.height < 2 {
return;
}
let input_area = Rect {
y: area.y + area.height - 1,
height: 1,
..area
};
let content = Rect {
height: area.height - 1,
..area
};
if let Some(ref d) = app.detail {
detail::render(d, f, content);
}
render_input_line(f, input_area, label, buffer);
}
fn render_input_line(f: &mut ratatui::Frame, area: Rect, label: &str, buffer: &str) {
let text = format!("{label}: {buffer}_");
let prompt = Paragraph::new(Line::from(Span::styled(
text,
Style::default().fg(Color::Yellow),
)));
f.render_widget(prompt, area);
}
fn filter_row(app: &App) -> crate::ui::filter_row::FilterRow {
crate::ui::filter_row::FilterRow {
query: app.filter.clone(),
structured: app.structured.clone(),
}
}
fn render_hint(f: &mut ratatui::Frame, area: Rect, bindings: &EffectiveBindings, tag: &str) {
let context = if tag == commandbar::MODE_DETAIL {
InputContext::Detail
} else {
InputContext::Board
};
let bar = commandbar::hint_bar_for(bindings, context);
let para = Paragraph::new(Line::from(Span::styled(
bar,
Style::default().fg(Color::Gray),
)));
f.render_widget(para, area);
}
fn render_help(app: &App, f: &mut ratatui::Frame, area: Rect, return_to: &Mode) {
match return_to {
Mode::Board => render_board(app, f, area, commandbar::MODE_BOARD),
Mode::Detail => render_detail(app, f, area, commandbar::MODE_DETAIL),
_ => render_board(app, f, area, commandbar::MODE_BOARD),
}
let help_entries = commandbar::help_overlay(&app.bindings);
let query = app.help_query.trim().to_string();
let mut fuzzy_fallback = false;
let matches: Vec<(String, String, String)> = if query.is_empty() {
help_entries.clone()
} else {
let needle = query.to_lowercase();
let haystack = |entry: &(String, String, String)| {
format!("{} {} {}", entry.0, entry.1, entry.2).to_lowercase()
};
let literal: Vec<(String, String, String)> = help_entries
.iter()
.filter(|entry| haystack(entry).contains(&needle))
.cloned()
.collect();
if literal.is_empty() {
fuzzy_fallback = true;
let mut scored: Vec<(u32, (String, String, String))> = help_entries
.iter()
.filter_map(|entry| {
crate::search::fuzzy_score(&query, &haystack(entry))
.map(|score| (score, entry.clone()))
})
.collect();
scored.sort_by(|a, b| b.0.cmp(&a.0));
scored.into_iter().map(|(_, entry)| entry).collect()
} else {
literal
}
};
let total = help_entries.len();
let frame_rows = area.height.saturating_sub(2);
let visible = (matches.len() as u16).min(frame_rows.saturating_sub(1));
let hidden = matches.len().saturating_sub(visible as usize);
app.help_metrics.set(HelpMetrics {
visible,
max_scroll: u16::try_from(hidden).unwrap_or(u16::MAX),
});
let offset = usize::from(app.help_scroll).min(hidden);
let mut rows: Vec<Line<'static>> = matches
.iter()
.skip(offset)
.take(visible as usize)
.map(|(key, desc, scope)| {
Line::from(vec![
Span::styled(format!("{key:<20}"), Style::default().fg(Color::Cyan)),
Span::styled(format!("{desc:<28}"), Style::default().fg(Color::White)),
Span::styled(scope.clone(), Style::default().fg(Color::DarkGray)),
])
})
.collect();
let caret = if app.help_searching {
let mut spans = vec![
Span::styled("/", Style::default().fg(Color::Yellow)),
Span::styled(app.help_query.clone(), Style::default().fg(Color::White)),
];
if !query.is_empty() {
spans.push(Span::styled(
format!(
" — {} of {total} bindings{}",
matches.len(),
if fuzzy_fallback { " (fuzzy)" } else { "" },
),
Style::default().fg(Color::DarkGray),
));
}
rows.push(Line::from(spans));
Some(Line::from(format!("/{}", app.help_query)).width())
} else if !query.is_empty() {
let mode = if fuzzy_fallback { " (fuzzy)" } else { "" };
rows.push(Line::from(Span::styled(
match hidden {
0 => format!(
"match \"{query}\" — {} of {total} bindings{mode}",
matches.len()
),
_ => format!(
"match \"{query}\" — {} of {total} bindings, rows {}-{}{mode}",
matches.len(),
offset + 1,
offset + visible as usize,
),
},
Style::default().fg(Color::DarkGray),
)));
None
} else if hidden > 0 {
rows.push(Line::from(Span::styled(
format!(
"rows {}-{} of {} — {} above, {} below",
offset + 1,
offset + visible as usize,
matches.len(),
offset,
hidden - offset,
),
Style::default().fg(Color::DarkGray),
)));
None
} else {
None
};
let block = Block::default()
.borders(Borders::ALL)
.title("Help [? close]")
.style(Style::default().fg(Color::Cyan));
let para = Paragraph::new(rows).block(block);
let notice_rows = u16::from(caret.is_some() || !query.is_empty() || hidden > 0);
let drawn_rows = visible + notice_rows;
let modal_w = 60.min(area.width).max(30);
let modal_h = (drawn_rows + 2).min(area.height).max(4);
let modal_x = area.x + (area.width.saturating_sub(modal_w)) / 2;
let modal_y = area.y + (area.height.saturating_sub(modal_h)) / 2;
let modal_area = Rect::new(modal_x, modal_y, modal_w, modal_h);
if area.height >= modal_h && area.width >= modal_w {
f.render_widget(Clear, modal_area);
f.render_widget(para, modal_area);
}
if let Some(column) = caret
&& area.height >= modal_h
&& area.width >= modal_w
{
let inner = Block::default().borders(Borders::ALL).inner(modal_area);
let row = inner.y + visible.min(inner.height.saturating_sub(1));
let x = inner.x
+ u16::try_from(column)
.unwrap_or(u16::MAX)
.min(inner.width.saturating_sub(1));
f.set_cursor_position(ratatui::layout::Position::new(x, row));
}
app.record_region(area, Action::HelpClose);
}
fn confirm_delete_prompt(area: Rect) -> Rect {
let prompt_w = 40.min(area.width);
let prompt_h = 4;
let prompt_x = area.x + (area.width.saturating_sub(prompt_w)) / 2;
let prompt_y = area.y + (area.height.saturating_sub(prompt_h)) / 2;
Rect::new(prompt_x, prompt_y, prompt_w, prompt_h)
}
fn confirm_delete_buttons(prompt: Rect) -> (Rect, Rect) {
let yes = Rect::new(prompt.x + 3, prompt.y + 3, 5, 1);
let no = Rect::new(prompt.x + 10, prompt.y + 3, 4, 1);
(yes, no)
}
fn render_context_menu(app: &App, f: &mut ratatui::Frame, area: Rect, return_to: &Mode) {
match return_to {
Mode::Board => render_board(app, f, area, commandbar::MODE_BOARD),
_ => render_board(app, f, area, commandbar::MODE_BOARD),
}
if let Some(menu) = app.context_menu.as_ref() {
menu.render(f, area);
}
}
pub fn drain_disclosures(app: &mut App) {
for disclosure in crate::storage::take_disclosures() {
app.note_disclosure(disclosure);
}
}
pub fn run(config: &Config) -> Result<()> {
run_session(config, false)
}
pub fn run_new_issue(config: &Config) -> Result<()> {
run_session(config, true)
}
fn run_session(config: &Config, new_issue_form: bool) -> Result<()> {
let mut app = App::new(config)?;
if new_issue_form {
app.open_new_issue_form();
}
let mut watcher = crate::watcher::FsWatcher::new(app.store.issues_dir.clone());
enable_raw_mode().map_err(io_err)?;
execute!(io::stdout(), EnterAlternateScreen).map_err(io_err)?;
mouse_capture_on(io::stdout()).map_err(io_err)?;
let guard = RestoreGuard;
let backend = CrosstermBackend::new(io::stdout());
let mut terminal = Terminal::new(backend).map_err(io_err)?;
let result = loop {
drain_disclosures(&mut app);
terminal
.draw(|f| render(&app, f, f.area()))
.map_err(io_err)?;
if event::poll(Duration::from_millis(200)).map_err(io_err)? {
let action = match event::read().map_err(io_err)? {
Event::Key(key) => app.handle_key(key)?,
Event::Mouse(mouse) => app.handle_mouse(mouse)?,
_ => Action::None,
};
match action {
Action::None => {}
Action::Quit => break Ok(()),
Action::SwitchBoard(index) => {
app.switch_board(index);
}
Action::ConfirmDeleteYes => {
let _ = app.confirm_delete_yes();
}
Action::ConfirmDeleteNo => {
let _ = app.confirm_delete_no();
}
Action::FilterModalFocus(field) => {
if let Some(modal) = app.filter_modal.as_mut() {
modal.focus_field(field);
}
}
Action::SettingsRow(row) => {
let count =
app.active_board_config().columns.len() + crate::ui::settings::DISPLAY_ROWS;
if let Some(modal) = app.settings.as_mut() {
modal.move_selected_to(row, count);
}
let _ = app.settings_toggle_selected();
}
Action::DatePickerHit(hit) => match hit {
DatePickerHit::Day(date) => {
if let Some(picker) = app.date_picker.as_mut() {
picker.select_date_str(&date);
}
}
DatePickerHit::PrevMonth => {
if let Some(picker) = app.date_picker.as_mut() {
picker.shift_month(-1);
}
}
DatePickerHit::NextMonth => {
if let Some(picker) = app.date_picker.as_mut() {
picker.shift_month(1);
}
}
DatePickerHit::Submit => {
let _ = app.handle_date_picker_key(KeyEvent::new(
KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
));
}
},
Action::HelpClose => {
app.close_help();
}
Action::OpenEditor(filename) => {
drop(terminal);
let _ = disable_raw_mode();
let _ = mouse_capture_off(io::stdout());
let _ = execute!(io::stdout(), LeaveAlternateScreen);
let edit_result = (|| -> Result<()> {
let name = editor::resolve_editor(app.store.config.editor.as_deref())?;
let original = app.store.get(IssueSelector::Filename(filename.clone()))?;
let temp = std::env::temp_dir().join(format!(
"tix-edit-{}-{}.md",
std::process::id(),
original.number
));
std::fs::write(&temp, original.to_file_contents()).map_err(TixError::Io)?;
editor::run_editor(&name, &temp, &app.store.config)?;
let edited = std::fs::read_to_string(&temp).map_err(TixError::Io)?;
let mut parsed = Issue::parse(&edited)?;
let _ = std::fs::remove_file(&temp);
if parsed.title.trim().is_empty() {
app.mode = Mode::EditLintWarning {
filename: filename.clone(),
message: "title is required".to_string(),
};
app.status_msg = Some("editor: title is required".to_string());
return Ok(());
}
parsed.bump_updated();
let location = match parsed.assignee {
Some(ref a) => match app.store.reassign(&original.filename, Some(a)) {
Ok(moved) => moved.to_string_lossy().to_string(),
Err(e) => {
app.status_msg = Some(format!("editor: {e}"));
return Ok(());
}
},
None => original.filename.clone(),
};
app.store.update(&location, &parsed)?;
if let Some(ref mut d) = app.detail
&& (d.issue.filename == original.filename
|| d.issue.filename == location)
{
d.issue = parsed;
d.scroll_offset = 0;
}
app.refresh_corpus()?;
app.status_msg = Some("Edited".to_string());
Ok(())
})();
if let Err(e) = edit_result {
app.status_msg = Some(format!("editor: {e}"));
}
enable_raw_mode().map_err(io_err)?;
execute!(io::stdout(), EnterAlternateScreen).map_err(io_err)?;
mouse_capture_on(io::stdout()).map_err(io_err)?;
let backend = CrosstermBackend::new(io::stdout());
terminal = Terminal::new(backend).map_err(io_err)?;
}
Action::OpenAgent(filename) => {
drop(terminal);
let _ = disable_raw_mode();
let _ = mouse_capture_off(io::stdout());
let _ = execute!(io::stdout(), LeaveAlternateScreen);
let edit_result = (|| -> Result<()> {
let agent = editor::resolve_agent(app.store.config.agent.as_deref())?;
let issue = app.store.get(IssueSelector::Filename(filename.clone()))?;
let temp = std::env::temp_dir().join(format!(
"tix-agent-{}-{}.md",
std::process::id(),
issue.number
));
std::fs::write(&temp, issue.body.as_bytes()).map_err(io_err)?;
let result = std::process::Command::new(&agent)
.arg(&temp)
.status()
.map_err(|e| {
crate::error::TixError::Editor(format!(
"failed to launch {agent}: {e}"
))
});
let _ = std::fs::remove_file(&temp);
result?;
Ok(())
})();
if let Err(e) = edit_result {
app.status_msg = Some(format!("agent: {e}"));
}
enable_raw_mode().map_err(io_err)?;
execute!(io::stdout(), EnterAlternateScreen).map_err(io_err)?;
mouse_capture_on(io::stdout()).map_err(io_err)?;
let backend = CrosstermBackend::new(io::stdout());
terminal = Terminal::new(backend).map_err(io_err)?;
}
}
}
if watcher.poll()
&& let Err(e) = app.refresh_from_disk()
{
app.status_msg = Some(format!("watch: {e}"));
}
};
drop(guard);
result
}
pub fn render_once(config: &Config) -> Result<String> {
let size = resolve_render_size(crossterm::terminal::size().ok());
render_once_at(config, size.0, size.1)
}
fn resolve_render_size(measured: Option<(u16, u16)>) -> (u16, u16) {
let (width, height) = measured.unwrap_or((RENDER_FALLBACK_WIDTH, RENDER_FALLBACK_HEIGHT));
(width.max(1), height.max(1))
}
fn render_once_at(config: &Config, width: u16, height: u16) -> Result<String> {
let render_h = height.max(1).saturating_sub(RENDER_BOTTOM_MARGIN).max(1);
let mut app = App::new(config)?;
app.mode = Mode::Board;
app.rollover_dialog = None;
app.one_shot = true;
let cells = render_to_cells(&app, width, render_h)?;
Ok(cells_to_text(&cells, width, render_h))
}
fn render_to_cells(app: &App, width: u16, height: u16) -> Result<Vec<String>> {
let backend = ratatui::backend::TestBackend::new(width.max(1), height.max(1));
let mut terminal = ratatui::Terminal::new(backend).map_err(io_err)?;
terminal
.draw(|f| render(app, f, f.area()))
.map_err(io_err)?;
let buffer = terminal.backend().buffer();
Ok(buffer
.content
.iter()
.map(|cell| cell.symbol().to_string())
.collect())
}
fn cells_to_text(cells: &[String], width: u16, height: u16) -> String {
let mut lines = Vec::new();
for row in 0..height as usize {
let start = row * width as usize;
let end = start + width as usize;
let line = cells[start..end].concat();
lines.push(line.trim_end().to_string());
}
while matches!(lines.last(), Some(last) if last.is_empty()) {
let _ = lines.pop();
}
if lines.is_empty() {
String::new()
} else {
format!("{}\n", lines.join("\n"))
}
}
fn mouse_capture_on(mut stream: io::Stdout) -> Result<()> {
execute!(stream, EnableMouseCapture).map_err(io_err)
}
fn mouse_capture_off(mut stream: io::Stdout) -> Result<()> {
execute!(stream, DisableMouseCapture).map_err(io_err)
}
struct RestoreGuard;
impl Drop for RestoreGuard {
fn drop(&mut self) {
let _ = disable_raw_mode();
let _ = mouse_capture_off(io::stdout());
let _ = execute!(io::stdout(), LeaveAlternateScreen);
}
}
fn io_err<E: std::fmt::Display>(e: E) -> TixError {
TixError::Io(io::Error::other(e.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn make_app() -> (TempDir, App) {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(&dir).unwrap();
let config_path = tmp.path().join("tix.toml");
let config = Config {
issues_dir: dir.clone(),
..Config::default()
};
config.save(&config_path).unwrap();
let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
let app = App::new(&config).unwrap();
(tmp, app)
}
fn write_issue(
dir: &std::path::Path,
name: &str,
number: u32,
title: &str,
status: &str,
commit: Option<&str>,
) {
let commit_line = match commit {
Some(h) => format!("commit: \"{h}\""),
None => String::new(),
};
let contents = format!(
"---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"{status}\"\nlabels: []\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n{commit_line}\n---\n\nBody text."
);
fs::write(dir.join(name), contents).unwrap();
}
fn press(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, crossterm::event::KeyModifiers::NONE)
}
fn press_with_modifiers(code: KeyCode, modifiers: crossterm::event::KeyModifiers) -> KeyEvent {
KeyEvent::new(code, modifiers)
}
fn make_app_with_planning() -> (TempDir, App) {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(&dir).unwrap();
let mut config = Config {
issues_dir: dir.clone(),
..Config::default()
};
config.cycles.insert(
"Cycle 1".into(),
crate::config::PlanningCycle {
description: Some("kickoff".into()),
start_date: Some("2026-10-01".into()),
due_date: Some("2999-12-31".into()),
},
);
config.cycles.insert(
"Cycle 2".into(),
crate::config::PlanningCycle {
description: Some("next".into()),
start_date: Some("2999-01-01".into()),
due_date: Some("2999-01-15".into()),
},
);
let issue = "---\nnumber: 1\ntitle: \"A\"\nstatus: \"todo\"\nlabels: []\npriority: \"medium\"\ncycles: [\"Cycle 1\"]\nmilestones: [\"GA\"]\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody text.";
fs::create_dir_all(dir.join("alice")).unwrap();
fs::write(dir.join("alice/2026-01-01-a.md"), issue).unwrap();
let milestones_dir = tmp.path().join("docs/milestones");
fs::create_dir_all(&milestones_dir).unwrap();
fs::write(
milestones_dir.join("2026-01-01-ga.md"),
"---\nname: \"GA\"\ntarget_date: \"2026-12-31\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nship it\n",
)
.unwrap();
let config_path = tmp.path().join("tix.toml");
config.save(&config_path).unwrap();
let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
let app = App::new(&config).unwrap();
(tmp, app)
}
fn make_app_with_rollover_dialog() -> (TempDir, App) {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(&dir).unwrap();
let mut config = Config {
issues_dir: dir.clone(),
..Config::default()
};
config.cycles.clear();
config.cycles.insert(
"Cycle 1".into(),
crate::config::PlanningCycle {
description: Some("expired".into()),
start_date: Some("2000-01-01".into()),
due_date: Some("2000-01-02".into()),
},
);
config.cycles.insert(
"Cycle 2".into(),
crate::config::PlanningCycle {
description: Some("next".into()),
start_date: Some("2999-01-01".into()),
due_date: Some("2999-01-15".into()),
},
);
let issue = "---\nnumber: 1\ntitle: \"A\"\nstatus: \"todo\"\nlabels: []\npriority: \"medium\"\ncycles: [\"Cycle 1\"]\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody text.";
fs::create_dir_all(dir.join("alice")).unwrap();
fs::write(dir.join("alice/2026-01-01-a.md"), issue).unwrap();
let config_path = tmp.path().join("tix.toml");
config.save(&config_path).unwrap();
let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
let app = App::new(&config).unwrap();
(tmp, app)
}
#[test]
fn quit_on_q() {
let (_tmp, mut app) = make_app();
let action = app.handle_key(press(KeyCode::Char('q'))).unwrap();
assert!(app.quit);
assert!(matches!(action, Action::Quit));
}
#[test]
fn m_opens_planning_modal_from_board() {
let (_tmp, mut app) = make_app();
let _ = app.handle_key(press(KeyCode::Char('m'))).unwrap();
assert!(
matches!(app.mode, Mode::PlanningManagement),
"m flips board mode to planning management"
);
assert!(app.planning.is_some(), "planning modal state is opened");
}
#[test]
fn q_and_esc_close_planning_modal() {
let (_tmp, mut app) = make_app();
let _ = app.handle_key(press(KeyCode::Char('m'))).unwrap();
let _ = app.handle_key(press(KeyCode::Char('q'))).unwrap();
assert!(matches!(app.mode, Mode::Board));
assert!(app.planning.is_none());
let (_tmp, mut app) = make_app();
let _ = app.handle_key(press(KeyCode::Char('m'))).unwrap();
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(matches!(app.mode, Mode::Board), "Esc closes the modal");
assert!(app.planning.is_none());
}
#[test]
fn esc_cancels_planning_form_back_to_modal() {
let (_tmp, mut app) = make_app();
let _ = app.handle_key(press(KeyCode::Char('m'))).unwrap();
let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
assert!(matches!(app.mode, Mode::PlanningForm));
assert!(app.planning_form.is_some());
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(
matches!(app.mode, Mode::PlanningManagement),
"Esc in the form returns to the modal"
);
assert!(app.planning_form.is_none());
assert!(app.planning.is_some(), "the modal stays open underneath");
}
#[test]
fn planning_browse_navigates_and_switches_segments() {
let (_tmp, mut app) = make_app_with_planning();
let _ = app.handle_key(press(KeyCode::Char('m'))).unwrap();
let _ = app.handle_key(press(KeyCode::Char('j'))).unwrap();
assert_eq!(app.planning.as_ref().unwrap().selected, 1);
let _ = app.handle_key(press(KeyCode::Char('j'))).unwrap();
assert_eq!(
app.planning.as_ref().unwrap().selected,
1,
"cursor clamps at the last cycle"
);
let _ = app.handle_key(press(KeyCode::Char('k'))).unwrap();
assert_eq!(app.planning.as_ref().unwrap().selected, 0);
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
let modal = app.planning.as_ref().unwrap();
assert_eq!(
modal.segment,
crate::ui::planning::PlanningSegment::Milestones,
"Tab switches to the milestones segment"
);
assert_eq!(modal.selected, 0, "segment switch resets the row cursor");
let _ = app.handle_key(press(KeyCode::Char('j'))).unwrap();
assert_eq!(
app.planning.as_ref().unwrap().selected,
0,
"a single milestone clamps the cursor"
);
let _ = app.handle_key(press(KeyCode::BackTab)).unwrap();
assert_eq!(
app.planning.as_ref().unwrap().segment,
crate::ui::planning::PlanningSegment::Cycles,
"BackTab switches back to the cycles segment"
);
let _ = app.handle_key(press(KeyCode::Char('q'))).unwrap();
assert!(
matches!(app.mode, Mode::Board),
"q cancels out of the modal"
);
assert!(app.planning.is_none());
}
#[test]
fn planning_n_opens_create_form_for_active_segment() {
let (_tmp, mut app) = make_app_with_planning();
let _ = app.handle_key(press(KeyCode::Char('m'))).unwrap();
let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
assert!(matches!(app.mode, Mode::PlanningForm));
assert_eq!(
app.planning_form.as_ref().unwrap().kind,
crate::ui::planning::PlanningKind::Cycle,
"n on the cycles segment opens a cycle form"
);
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
assert_eq!(
app.planning_form.as_ref().unwrap().kind,
crate::ui::planning::PlanningKind::Milestone,
"n on the milestones segment opens a milestone form"
);
}
#[test]
fn planning_e_opens_edit_form_prefilled() {
let (_tmp, mut app) = make_app_with_planning();
let _ = app.handle_key(press(KeyCode::Char('m'))).unwrap();
let _ = app.handle_key(press(KeyCode::Char('e'))).unwrap();
let form = app.planning_form.as_ref().unwrap().clone();
assert_eq!(form.kind, crate::ui::planning::PlanningKind::Cycle);
assert_eq!(form.name(), "Cycle 1");
assert_eq!(form.editing.as_deref(), Some("Cycle 1"));
assert_eq!(form.description(), "kickoff");
assert_eq!(form.date_a(), "2026-10-01");
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
let _ = app.handle_key(press(KeyCode::Char('e'))).unwrap();
let form = app.planning_form.as_ref().unwrap().clone();
assert_eq!(form.kind, crate::ui::planning::PlanningKind::Milestone);
assert_eq!(form.name(), "GA");
assert_eq!(form.editing.as_deref(), Some("GA"));
assert_eq!(form.date_a(), "2026-12-31");
}
#[test]
fn planning_x_shows_referencing_count_and_cancel_keeps_item() {
let (_tmp, mut app) = make_app_with_planning();
let _ = app.handle_key(press(KeyCode::Char('m'))).unwrap();
let _ = app.handle_key(press(KeyCode::Char('x'))).unwrap();
match &app.planning.as_ref().unwrap().prompt {
Some(crate::ui::planning::PlanningPrompt::ConfirmDelete { name, referencing }) => {
assert_eq!(name, "Cycle 1");
assert_eq!(*referencing, 1, "one issue references Cycle 1");
}
other => panic!("x raises a confirm prompt, got {other:?}"),
}
let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
assert!(
app.planning.as_ref().unwrap().prompt.is_none(),
"n dismisses the prompt"
);
assert!(
matches!(app.mode, Mode::PlanningManagement),
"cancelling stays in the modal"
);
assert_eq!(
app.planning_rows_len(),
2,
"the cancelled delete leaves the cycle in the registry"
);
}
#[test]
fn the_cut_marker_survives_a_double_width_filename() {
let (_tmp, mut app) = make_app();
app.note_disclosure(
"remove `links:` by hand to silence \u{2014} \
alice/2026-09-14-\u{65e5}\u{672c}\u{8a9e}\u{306e}\u{3068}\u{3066}\u{3082}\u{3044}\u{3044}\u{30d5}\u{30a1}\u{30a4}\u{30eb}\u{540d}_tests.md \
(retired by C-001; never read as typed data, no migration)"
.to_string(),
);
for width in [60u16, 80, 100, 120, 200] {
let cells = render_to_cells(&app, width, 40).unwrap();
let rendered: Vec<String> = (0..40).map(|y| flush_line(&cells, width, y)).collect();
let status = rendered
.iter()
.find(|l| l.contains("remove `links:`"))
.unwrap_or_else(|| panic!("width {width}: no status row: {rendered:?}"));
assert!(
status.contains("remove `links:` by hand"),
"width {width}: the action must stay visible, got {status:?}"
);
if width < 200 {
assert!(
status.contains('\u{2026}'),
"width {width}: a cut must be marked, not silent, got {status:?}"
);
}
}
}
#[test]
#[allow(clippy::unicode_not_nfc)] fn a_truncated_status_row_never_exceeds_its_width_in_cells() {
use unicode_width::UnicodeWidthStr;
for msg in [
"remove `links:` by hand to silence \u{2014} plain-ascii-file.md",
"remove `links:` by hand to silence \u{2014} \u{65e5}\u{672c}\u{8a9e}.md",
"short",
"",
] {
for budget in [1usize, 2, 3, 5, 10, 20] {
let text = crate::ui::board::truncate_to_cells(msg, budget);
assert!(
text.width() <= budget,
"budget {budget}, msg {msg:?}: produced {text:?} at width {}",
text.width()
);
if msg.width() > budget {
assert!(
text.ends_with('\u{2026}'),
"budget {budget}, msg {msg:?}: a cut must be marked, got {text:?}"
);
}
}
}
}
#[test]
fn the_retired_key_warning_stays_actionable_at_every_terminal_width() {
let (_tmp, mut app) = make_app();
app.note_disclosure(
"remove `links:` by hand to silence \u{2014} \
alice/2026-09-14-add-the-ability-to-hide-and-show-kanban-columns.md \
(retired by C-001; never read as typed data, no migration)"
.to_string(),
);
for width in [80u16, 100, 120] {
let cells = render_to_cells(&app, width, 40).unwrap();
let rendered: Vec<String> = (0..40).map(|y| flush_line(&cells, width, y)).collect();
let status = rendered
.iter()
.find(|l| l.contains("remove `links:`"))
.unwrap_or_else(|| panic!("width {width}: no status row rendered: {rendered:?}"));
assert!(
status.contains("remove `links:` by hand to silence"),
"width {width}: the action must be visible, got {status:?}"
);
assert!(
status.contains('\u{2026}'),
"width {width}: a cut is marked rather than silent, got {status:?}"
);
}
}
#[test]
fn planning_renders_rows_with_fields_and_counts() {
let (_tmp, mut app) = make_app_with_planning();
let _ = app.handle_key(press(KeyCode::Char('m'))).unwrap();
let cells = render_to_cells(&app, 120, 40).unwrap();
let lines: Vec<String> = (0..40).map(|y| flush_line(&cells, 120, y)).collect();
assert!(
lines
.iter()
.any(|l| l.contains("Cycle 1 | kickoff | 2026-10-01 | 2999-12-31 | 1 issue")),
"cycle rows show fields and count: {lines:?}"
);
assert!(
lines
.iter()
.any(|l| l.contains("Cycle 2 | next | 2999-01-01 | 2999-01-15 | 0 issues")),
"unreferenced cycles render a zero count"
);
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
let cells = render_to_cells(&app, 120, 40).unwrap();
let lines: Vec<String> = (0..40).map(|y| flush_line(&cells, 120, y)).collect();
assert!(
lines
.iter()
.any(|l| l.contains("GA | ship it | 2026-12-31 | 1 issue")),
"milestone rows show fields and count: {lines:?}"
);
}
#[test]
fn planning_form_creates_cycle_with_blank_fields_rejected() {
let (_tmp, mut app) = make_app_with_planning();
let _ = app.handle_key(press(KeyCode::Char('m'))).unwrap();
let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(
matches!(app.mode, Mode::PlanningForm),
"blank-name submit keeps the form open"
);
let status = app.status_msg.as_deref().unwrap_or("");
assert!(
status.contains("a name is required"),
"blank names surface an error status: {status:?}"
);
assert_eq!(app.planning_rows_len(), 2, "nothing was created");
for c in "Cycle 9".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(
matches!(app.mode, Mode::PlanningManagement),
"a valid create returns to the modal"
);
assert!(app.planning_form.is_none());
assert!(
app.store.config.cycles.contains_key("Cycle 9"),
"the new cycle is persisted to tix.toml and reloaded into the store"
);
let (cycles, _) = app.planning_rows();
assert!(
cycles.iter().any(|r| r.name == "Cycle 9"),
"the modal re-query shows the new cycle"
);
}
#[test]
fn planning_form_creates_milestone_and_refreshes_pickers() {
let (tmp, mut app) = make_app_with_planning();
let _ = app.handle_key(press(KeyCode::Char('m'))).unwrap();
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
for c in "Beta".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
assert!(matches!(app.mode, Mode::DatePicker { .. }));
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let milestones_dir = tmp.path().join("docs/milestones");
let created = milestones_dir
.read_dir()
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().contains("beta.md"))
.count();
assert_eq!(created, 1, "a milestone registry file is written");
let (_, milestones) = app.planning_rows();
assert!(
milestones
.iter()
.any(|r| { r.name == "Beta" && r.date_a == today() }),
"the modal re-query shows the milestone with its target date"
);
let options = app.picker_options(FormPickerField::Milestones);
assert!(
options.iter().any(|o| o.id == "Beta"),
"a freshly built picker lists the new milestone (no restart)"
);
}
#[test]
fn planning_form_date_row_masked_typing_and_picker() {
let (_tmp, mut app) = make_app_with_planning();
let _ = app.handle_key(press(KeyCode::Char('m'))).unwrap();
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
for c in "Gamma".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::DatePicker { .. }));
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(matches!(app.mode, Mode::PlanningForm));
for c in "20261130".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
assert_eq!(app.planning_form.as_ref().unwrap().date_a(), "2026-11-30");
let _ = app.handle_key(press(KeyCode::Backspace)).unwrap();
assert_eq!(app.planning_form.as_ref().unwrap().date_a(), "2026-11-3");
let _ = app.handle_key(press(KeyCode::Char('0'))).unwrap();
assert_eq!(app.planning_form.as_ref().unwrap().date_a(), "2026-11-30");
let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
assert!(matches!(app.mode, Mode::DatePicker { .. }));
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert_eq!(app.planning_form.as_ref().unwrap().date_a(), "2026-11-30");
}
#[test]
fn planning_form_creates_cycle_visible_to_pickers() {
let (_tmp, mut app) = make_app_with_planning();
let _ = app.handle_key(press(KeyCode::Char('m'))).unwrap();
let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
for c in "Cycle 9".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let options = app.picker_options(crate::app::FormPickerField::Cycles);
assert!(
options.iter().any(|o| o.id == "Cycle 9"),
"a freshly built cycle picker lists the new cycle"
);
}
#[test]
fn planning_form_edits_cycle_fields() {
let (_tmp, mut app) = make_app_with_planning();
let _ = app.handle_key(press(KeyCode::Char('m'))).unwrap();
let _ = app.handle_key(press(KeyCode::Char('e'))).unwrap();
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
for _ in 0..7 {
let _ = app.handle_key(press(KeyCode::Backspace)).unwrap();
}
for c in "sprint".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let cycle = &app.store.config.cycles["Cycle 1"];
assert_eq!(cycle.description.as_deref(), Some("sprint"));
assert_eq!(cycle.start_date.as_deref(), Some("2026-10-01"));
assert_eq!(cycle.due_date.as_deref(), Some("2999-12-31"));
assert!(
app.store.config.cycles.contains_key("Cycle 1")
&& !app.store.config.cycles.contains_key("sprint"),
"field edits never rename the cycle"
);
}
#[test]
fn planning_form_renames_cycle_cascade_rewrites_issues_and_refreshes() {
let (tmp, mut app) = make_app_with_planning();
let _ = app.handle_key(press(KeyCode::Char('m'))).unwrap();
let _ = app.handle_key(press(KeyCode::Char('e'))).unwrap();
for _ in 0..7 {
let _ = app.handle_key(press(KeyCode::Backspace)).unwrap();
}
for c in "Cycle One".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(
!app.store.config.cycles.contains_key("Cycle 1"),
"old name gone"
);
assert!(
app.store.config.cycles.contains_key("Cycle One"),
"new name persisted"
);
let issue = app.store.get(IssueSelector::Number(1)).unwrap();
assert_eq!(issue.cycles.unwrap(), vec!["Cycle One".to_string()]);
let (cycles, _) = app.planning_rows();
assert!(
cycles.iter().any(|r| r.name == "Cycle One"),
"the modal re-query shows the renamed cycle"
);
let options = app.picker_options(crate::app::FormPickerField::Cycles);
assert!(options.iter().any(|o| o.id == "Cycle One"));
assert!(!options.iter().any(|o| o.id == "Cycle 1"));
app.apply_filter("cycle:One");
let hit: usize = (0..app.board.total_cols())
.map(|c| app.board.column_cards(c).len())
.sum();
assert_eq!(hit, 1, "the board corpus picks up the renamed cycle");
let raw = std::fs::read_to_string(tmp.path().join("issues/alice/2026-01-01-a.md")).unwrap();
assert!(
raw.contains("Cycle One"),
"issue file carries the new spelling"
);
assert!(!raw.contains("Cycle 1"), "stale spelling fully rewritten");
}
#[test]
fn planning_form_duplicate_name_surfaces_error_keeps_form() {
let (_tmp, mut app) = make_app_with_planning();
let _ = app.handle_key(press(KeyCode::Char('m'))).unwrap();
let _ = app.handle_key(press(KeyCode::Char('e'))).unwrap();
for _ in 0..7 {
let _ = app.handle_key(press(KeyCode::Backspace)).unwrap();
}
for c in "Cycle 2".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::PlanningForm));
let status = app.status_msg.as_deref().unwrap_or("");
assert!(
status.contains("already exists"),
"duplicate names surface an error status: {status:?}"
);
assert!(app.store.config.cycles.contains_key("Cycle 1"));
assert!(app.store.config.cycles.contains_key("Cycle 2"));
assert_eq!(
app.store.config.cycles.len(),
2,
"the rejected rename changed nothing"
);
}
#[test]
fn planning_form_renames_milestone_cascade_edge_to_edge() {
let (tmp, mut app) = make_app_with_planning();
let _ = app.handle_key(press(KeyCode::Char('m'))).unwrap();
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
let _ = app.handle_key(press(KeyCode::Char('e'))).unwrap();
for _ in 0..2 {
let _ = app.handle_key(press(KeyCode::Backspace)).unwrap();
}
for c in "GAX".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
for _ in 0..7 {
let _ = app.handle_key(press(KeyCode::Backspace)).unwrap();
}
for c in "beta".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
assert!(matches!(app.mode, Mode::DatePicker { .. }));
let _ = app.handle_key(press(KeyCode::PageDown)).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let milestones_dir = tmp.path().join("docs/milestones");
let files: Vec<String> = milestones_dir
.read_dir()
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().to_string())
.collect();
assert!(
files.iter().any(|f| f.ends_with("-gax.md")),
"the milestone registry file is renamed: {files:?}"
);
assert!(
!files.iter().any(|f| f.ends_with("-ga.md")),
"the old registry file is gone: {files:?}"
);
let issue = app.store.get(IssueSelector::Number(1)).unwrap();
assert_eq!(issue.milestones.unwrap(), vec!["GAX".to_string()]);
let planner = Planner::load_from(&tmp.path().join("tix.toml")).unwrap();
let ms = planner.milestone("GAX").unwrap();
assert_eq!(ms.target_date.as_str(), "2027-01-31");
assert!(ms.description.contains("beta"), "description was edited");
}
#[test]
fn planning_delete_confirm_removes_cycle_and_refreshes_grouping() {
let (_tmp, mut app) = make_app_with_planning();
let _ = app.handle_key(press(KeyCode::Char('m'))).unwrap();
let _ = app.handle_key(press(KeyCode::Char('x'))).unwrap();
match &app.planning.as_ref().unwrap().prompt {
Some(crate::ui::planning::PlanningPrompt::ConfirmDelete { name, referencing }) => {
assert_eq!(name, "Cycle 1");
assert_eq!(*referencing, 1, "the confirm prompt reports the count");
}
other => panic!("x raises a confirm prompt, got {other:?}"),
}
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(
app.store.config.cycles.contains_key("Cycle 1"),
"cancelling keeps the item"
);
let _ = app.handle_key(press(KeyCode::Char('x'))).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(!app.store.config.cycles.contains_key("Cycle 1"));
assert!(app.store.config.cycles.contains_key("Cycle 2"));
let (cycles, _) = app.planning_rows();
assert!(
cycles.iter().all(|r| r.name != "Cycle 1"),
"the modal re-query drops the deleted cycle"
);
let issue = app.store.get(IssueSelector::Number(1)).unwrap();
assert_eq!(
issue.cycles.unwrap(),
vec!["Cycle 1".to_string()],
"references preserved"
);
let options = app.picker_options(crate::app::FormPickerField::Cycles);
assert!(
!options.iter().any(|o| o.id == "Cycle 1"),
"no stale picker entry"
);
app.apply_filter("cycle:Cycle");
let hit: usize = (0..app.board.total_cols())
.map(|c| app.board.column_cards(c).len())
.sum();
assert_eq!(hit, 1, "the referencing issue stays grouped on the board");
}
#[test]
fn planning_delete_confirm_removes_milestone_file_preserves_issues() {
let (tmp, mut app) = make_app_with_planning();
let _ = app.handle_key(press(KeyCode::Char('m'))).unwrap();
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
let _ = app.handle_key(press(KeyCode::Char('x'))).unwrap();
match &app.planning.as_ref().unwrap().prompt {
Some(crate::ui::planning::PlanningPrompt::ConfirmDelete { name, referencing }) => {
assert_eq!(name, "GA");
assert_eq!(*referencing, 1);
}
other => panic!("x raises a confirm prompt, got {other:?}"),
}
let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
let milestones_dir = tmp.path().join("docs/milestones");
assert!(
milestones_dir.join("2026-01-01-ga.md").exists(),
"cancel keeps the milestone file"
);
let _ = app.handle_key(press(KeyCode::Char('x'))).unwrap();
let _ = app.handle_key(press(KeyCode::Char('y'))).unwrap();
assert!(
!milestones_dir.join("2026-01-01-ga.md").exists(),
"confirm removes the registry file"
);
let (_, milestones) = app.planning_rows();
assert!(milestones.is_empty(), "the milestone segment is now empty");
let issue = app.store.get(IssueSelector::Number(1)).unwrap();
assert_eq!(issue.milestones.unwrap(), vec!["GA".to_string()]);
let options = app.picker_options(FormPickerField::Milestones);
assert!(
!options.iter().any(|o| o.id == "GA"),
"no stale picker entry"
);
}
#[test]
fn startup_shows_rollover_dialog_when_plan_exists() {
let (_tmp, app) = make_app_with_rollover_dialog();
assert!(matches!(app.mode, Mode::RolloverDialog));
assert!(app.rollover_dialog.is_some());
}
#[test]
fn rollover_dialog_enter_applies_rollover() {
let (_tmp, mut app) = make_app_with_rollover_dialog();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Board));
let issue = app.store.list().unwrap().into_iter().next().unwrap();
let cycles = issue.cycles.unwrap_or_default();
assert!(cycles.iter().any(|c| c == "Cycle 2"));
assert!(!cycles.iter().any(|c| c == "Cycle 1"));
}
#[test]
fn quit_on_esc() {
let (_tmp, mut app) = make_app();
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(app.quit);
}
#[test]
fn cursor_clamp_empty_column() {
let (_tmp, mut app) = make_app();
app.board.move_left();
assert_eq!(app.board.active_col, app.board.total_cols() - 1);
app.board.move_right();
assert_eq!(app.board.active_col, 0);
}
#[test]
fn cursor_clamp_single_card() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
assert_eq!(app.board.column_cards(0).len(), 1);
app.board.move_down();
assert_eq!(app.board.selected, 0);
app.board.move_up();
assert_eq!(app.board.selected, 0);
}
#[test]
fn wrap_around_columns() {
let (_tmp, mut app) = make_app();
let total = app.board.total_cols();
for _ in 0..total + 2 {
app.board.move_right();
}
assert_eq!(app.board.active_col, 2 % total);
for _ in 0..total + 2 {
app.board.move_left();
}
}
#[test]
fn refresh_from_disk_picks_up_new_file() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
assert_eq!(app.board.column_cards(0).len(), 0);
write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
app.refresh_from_disk().unwrap();
assert_eq!(app.board.column_cards(0).len(), 1);
}
#[test]
fn refresh_from_disk_reflects_external_status_move() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
app.refresh_from_disk().unwrap();
assert_eq!(app.board.column_cards(0).len(), 1);
write_issue(&dir, "2026-01-01-a.md", 1, "A", "done", None);
app.refresh_from_disk().unwrap();
assert_eq!(app.board.column_cards(0).len(), 0);
assert_eq!(app.board.column_cards(3).len(), 1);
}
#[test]
fn refresh_from_disk_keeps_board_on_bad_file() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
app.refresh_from_disk().unwrap();
assert_eq!(app.board.column_cards(0).len(), 1);
fs::write(dir.join("2026-01-01-bad.md"), "not frontmatter").unwrap();
assert!(app.refresh_from_disk().is_err());
assert_eq!(
app.board.column_cards(0).len(),
1,
"a malformed file must not wipe the board"
);
}
#[test]
fn enter_opens_detail() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Detail));
assert!(app.detail.is_some());
}
#[test]
fn n_opens_form() {
let (_tmp, mut app) = make_app();
let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
assert!(matches!(app.mode, Mode::Form));
assert!(app.form.is_some());
}
fn filter_modal_focus(app: &App) -> crate::search::FilterField {
app.filter_modal
.as_ref()
.expect("filter modal open")
.focus()
}
#[test]
fn filter_modal_arrows_navigate_rows() {
let (_tmp, mut app) = make_app();
let _ = app.handle_key(press(KeyCode::Char('f'))).unwrap();
assert!(matches!(app.mode, Mode::FilterModal));
use crate::search::FilterField::*;
assert_eq!(filter_modal_focus(&app), Assignee);
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
assert_eq!(filter_modal_focus(&app), Label);
let _ = app.handle_key(press(KeyCode::Up)).unwrap();
assert_eq!(filter_modal_focus(&app), Assignee);
for _ in 0..crate::search::FilterField::ALL.len() {
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
}
assert_eq!(filter_modal_focus(&app), Assignee, "Down wraps forward");
let _ = app.handle_key(press(KeyCode::Up)).unwrap();
assert_eq!(
filter_modal_focus(&app),
Category,
"Up wraps backward, to the last row — which is now `Category` (WP08 \
appended it after `Parent`)"
);
}
#[test]
fn filter_modal_arrow_press_keeps_criterion_value() {
let (_tmp, mut app) = make_app();
let _ = app.handle_key(press(KeyCode::Char('f'))).unwrap();
for c in "alice".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
let before = app
.filter_modal
.as_ref()
.unwrap()
.active_filter()
.assignee
.clone();
assert_eq!(before.as_deref(), Some("alice"));
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
let after = app
.filter_modal
.as_ref()
.unwrap()
.active_filter()
.assignee
.clone();
assert_eq!(after, before, "arrow press must not alter the value");
use crate::search::FilterField::*;
assert_eq!(filter_modal_focus(&app), Label);
}
#[test]
fn filter_modal_j_and_k_type_their_letters() {
let (_tmp, mut app) = make_app();
let _ = app.handle_key(press(KeyCode::Char('f'))).unwrap();
let _ = app.handle_key(press(KeyCode::Char('j'))).unwrap();
let _ = app.handle_key(press(KeyCode::Char('k'))).unwrap();
let modal = app.filter_modal.as_ref().unwrap();
assert_eq!(
modal.active_filter().assignee.as_deref(),
Some("jk"),
"j and k must type into the focused criterion, not navigate"
);
use crate::search::FilterField::*;
assert_eq!(modal.focus(), Assignee);
}
#[test]
fn filter_modal_date_field_masked_typing_and_picker() {
let (_tmp, mut app) = make_app();
let _ = app.handle_key(press(KeyCode::Char('f'))).unwrap();
use crate::search::FilterField::*;
for _ in 0..7 {
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
}
assert_eq!(filter_modal_focus(&app), StartDate);
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::DatePicker { .. }));
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(matches!(app.mode, Mode::FilterModal));
for c in "20260910".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
assert_eq!(
app.filter_modal
.as_ref()
.unwrap()
.active_filter()
.start_date
.as_deref(),
Some("2026-09-10")
);
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Board));
assert_eq!(app.structured.start_date.as_deref(), Some("2026-09-10"));
}
#[test]
fn d_opens_confirm_delete() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
let _ = app.handle_key(press(KeyCode::Char('d'))).unwrap();
assert!(matches!(app.mode, Mode::ConfirmDelete));
assert!(app.confirm_delete_target.is_some());
}
#[test]
fn move_to_done_triggers_commit_prompt() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "A", "in review", None);
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
app.board.select_column(2);
app.board.selected = 0;
let _ = app.handle_key(press(KeyCode::Char('>'))).unwrap();
assert!(
matches!(
&app.mode,
Mode::MoveIntoDone { target_col: 3, buffer } if buffer.is_empty()
),
"expected MoveIntoDone, got {:?}",
app.mode
);
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(matches!(app.mode, Mode::Board));
let issue = app
.store
.get(crate::storage::IssueSelector::Filename(
"2026-01-01-a.md".into(),
))
.unwrap();
assert_eq!(issue.status.display(), "in review");
assert!(issue.commit.is_none());
}
#[test]
fn move_into_done_with_commit_applies() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "A", "in review", None);
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
app.board.select_column(2);
app.board.selected = 0;
let _ = app.handle_key(press(KeyCode::Char('>'))).unwrap();
assert!(matches!(&app.mode, Mode::MoveIntoDone { .. }));
for c in "abc123".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Board));
let issue = app
.store
.get(crate::storage::IssueSelector::Filename(
"2026-01-01-a.md".into(),
))
.unwrap();
assert_eq!(issue.status.display(), "done");
assert_eq!(issue.commit.as_deref(), Some("abc123"));
}
#[test]
fn move_into_done_with_empty_commit_moves_without_hash() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "A", "in review", None);
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
app.board.select_column(2);
app.board.selected = 0;
let _ = app.handle_key(press(KeyCode::Char('>'))).unwrap();
assert!(matches!(&app.mode, Mode::MoveIntoDone { .. }));
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Board));
let issue = app
.store
.get(crate::storage::IssueSelector::Filename(
"2026-01-01-a.md".into(),
))
.unwrap();
assert_eq!(issue.status.display(), "done");
assert!(
issue.commit.is_none(),
"no commit recorded when moving to done without a hash"
);
}
#[test]
fn move_out_of_done_clears_commit() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "A", "done", Some("abc123"));
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
app.board.select_column(3);
app.board.selected = 0;
let _ = app.handle_key(press(KeyCode::Char('<'))).unwrap();
assert!(matches!(app.mode, Mode::Board));
let issue = app
.store
.get(crate::storage::IssueSelector::Filename(
"2026-01-01-a.md".into(),
))
.unwrap();
assert_eq!(issue.status.display(), "in review");
assert!(issue.commit.is_none());
}
#[test]
fn move_right_wraps_across_real_columns_skipping_mismatch() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "A", "done", None);
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
app.board.select_column(3); app.board.selected = 0;
let _ = app.handle_key(press(KeyCode::Char('>'))).unwrap();
assert_eq!(app.board.active_col, 0);
let issue = app
.store
.get(crate::storage::IssueSelector::Filename(
"2026-01-01-a.md".into(),
))
.unwrap();
assert_eq!(issue.status.display(), "todo");
}
#[test]
fn move_left_wraps_across_real_columns_skipping_mismatch() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", Some("abc"));
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
app.board.select_column(0);
app.board.selected = 0;
let _ = app.handle_key(press(KeyCode::Char('<'))).unwrap();
assert_eq!(app.board.active_col, 3);
let issue = app
.store
.get(crate::storage::IssueSelector::Filename(
"2026-01-01-a.md".into(),
))
.unwrap();
assert_eq!(issue.status.display(), "done");
}
#[test]
fn move_left_right_board() {
let (_tmp, mut app) = make_app();
let initial = app.board.active_col;
let _ = app.handle_key(press(KeyCode::Char('l'))).unwrap();
assert_eq!(app.board.active_col, initial + 1);
let _ = app.handle_key(press(KeyCode::Char('h'))).unwrap();
assert_eq!(app.board.active_col, initial);
}
#[test]
fn refresh_rebuilds_board() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
let _ = app.handle_key(press(KeyCode::Char('r'))).unwrap();
assert_eq!(app.board.column_cards(0).len(), 1);
assert_eq!(app.status_msg, Some("Refreshed".to_string()));
}
#[test]
fn detail_esc_back_to_board() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Detail));
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(matches!(app.mode, Mode::Board));
assert!(app.detail.is_none());
}
#[test]
fn confirm_delete_y_removes_issue() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
let _ = app.handle_key(press(KeyCode::Char('d'))).unwrap();
assert!(matches!(app.mode, Mode::ConfirmDelete));
let _ = app.handle_key(press(KeyCode::Char('y'))).unwrap();
assert!(matches!(app.mode, Mode::Board));
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
assert_eq!(app.board.column_cards(0).len(), 0);
}
#[test]
fn confirm_delete_n_cancels() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
let _ = app.handle_key(press(KeyCode::Char('d'))).unwrap();
let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
assert!(matches!(app.mode, Mode::Board));
assert!(app.confirm_delete_target.is_none());
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
assert_eq!(app.board.column_cards(0).len(), 1);
}
#[test]
fn form_submit_creates_issue() {
let (_tmp, mut app) = make_app();
let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
if let Mode::Form = &app.mode {
for c in "My Issue".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
}
assert!(matches!(app.mode, Mode::Board));
assert!(
app.status_msg
.as_deref()
.is_some_and(|m| m.starts_with("Created ")),
"got {:?}",
app.status_msg
);
}
#[test]
fn form_submit_selects_created_issue() {
let (_tmp, mut app) = make_app();
let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
if let Mode::Form = &app.mode {
for c in "Selected Issue".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
}
assert!(matches!(app.mode, Mode::Board));
let card = app.board.get_selected_card().expect("selected card");
assert_eq!(card.title, "Selected Issue");
assert_eq!(app.board.active_col, 0);
}
#[test]
fn form_submit_selects_created_issue_in_assignee_segment() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "Old", "todo", None);
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
if let Mode::Form = &app.mode {
for c in "New".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
}
app.form.as_mut().unwrap().assignee = "alice".to_string();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Board));
let card = app.board.get_selected_card().expect("selected card");
assert_eq!(
card.title, "New",
"the just-created issue is selected, not the previously selected one"
);
assert!(card.filename.starts_with("alice/"), "got {}", card.filename);
}
#[test]
fn editor_return_warns_on_invalid_issue() {
let (_tmp, mut app) = make_app();
let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
if let Mode::Form = &app.mode {
for c in "Lint Warn".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
}
let issue = app
.board
.get_selected_card()
.cloned()
.expect("selected issue");
let path = app.store.issues_dir.join(&issue.filename);
let mut text = std::fs::read_to_string(&path).unwrap();
let before = text.clone();
text = text
.lines()
.map(|l| {
if l.starts_with("priority:") {
"priority: strange"
} else {
l
}
})
.collect::<Vec<_>>()
.join("\n");
assert_ne!(
text, before,
"the fixture must actually change the priority"
);
std::fs::write(&path, text).unwrap();
app.after_editor(&issue.filename);
assert!(matches!(app.mode, Mode::EditLintWarning { .. }));
}
#[test]
fn form_empty_title_rejected() {
let (_tmp, mut app) = make_app();
let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Form));
assert_eq!(app.status_msg.as_deref(), Some("Title is required"));
}
#[test]
fn form_esc_cancels() {
let (_tmp, mut app) = make_app();
let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
assert!(matches!(app.mode, Mode::Form));
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(matches!(app.mode, Mode::Board));
}
fn form_focus(app: &App) -> crate::ui::form::FormFocus {
app.form.as_ref().expect("form open").focus
}
#[test]
fn form_arrows_navigate_fields() {
let (_tmp, mut app) = make_app();
let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
use crate::ui::form::FormFocus::*;
assert_eq!(form_focus(&app), Title);
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
assert_eq!(form_focus(&app), Status);
let _ = app.handle_key(press(KeyCode::Up)).unwrap();
assert_eq!(form_focus(&app), Title);
for _ in 0..crate::ui::form::FormFocus::RING.len() {
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
}
assert_eq!(form_focus(&app), Title, "Down wraps forward");
let _ = app.handle_key(press(KeyCode::Up)).unwrap();
assert_eq!(
form_focus(&app),
*crate::ui::form::FormFocus::RING
.last()
.expect("RING is non-empty"),
"Up wraps backward, from the last field in the ring"
);
}
#[test]
fn form_j_and_k_type_their_letters() {
let (_tmp, mut app) = make_app();
let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
let _ = app.handle_key(press(KeyCode::Char('j'))).unwrap();
let _ = app.handle_key(press(KeyCode::Char('k'))).unwrap();
let form = app.form.as_ref().unwrap();
assert_eq!(form.title, "jk", "j and k must type, not navigate");
assert_eq!(form.focus, crate::ui::form::FormFocus::Title);
}
#[test]
fn form_arrow_navigated_status_creates_in_that_column() {
let (_tmp, mut app) = make_app();
let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
for c in "Arrow Status".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
let prefilled = app.store.config.default_config().columns[0].len();
for _ in 0..prefilled {
let _ = app.handle_key(press(KeyCode::Backspace)).unwrap();
}
for c in "in progress".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Board));
let names: Vec<String> = (0..app.board.total_cols())
.map(|i| app.board.column_name(i).to_string())
.collect();
let idx = names
.iter()
.position(|n| n == "in progress")
.expect("in progress column exists");
assert_eq!(
app.board.column_cards(idx).len(),
1,
"arrow-navigated status must land the issue in 'in progress'"
);
}
#[test]
fn form_invalid_date_order_is_rejected() {
let (_tmp, mut app) = make_app();
let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
for c in "Date check".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
for _ in 0..9 {
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
}
let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
assert!(matches!(app.mode, Mode::DatePicker { .. }));
let _ = app.handle_key(press(KeyCode::PageDown)).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Form));
assert_eq!(
app.status_msg.as_deref(),
Some("Due date must be >= start date")
);
}
#[test]
fn form_persists_planning_fields() {
let (_tmp, mut app) = make_app();
let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
for c in "Planning fields".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
for c in "Cycle 1,Cycle 2".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
for c in "Backlog".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
for c in "alice".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Board));
let issues = app.store.list().unwrap();
assert_eq!(issues.len(), 1);
let issue = &issues[0];
assert_eq!(
issue.cycles.as_deref(),
Some(&["Cycle 1".to_string(), "Cycle 2".to_string()][..])
);
assert_eq!(
issue.milestones.as_deref(),
Some(&["Backlog".to_string()][..])
);
assert_eq!(issue.reviewer.as_deref(), Some("alice"));
assert_eq!(issue.start_date.as_deref(), Some(today().as_str()));
assert_eq!(issue.due_date.as_deref(), Some(today().as_str()));
}
#[test]
fn form_date_field_masked_typing_and_picker() {
let (_tmp, mut app) = make_app();
let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
for c in "Dated".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
for _ in 0..9 {
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
}
assert!(app.form.as_ref().unwrap().is_date_focus());
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::DatePicker { .. }));
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(matches!(app.mode, Mode::Form));
for c in "20260910".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
assert_eq!(app.form.as_ref().unwrap().start_date, "2026-09-10");
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Board));
let issues = app.store.list().unwrap();
let issue = issues
.iter()
.find(|i| i.title == "Dated")
.expect("the form created the issue");
assert_eq!(issue.start_date.as_deref(), Some("2026-09-10"));
}
#[test]
fn form_date_picker_select_and_clear() {
let (_tmp, mut app) = make_app();
let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
for c in "Dated".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
for _ in 0..9 {
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
}
let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
assert!(matches!(app.mode, Mode::DatePicker { .. }));
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Form));
assert_eq!(app.form.as_ref().unwrap().start_date, today());
let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert_eq!(
app.form.as_ref().unwrap().start_date,
today(),
"Esc leaves the date unchanged"
);
let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
let _ = app.handle_key(press(KeyCode::Char('c'))).unwrap();
assert_eq!(
app.form.as_ref().unwrap().start_date,
"",
"c clears the date field"
);
}
fn drawn_date_picker_day(app: &App, target: &str) -> (u16, u16) {
use ratatui::backend::TestBackend;
let backend = TestBackend::new(app.frame_size.get().width, app.frame_size.get().height);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal
.draw(|f| crate::app::render(app, f, f.area()))
.unwrap();
let picker = app.date_picker.as_ref().expect("picker open");
let rect = picker.popup_rect(app.frame_size.get());
let days_y = rect.y + 3;
let weeks = rect.height.saturating_sub(6);
for row in 0..weeks {
for col in 0..7u16 {
let x = rect.x + 1 + col * 3 + 1;
let y = days_y + row;
if picker
.date_at(app.frame_size.get(), x, y)
.map(crate::ui::date_picker::format_date)
.as_deref()
== Some(target)
{
return (x, y);
}
}
}
panic!("no day cell found for {target}");
}
#[test]
fn date_picker_day_click_changes_selected_date() {
let (_tmp, mut app) = make_app();
app.frame_size.set(Rect::new(0, 0, 80, 24));
let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
for _ in 0..9 {
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
}
app.form.as_mut().unwrap().start_date = "2026-03-15".into();
let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
assert!(matches!(app.mode, Mode::DatePicker { .. }));
let (x, y) = drawn_date_picker_day(&app, "2026-03-16");
let action = app.handle_mouse(down_left(x, y)).unwrap();
assert_eq!(
action,
Action::DatePickerHit(DatePickerHit::Day("2026-03-16".into()))
);
if let Action::DatePickerHit(DatePickerHit::Day(date)) = action {
app.date_picker.as_mut().unwrap().select_date_str(&date);
}
assert_eq!(app.date_picker.as_ref().unwrap().selected(), "2026-03-16");
}
#[test]
fn date_picker_ok_click_submits_selected_date() {
let (_tmp, mut app) = make_app();
app.frame_size.set(Rect::new(0, 0, 80, 24));
let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
for _ in 0..9 {
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
}
app.form.as_mut().unwrap().start_date = "2026-03-15".into();
let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
let (x, y) = drawn_date_picker_day(&app, "2026-03-16");
let _ = app.handle_mouse(down_left(x, y)).unwrap();
app.date_picker
.as_mut()
.unwrap()
.select_date_str("2026-03-16");
let picker = app.date_picker.as_ref().unwrap();
let rect = picker.popup_rect(app.frame_size.get());
let ok_x = rect.x + rect.width / 2;
let ok_y = rect.y + rect.height.saturating_sub(1);
let action = app.handle_mouse(down_left(ok_x, ok_y)).unwrap();
assert_eq!(action, Action::DatePickerHit(DatePickerHit::Submit));
let _ = app.handle_date_picker_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Form));
assert_eq!(app.form.as_ref().unwrap().start_date, "2026-03-16");
}
#[test]
fn form_non_date_field_still_opens_list_picker() {
let (_tmp, mut app) = make_app();
let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
for _ in 0..6 {
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
}
let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
assert!(
app.form_picker.is_some(),
"cycles field opens the list picker"
);
assert!(!matches!(app.mode, Mode::DatePicker { .. }));
}
#[test]
fn form_picker_inline_create_cycle_persists_and_populates_field() {
let (_tmp, mut app) = make_app();
let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
for c in "Picker cycle".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
for _ in 0..6 {
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
}
let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
assert!(app.form_picker.is_some(), "picker should open on cycles");
let _ = app.handle_key(press(KeyCode::Char('+'))).unwrap();
for c in "Cycle 42".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let form = app.form.as_ref().unwrap();
assert!(
form.cycles.split(',').any(|c| c.trim() == "Cycle 42"),
"created cycle should populate form field, got {:?}",
form.cycles
);
assert!(
app.store.config.cycles.contains_key("Cycle 42"),
"new cycle should be persisted in config"
);
let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Board));
let issue = app.store.list().unwrap().into_iter().next().unwrap();
assert!(
issue
.cycles
.unwrap_or_default()
.iter()
.any(|c| c == "Cycle 42")
);
}
#[test]
fn form_picker_selects_existing_label() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
let seed = "---\nnumber: 1\ntitle: \"Seed\"\nstatus: \"todo\"\nlabels: [\"bug\"]\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody text.";
fs::write(dir.join("2026-01-01-seed.md"), seed).unwrap();
app.rebuild_board();
let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
for c in "Picker labels".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
for _ in 0..3 {
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
}
let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Board));
let issues = app.store.list().unwrap();
let created = issues
.into_iter()
.find(|i| i.title == "Picker labels")
.expect("created issue");
assert!(created.labels.iter().any(|l| l == "bug"));
}
#[test]
fn key_release_ignored() {
let (_tmp, mut app) = make_app();
let key = KeyEvent {
code: KeyCode::Char('q'),
modifiers: crossterm::event::KeyModifiers::NONE,
kind: KeyEventKind::Release,
state: crossterm::event::KeyEventState::NONE,
};
let _ = app.handle_key(key).unwrap();
assert!(!app.quit);
}
#[test]
fn handle_mouse_in_mode_without_mouse_support_is_noop() {
let (_tmp, mut app) = make_app();
app.mode = Mode::Filter {
buffer: "query".into(),
};
let before = app.mode.clone();
let ev = MouseEvent {
kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
column: 1,
row: 1,
modifiers: crossterm::event::KeyModifiers::NONE,
};
let action = app.handle_mouse(ev).unwrap();
assert_eq!(action, Action::None);
assert_eq!(app.mode, before);
}
#[test]
fn frame_hit_regions_are_empty_until_recorded_and_resolve_to_actions() {
let (_tmp, app) = make_app();
assert!(
app.frame_hit_regions.borrow().is_empty(),
"no regions before a draw"
);
let rect_a = Rect::new(0, 0, 10, 5);
let rect_b = Rect::new(2, 1, 2, 2);
app.record_region(rect_a, Action::OpenEditor("a.md".into()));
app.record_region(rect_b, Action::Quit);
assert_eq!(app.frame_hit_regions.borrow().len(), 2);
assert_eq!(
app.resolve_region(0, 0),
Some(Action::OpenEditor("a.md".into()))
);
assert_eq!(app.resolve_region(3, 2), Some(Action::Quit));
assert_eq!(app.resolve_region(11, 2), None);
assert_eq!(app.resolve_region(3, 6), None);
}
#[test]
fn draw_clears_regions_so_stale_targets_never_fire() {
use ratatui::backend::TestBackend;
let (_tmp, app) = make_app();
app.record_region(Rect::new(0, 0, 10, 5), Action::Quit);
app.record_region(Rect::new(0, 0, 10, 5), Action::Quit);
let mut terminal = Terminal::new(TestBackend::new(80, 40)).unwrap();
terminal.draw(|f| render(&app, f, f.area())).unwrap();
assert!(
app.frame_hit_regions.borrow().is_empty(),
"a draw clears regions recorded before it"
);
}
fn make_app_with_cards(n: u32) -> (TempDir, App) {
let (tmp, mut app) = make_app();
let dir = app.store.config.issues_dir.clone();
for i in 1..=n {
write_issue(
&dir,
&format!("card-{i:02}.md"),
i,
&format!("Card {i:02}"),
"todo",
None,
);
}
app.rebuild_board();
(tmp, app)
}
fn make_mouse_app() -> (TempDir, App) {
let (tmp, mut app) = make_app();
app.mouse.double_click_window = Duration::from_secs(60);
let dir = app.store.config.issues_dir.clone();
write_issue(&dir, "a.md", 1, "Alpha", "todo", None);
write_issue(&dir, "b.md", 2, "Beta", "todo", None);
write_issue(&dir, "c.md", 3, "Gamma", "in progress", None);
write_issue(&dir, "d.md", 4, "Delta", "in review", None);
write_issue(&dir, "e.md", 5, "Epsilon", "done", Some("abc123"));
app.rebuild_board();
app.frame_size.set(Rect::new(0, 0, 80, 40));
(tmp, app)
}
fn make_sparse_order_mouse_app() -> (TempDir, App) {
let (tmp, mut app) = make_app();
app.mouse.double_click_window = Duration::from_secs(60);
let dir = app.store.config.issues_dir.clone();
write_issue(&dir, "a.md", 1, "Alpha", "todo", None);
write_issue(&dir, "b.md", 2, "Beta", "todo", None);
write_issue(&dir, "c.md", 3, "Gamma", "todo", None);
write_issue(&dir, "d.md", 4, "Delta", "todo", None);
app.rebuild_board();
app.frame_size.set(Rect::new(0, 0, 80, 40));
(tmp, app)
}
fn make_blocked_mouse_app() -> (TempDir, App) {
let (tmp, mut app) = make_mouse_app();
app.store
.link("1", "2", crate::relationships::RelationKind::Blocks)
.unwrap();
app.rebuild_board();
(tmp, app)
}
fn done_col(app: &App) -> usize {
let name = app
.store
.config
.default_config()
.effective_done_column()
.expect("the fixture board has a done column");
(0..app.board.total_cols())
.find(|c| app.board.column_name(*c) == name)
.unwrap_or_else(|| panic!("no column named {name}"))
}
fn down_left(x: u16, y: u16) -> MouseEvent {
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: x,
row: y,
modifiers: crossterm::event::KeyModifiers::NONE,
}
}
fn down_right(x: u16, y: u16) -> MouseEvent {
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Right),
column: x,
row: y,
modifiers: crossterm::event::KeyModifiers::NONE,
}
}
fn up_left(x: u16, y: u16) -> MouseEvent {
MouseEvent {
kind: MouseEventKind::Up(MouseButton::Left),
column: x,
row: y,
modifiers: crossterm::event::KeyModifiers::NONE,
}
}
fn drag_event(x: u16, y: u16) -> MouseEvent {
MouseEvent {
kind: MouseEventKind::Drag(MouseButton::Left),
column: x,
row: y,
modifiers: crossterm::event::KeyModifiers::NONE,
}
}
fn card_cell(app: &App, col: usize, row: usize) -> (u16, u16) {
let board_area = app.board_mouse_area(app.frame_size.get()).unwrap();
let rect = board::compute_column_rects(board_area, app.board.total_cols())[col];
let mut y = rect.y + 1;
for before in 0..row {
y += crate::ui::widgets::card_line_count(
&app.board.columns[col].cards[before],
rect.width as usize,
true,
3,
);
}
(rect.x + 2, y)
}
fn column_cell(app: &App, col: usize) -> (u16, u16) {
let board_area = app.board_mouse_area(app.frame_size.get()).unwrap();
let rect = board::compute_column_rects(board_area, app.board.total_cols())[col];
(rect.x + 2, rect.y + rect.height - 2)
}
#[test]
fn a_drag_of_a_blocked_card_into_done_asks_before_it_writes() {
let (_tmp, mut app) = make_blocked_mouse_app();
let (sx, sy) = card_cell(&app, 0, 1); let (tx, ty) = column_cell(&app, done_col(&app));
app.handle_mouse(down_left(sx, sy)).unwrap();
app.handle_mouse(drag_event(tx, ty)).unwrap();
app.handle_mouse(up_left(tx, ty)).unwrap();
assert!(
matches!(app.mode, Mode::ConfirmBlockedMove { .. }),
"a drag into done must raise the acknowledgement prompt, got {:?}",
app.mode
);
app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(
app.board.column_cards(0).iter().any(|c| c.number == 2),
"a cancelled drag must not move the card"
);
}
#[test]
fn a_confirmed_blocked_drag_into_done_records_its_commit() {
let (_tmp, mut app) = make_blocked_mouse_app();
let dir = app.store.config.issues_dir.clone();
let (sx, sy) = card_cell(&app, 0, 1);
let (tx, ty) = column_cell(&app, done_col(&app));
app.handle_mouse(down_left(sx, sy)).unwrap();
app.handle_mouse(drag_event(tx, ty)).unwrap();
app.handle_mouse(up_left(tx, ty)).unwrap();
app.handle_key(press(KeyCode::Char('y'))).unwrap();
assert!(
matches!(app.mode, Mode::MoveIntoDone { .. }),
"confirming must still ask for the commit hash, got {:?}",
app.mode
);
for c in "deadbee".chars() {
app.handle_key(press(KeyCode::Char(c))).unwrap();
}
app.handle_key(press(KeyCode::Enter)).unwrap();
let file = fs::read_to_string(dir.join("b.md")).unwrap();
assert!(file.contains("commit: deadbee"), "commit recorded:\n{file}");
assert!(
file.contains("blocked_move_acknowledged"),
"and the acknowledgement is in the same record:\n{file}"
);
}
#[test]
fn a_context_menu_move_of_a_blocked_card_asks_before_it_writes() {
let (_tmp, mut app) = make_blocked_mouse_app();
let (sx, sy) = card_cell(&app, 0, 1); let target = done_col(&app);
app.handle_mouse(down_right(sx, sy)).unwrap();
assert!(
matches!(app.mode, Mode::ContextMenu { .. }),
"right-click opens the menu, got {:?}",
app.mode
);
let index = app
.context_menu
.as_ref()
.unwrap()
.items()
.iter()
.position(|i| i.kind == MenuItemKind::MoveTo { column: target })
.expect("the menu offers a move into the done column");
for _ in 0..index {
app.handle_key(press(KeyCode::Down)).unwrap();
}
app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(
matches!(app.mode, Mode::ConfirmBlockedMove { .. }),
"a menu move into done must raise the acknowledgement prompt, got {:?}",
app.mode
);
app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(
app.board.column_cards(0).iter().any(|c| c.number == 2),
"a cancelled menu move must not move the card"
);
}
#[test]
fn mouse_click_selects_card() {
let (_tmp, mut app) = make_mouse_app();
let (x, y) = card_cell(&app, 0, 1);
let action = app.handle_mouse(down_left(x, y)).unwrap();
assert_eq!(action, Action::None);
assert_eq!((app.board.active_col, app.board.selected), (0, 1));
assert_eq!(
app.board.get_selected_card().map(|c| c.number),
Some(2),
"second todo card selected"
);
}
#[test]
fn mouse_click_on_empty_column_space_selects_column() {
let (_tmp, mut app) = make_mouse_app();
let (x, y) = column_cell(&app, 1);
let action = app.handle_mouse(down_left(x, y)).unwrap();
assert_eq!(action, Action::None);
assert_eq!(app.board.active_col, 1);
}
#[test]
fn mouse_double_click_opens_detail() {
let (_tmp, mut app) = make_mouse_app();
let (x, y) = card_cell(&app, 0, 0);
app.handle_mouse(down_left(x, y)).unwrap();
app.handle_mouse(up_left(x, y)).unwrap();
app.handle_mouse(down_left(x, y)).unwrap();
assert!(matches!(app.mode, Mode::Detail));
assert_eq!(
app.detail.as_ref().map(|d| d.issue.number),
Some(1),
"double-clicked card detail is open"
);
}
#[test]
fn mouse_single_click_does_not_open_detail() {
let (_tmp, mut app) = make_mouse_app();
let (x, y) = card_cell(&app, 0, 0);
app.handle_mouse(down_left(x, y)).unwrap();
app.handle_mouse(up_left(x, y)).unwrap();
assert!(matches!(app.mode, Mode::Board));
assert!(app.detail.is_none());
}
#[test]
fn mouse_drag_moves_issue_to_another_real_column() {
let (_tmp, mut app) = make_mouse_app();
let (sx, sy) = card_cell(&app, 0, 1);
let target = column_cell(&app, 1);
app.handle_mouse(down_left(sx, sy)).unwrap();
app.handle_mouse(drag_event(target.0, target.1)).unwrap();
let action = app.handle_mouse(up_left(target.0, target.1)).unwrap();
assert_eq!(action, Action::None);
assert_eq!(app.board.active_col, 1);
assert!(
app.board.columns[1].cards.iter().any(|c| c.number == 2),
"Beta landed in 'in progress'"
);
assert!(
app.board.columns[0].cards.iter().all(|c| c.number != 2),
"Beta left 'todo'"
);
assert_eq!(app.status_msg.as_deref(), Some("Moved #2 to in progress"));
}
#[test]
fn mouse_drag_updates_preview_column_while_held() {
let (_tmp, mut app) = make_mouse_app();
let (sx, sy) = card_cell(&app, 0, 1);
let target = column_cell(&app, 1);
app.handle_mouse(down_left(sx, sy)).unwrap();
app.handle_mouse(drag_event(target.0, target.1)).unwrap();
assert_eq!(app.drag_preview_col, Some(1));
assert_eq!(app.board.hover, None);
app.handle_mouse(up_left(target.0, target.1)).unwrap();
assert_eq!(app.drag_preview_col, None);
}
#[test]
fn ctrl_up_and_down_reorder_the_selected_card() {
let (_tmp, mut app) = make_mouse_app();
app.board.active_col = 0;
app.board.selected = 1;
let up = app
.handle_key(press_with_modifiers(
KeyCode::Up,
crossterm::event::KeyModifiers::CONTROL,
))
.unwrap();
assert_eq!(up, Action::None);
assert_eq!(
app.board
.column_cards(0)
.iter()
.map(|c| c.title.as_str())
.collect::<Vec<_>>(),
["Beta", "Alpha"],
"ctrl-up moves the selected card one slot earlier"
);
assert_eq!(app.board.selected, 0);
let down = app
.handle_key(press_with_modifiers(
KeyCode::Down,
crossterm::event::KeyModifiers::CONTROL,
))
.unwrap();
assert_eq!(down, Action::None);
assert_eq!(
app.board
.column_cards(0)
.iter()
.map(|c| c.title.as_str())
.collect::<Vec<_>>(),
["Alpha", "Beta"],
"ctrl-down restores the original order"
);
}
#[test]
fn mouse_drop_on_source_column_is_noop() {
let (_tmp, mut app) = make_mouse_app();
let (sx, sy) = card_cell(&app, 0, 1);
let target = column_cell(&app, 0);
app.handle_mouse(down_left(sx, sy)).unwrap();
app.handle_mouse(drag_event(target.0, target.1)).unwrap();
app.handle_mouse(up_left(target.0, target.1)).unwrap();
assert!(
app.board.columns[0].cards.iter().any(|c| c.number == 2),
"Beta stayed in 'todo'"
);
}
#[test]
fn mouse_drag_reorders_cards_within_same_column_and_persists_order() {
let (_tmp, mut app) = make_mouse_app();
let (sx, sy) = card_cell(&app, 0, 1);
let (tx, ty) = card_cell(&app, 0, 0);
app.handle_mouse(down_left(sx, sy)).unwrap();
app.handle_mouse(drag_event(tx, ty)).unwrap();
let action = app.handle_mouse(up_left(tx, ty)).unwrap();
assert_eq!(action, Action::None);
assert_eq!(
app.board
.column_cards(0)
.iter()
.map(|c| c.title.as_str())
.collect::<Vec<_>>(),
["Beta", "Alpha"],
"dropping on another card in the same column reorders it"
);
let board_name = app.active_board_name().to_string();
let board_cfg = app
.store
.config
.kanban
.configurations
.get(&board_name)
.unwrap();
assert_eq!(
board_cfg.card_order,
vec!["b.md".to_string(), "a.md".to_string()],
"same-column drag persists the custom order in config"
);
}
#[test]
fn mouse_drag_persists_only_cards_that_differ_from_default_order() {
let (_tmp, mut app) = make_sparse_order_mouse_app();
let (sx, sy) = card_cell(&app, 0, 3);
let (tx, ty) = card_cell(&app, 0, 1);
app.handle_mouse(down_left(sx, sy)).unwrap();
app.handle_mouse(drag_event(tx, ty)).unwrap();
app.handle_mouse(up_left(tx, ty)).unwrap();
assert_eq!(
app.board
.column_cards(0)
.iter()
.map(|c| c.title.as_str())
.collect::<Vec<_>>(),
["Alpha", "Delta", "Beta", "Gamma"],
"dragging the last card into the middle updates the visible order"
);
let board_name = app.active_board_name().to_string();
let board_cfg = app
.store
.config
.kanban
.configurations
.get(&board_name)
.unwrap();
assert_eq!(
board_cfg.card_order,
vec!["d.md".to_string(), "b.md".to_string(), "c.md".to_string()],
"only the cards whose positions changed are persisted"
);
}
#[test]
fn mouse_drop_on_no_column_area_is_noop() {
let (_tmp, mut app) = make_mouse_app();
let (sx, sy) = card_cell(&app, 0, 0);
app.handle_mouse(down_left(sx, sy)).unwrap();
let outside = (sx, 0u16); app.handle_mouse(drag_event(outside.0, outside.1)).unwrap();
app.handle_mouse(up_left(outside.0, outside.1)).unwrap();
assert!(
app.board.columns[0].cards.iter().any(|c| c.number == 1),
"Alpha stayed in 'todo'"
);
assert_eq!(app.status_msg, None);
}
#[test]
fn mouse_drop_on_mismatch_column_is_noop() {
let (_tmp, mut app) = make_app();
app.mouse.double_click_window = Duration::from_secs(60);
let dir = app.store.config.issues_dir.clone();
write_issue(&dir, "x.md", 1, "Alpha", "todo", None);
write_issue(&dir, "w.md", 9, "Oddball", "weird", None);
app.rebuild_board();
app.board.show_mismatch = true;
app.frame_size.set(Rect::new(0, 0, 80, 40));
let (sx, sy) = card_cell(&app, 0, 0);
let mismatch_col = app.board.columns.len();
let target = column_cell(&app, mismatch_col);
app.handle_mouse(down_left(sx, sy)).unwrap();
app.handle_mouse(drag_event(target.0, target.1)).unwrap();
app.handle_mouse(up_left(target.0, target.1)).unwrap();
assert!(
app.board.columns[0].cards.iter().any(|c| c.number == 1),
"Alpha stayed in 'todo' (mismatched column is not a move target)"
);
}
#[test]
fn mouse_drag_into_done_without_commit_opens_prompt() {
let (_tmp, mut app) = make_mouse_app();
let (sx, sy) = card_cell(&app, 0, 0);
let target = column_cell(&app, 3); app.handle_mouse(down_left(sx, sy)).unwrap();
app.handle_mouse(drag_event(target.0, target.1)).unwrap();
app.handle_mouse(up_left(target.0, target.1)).unwrap();
assert!(
matches!(&app.mode, Mode::MoveIntoDone { target_col: 3, buffer } if buffer.is_empty()),
"drag into done without a commit opens the MoveIntoDone prompt, got {:?}",
app.mode
);
}
fn moved_to(x: u16, y: u16) -> MouseEvent {
MouseEvent {
kind: MouseEventKind::Moved,
column: x,
row: y,
modifiers: crossterm::event::KeyModifiers::NONE,
}
}
fn scroll_up(x: u16, y: u16) -> MouseEvent {
MouseEvent {
kind: MouseEventKind::ScrollUp,
column: x,
row: y,
modifiers: crossterm::event::KeyModifiers::NONE,
}
}
fn scroll_down(x: u16, y: u16) -> MouseEvent {
MouseEvent {
kind: MouseEventKind::ScrollDown,
column: x,
row: y,
modifiers: crossterm::event::KeyModifiers::NONE,
}
}
#[test]
fn mouse_hover_updates_without_selection_change() {
let (_tmp, mut app) = make_mouse_app();
let (x, y) = card_cell(&app, 0, 1);
let before = (app.board.active_col, app.board.selected);
let action = app.handle_mouse(moved_to(x, y)).unwrap();
assert_eq!(action, Action::None);
assert_eq!(app.board.hover, Some((0, 1)));
assert_eq!((app.board.active_col, app.board.selected), before);
}
#[test]
fn mouse_hover_cleared_on_up_or_pointer_leaving_board() {
let (_tmp, mut app) = make_mouse_app();
let (x, y) = card_cell(&app, 0, 0);
app.handle_mouse(moved_to(x, y)).unwrap();
assert_eq!(app.board.hover, Some((0, 0)));
app.handle_mouse(moved_to(x, 0)).unwrap();
assert_eq!(app.board.hover, None);
app.handle_mouse(moved_to(x, y)).unwrap();
assert!(app.board.hover.is_some());
app.handle_mouse(up_left(x, y)).unwrap();
assert_eq!(app.board.hover, None);
}
#[test]
fn mouse_scroll_moves_board_selection_within_active_column() {
let (_tmp, mut app) = make_mouse_app();
let (x, y) = card_cell(&app, 0, 1);
app.handle_mouse(down_left(x, y)).unwrap();
assert_eq!(app.board.selected, 1);
let action = app.handle_mouse(scroll_up(x, y)).unwrap();
assert_eq!(action, Action::None);
assert_eq!(app.board.selected, 0);
let action = app.handle_mouse(scroll_down(x, y)).unwrap();
assert_eq!(action, Action::None);
assert_eq!(app.board.selected, 1);
}
#[test]
fn mouse_scroll_scrolls_detail_and_context_menu() {
let (_tmp, mut app) = make_mouse_app();
let (x, y) = card_cell(&app, 0, 0);
app.handle_mouse(down_left(x, y)).unwrap();
app.handle_mouse(up_left(x, y)).unwrap();
app.handle_mouse(down_left(x, y)).unwrap();
assert!(matches!(app.mode, Mode::Detail));
if let Some(d) = app.detail.as_mut() {
d.viewport.set(20);
d.max_scroll.set(8);
}
let action = app.handle_mouse(scroll_down(x, y)).unwrap();
assert_eq!(action, Action::None);
assert_eq!(app.detail.as_ref().unwrap().scroll_offset, 5);
app.handle_mouse(scroll_down(x, y)).unwrap();
assert_eq!(app.detail.as_ref().unwrap().scroll_offset, 8);
let action = app.handle_mouse(scroll_up(x, y)).unwrap();
assert_eq!(action, Action::None);
assert_eq!(app.detail.as_ref().unwrap().scroll_offset, 3);
for _ in 0..5 {
app.handle_mouse(scroll_up(x, y)).unwrap();
}
assert_eq!(app.detail.as_ref().unwrap().scroll_offset, 0);
let (_tmp, mut app) = make_mouse_app();
let (x, y) = card_cell(&app, 0, 0);
app.handle_mouse(down_right(x, y)).unwrap();
assert!(matches!(app.mode, Mode::ContextMenu { .. }));
let action = app.handle_mouse(scroll_down(x, y)).unwrap();
assert_eq!(action, Action::None);
assert_eq!(app.context_menu.as_ref().unwrap().selected(), Some(1));
let action = app.handle_mouse(scroll_up(x, y)).unwrap();
assert_eq!(action, Action::None);
assert_eq!(app.context_menu.as_ref().unwrap().selected(), Some(0));
}
#[test]
fn new_issue_form_is_the_form_the_board_key_opens() {
let (_tmp, mut by_key) = make_app();
by_key.mode = Mode::Board;
let _ = by_key.handle_key(press(KeyCode::Char('n'))).unwrap();
let (_tmp2, mut by_command) = make_app();
by_command.open_new_issue_form();
assert!(matches!(by_command.mode, Mode::Form));
assert!(matches!(by_key.mode, Mode::Form));
let key_form = by_key.form.as_ref().expect("n opens a form");
let cmd_form = by_command.form.as_ref().expect("the command opens a form");
assert_eq!(cmd_form.title, key_form.title);
assert_eq!(cmd_form.status, key_form.status);
assert_eq!(cmd_form.assignee, key_form.assignee);
assert_eq!(cmd_form.focus, key_form.focus);
}
#[test]
fn detail_page_keys_scroll_one_page_and_stop_at_the_end() {
let (_tmp, mut app) = make_mouse_app();
let (x, y) = card_cell(&app, 0, 0);
app.handle_mouse(down_left(x, y)).unwrap();
app.handle_mouse(up_left(x, y)).unwrap();
app.handle_mouse(down_left(x, y)).unwrap();
assert!(matches!(app.mode, Mode::Detail));
if let Some(d) = app.detail.as_mut() {
d.viewport.set(20);
d.max_scroll.set(45);
}
let page = app.detail.as_ref().unwrap().page();
assert_eq!(page, 19);
app.handle_key(press(KeyCode::PageDown)).unwrap();
assert_eq!(app.detail.as_ref().unwrap().scroll_offset, 19);
app.handle_key(press(KeyCode::PageDown)).unwrap();
assert_eq!(app.detail.as_ref().unwrap().scroll_offset, 38);
app.handle_key(press(KeyCode::PageDown)).unwrap();
assert_eq!(app.detail.as_ref().unwrap().scroll_offset, 45);
app.handle_key(press(KeyCode::PageUp)).unwrap();
assert_eq!(app.detail.as_ref().unwrap().scroll_offset, 26);
}
#[test]
fn help_overlay_scrolls_by_row_page_and_wheel() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (_tmp, mut app) = make_app();
app.open_help(Mode::Board);
let mut terminal = Terminal::new(TestBackend::new(80, 12)).unwrap();
terminal.draw(|f| render(&app, f, f.area())).unwrap();
let metrics = app.help_metrics.get();
assert!(
metrics.max_scroll > 0,
"the test frame is meant to hide rows: {metrics:?}"
);
app.handle_key(press(KeyCode::Char('j'))).unwrap();
assert_eq!(app.help_scroll, 1, "one row down");
app.handle_key(press(KeyCode::Char('k'))).unwrap();
assert_eq!(app.help_scroll, 0, "and back");
app.handle_key(press(KeyCode::PageDown)).unwrap();
assert_eq!(app.help_scroll, app.help_page(), "one page down");
app.handle_key(press(KeyCode::PageUp)).unwrap();
assert_eq!(app.help_scroll, 0, "and back");
app.handle_mouse(scroll_down(1, 1)).unwrap();
assert_eq!(app.help_scroll, WHEEL_ROWS, "one wheel notch");
app.handle_mouse(scroll_up(1, 1)).unwrap();
assert_eq!(app.help_scroll, 0);
for _ in 0..50 {
app.handle_key(press(KeyCode::PageDown)).unwrap();
}
assert_eq!(
app.help_scroll, metrics.max_scroll,
"the last row is reachable"
);
for _ in 0..50 {
app.handle_key(press(KeyCode::PageUp)).unwrap();
}
assert_eq!(app.help_scroll, 0, "and the first row stays the first");
}
#[test]
fn help_overlay_scrolls_its_rows_and_reopens_at_the_top() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (_tmp, mut app) = make_app();
let entries = crate::ui::commandbar::help_overlay(&app.bindings);
assert!(
entries.len() > 12,
"the overlay must be longer than the frame"
);
let draw = |app: &App| {
let mut terminal = Terminal::new(TestBackend::new(80, 12)).unwrap();
terminal.draw(|f| render(app, f, f.area())).unwrap();
let buf = terminal.backend().buffer();
let mut text = String::new();
for y in 0..buf.area.height {
for x in 0..buf.area.width {
text.push_str(buf[(x, y)].symbol());
}
text.push('\n');
}
text
};
app.open_help(Mode::Board);
let first = draw(&app);
assert!(
first.contains(&entries[0].0),
"the top of the list is on screen: {first}"
);
app.handle_key(press(KeyCode::PageDown)).unwrap();
let second = draw(&app);
assert_ne!(
second, first,
"scrolling a page must change what is drawn, not just a counter"
);
let page = app.help_page() as usize;
assert!(
second.contains(&entries[page].0),
"the row one page down is now visible: {second}"
);
assert!(
!second.contains(&entries[0].0),
"and the first page scrolled away"
);
app.close_help();
app.open_help(Mode::Board);
assert_eq!(app.help_scroll, 0);
assert!(draw(&app).contains(&entries[0].0));
}
#[test]
fn board_page_keys_move_the_selection_by_a_screen() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (_tmp, mut app) = make_app_with_cards(30);
let mut terminal = Terminal::new(TestBackend::new(70, 20)).unwrap();
terminal.draw(|f| render(&app, f, f.area())).unwrap();
let page = app.board_page.get();
assert!(
page > 1,
"a 20-row column must hold more than one card, got {page}"
);
assert_eq!(app.board.selected, 0);
app.handle_key(press(KeyCode::PageDown)).unwrap();
assert_eq!(app.board.selected, page, "one screen down");
app.handle_key(press(KeyCode::PageDown)).unwrap();
assert_eq!(app.board.selected, page * 2, "two screens down");
app.handle_key(press(KeyCode::PageUp)).unwrap();
assert_eq!(app.board.selected, page, "and back one");
for _ in 0..20 {
app.handle_key(press(KeyCode::PageDown)).unwrap();
}
assert_eq!(
app.board.selected, 29,
"paging past the end lands on the last card"
);
for _ in 0..20 {
app.handle_key(press(KeyCode::PageUp)).unwrap();
}
assert_eq!(app.board.selected, 0, "and paging up lands on the first");
}
#[test]
fn board_page_keys_do_nothing_in_an_empty_column() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (_tmp, mut app) = make_app();
let mut terminal = Terminal::new(TestBackend::new(70, 20)).unwrap();
terminal.draw(|f| render(&app, f, f.area())).unwrap();
assert_eq!(app.board_page.get(), 0, "nothing to page");
app.handle_key(press(KeyCode::PageDown)).unwrap();
assert_eq!(app.board.selected, 0);
assert!(matches!(app.mode, Mode::Board));
}
fn draw_help(app: &App, w: u16, h: u16) -> String {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let mut terminal = Terminal::new(TestBackend::new(w, h)).unwrap();
terminal.draw(|f| render(app, f, f.area())).unwrap();
let buf = terminal.backend().buffer();
let mut text = String::new();
for y in 0..buf.area.height {
for x in 0..buf.area.width {
text.push_str(buf[(x, y)].symbol());
}
text.push('\n');
}
text
}
#[test]
fn help_overlay_search_filters_the_list() {
let (_tmp, mut app) = make_app();
let entries = crate::ui::commandbar::help_overlay(&app.bindings);
let first_key = entries[0].0.clone();
app.open_help(Mode::Board);
assert!(
draw_help(&app, 80, 24).contains(&first_key),
"the unfiltered list starts at the top"
);
app.handle_key(press(KeyCode::Char('/'))).unwrap();
assert!(app.help_searching, "the input line has the keyboard");
for c in "clone".chars() {
app.handle_key(press(KeyCode::Char(c))).unwrap();
}
assert_eq!(app.help_query, "clone");
let filtered = draw_help(&app, 80, 24);
assert!(
filtered.contains("clone issue"),
"the match is listed: {filtered}"
);
assert!(
filtered.contains("of") && filtered.contains("bindings"),
"and the summary says how many rows the filter kept: {filtered}"
);
assert!(
!filtered.contains("open detail"),
"an unrelated binding is filtered out: {filtered}"
);
app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(!app.help_searching);
assert_eq!(app.help_query, "clone");
assert!(draw_help(&app, 80, 24).contains("clone issue"));
app.handle_key(press(KeyCode::Char('/'))).unwrap();
app.handle_key(press(KeyCode::Backspace)).unwrap();
app.handle_key(press(KeyCode::Backspace)).unwrap();
assert_eq!(app.help_query, "clo");
assert!(
draw_help(&app, 80, 24).contains("clone issue"),
"still a match"
);
app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(!app.help_searching);
assert!(app.help_query.is_empty());
assert!(
matches!(app.mode, Mode::Help { .. }),
"clearing a search is not closing the help"
);
assert!(
draw_help(&app, 80, 24).contains(&first_key),
"the whole list is back"
);
app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(!matches!(app.mode, Mode::Help { .. }));
}
#[test]
fn help_overlay_search_is_fuzzy() {
let (_tmp, mut app) = make_app();
app.open_help(Mode::Board);
app.handle_key(press(KeyCode::Char('/'))).unwrap();
for c in "nwiss".chars() {
app.handle_key(press(KeyCode::Char(c))).unwrap();
}
let text = draw_help(&app, 80, 24);
assert!(
text.contains("new issue"),
"a non-contiguous query still matches: {text}"
);
}
#[test]
fn help_overlay_search_resets_when_reopened() {
let (_tmp, mut app) = make_app();
app.open_help(Mode::Board);
app.handle_key(press(KeyCode::Char('/'))).unwrap();
app.handle_key(press(KeyCode::Char('x'))).unwrap();
app.handle_key(press(KeyCode::Enter)).unwrap();
assert_eq!(app.help_query, "x");
app.close_help();
app.open_help(Mode::Board);
assert!(app.help_query.is_empty(), "the query did not survive");
assert!(!app.help_searching);
}
#[test]
fn help_overlay_search_puts_the_caret_on_the_input_line() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (_tmp, mut app) = make_app();
app.open_help(Mode::Board);
app.handle_key(press(KeyCode::Char('/'))).unwrap();
for c in "clone".chars() {
app.handle_key(press(KeyCode::Char(c))).unwrap();
}
let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
terminal.draw(|f| render(&app, f, f.area())).unwrap();
let cursor = terminal.backend().cursor_position();
let buf = terminal.backend().buffer();
let mut found = None;
for y in 0..buf.area.height {
let mut row = String::new();
for x in 0..buf.area.width {
row.push_str(buf[(x, y)].symbol());
}
if let Some(byte) = row.find("/clone") {
let column = row[..byte].chars().count() as u16 + "/clone".chars().count() as u16;
found = Some((y, column));
}
}
let (row, column) = found.expect("the prompt is drawn");
assert_eq!(
cursor,
ratatui::layout::Position::new(column, row),
"the prompt row was: {:?}",
{
let mut text = String::new();
for x in 0..buf.area.width {
text.push_str(buf[(x, row)].symbol());
}
text
}
);
}
#[test]
fn help_overlay_search_accepts_the_keys_that_close_the_overlay() {
let (_tmp, mut app) = make_app();
app.open_help(Mode::Board);
app.handle_key(press(KeyCode::Char('/'))).unwrap();
for c in "quit?".chars() {
app.handle_key(press(KeyCode::Char(c))).unwrap();
}
assert_eq!(app.help_query, "quit?");
assert!(app.help_searching, "still typing");
assert!(
matches!(app.mode, Mode::Help { .. }),
"neither `q` nor `?` closed the overlay"
);
app.handle_key(press(KeyCode::Enter)).unwrap();
app.handle_key(press(KeyCode::Char('q'))).unwrap();
assert!(!matches!(app.mode, Mode::Help { .. }));
}
fn open_detail(app: &mut App) {
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Detail));
assert!(app.detail.is_some());
}
#[test]
fn help_opens_from_board_and_closes() {
let (_tmp, mut app) = make_app();
let _ = app.handle_key(press(KeyCode::Char('?'))).unwrap();
assert!(
matches!(&app.mode, Mode::Help { return_to } if **return_to == Mode::Board),
"got {:?}",
app.mode
);
let _ = app.handle_key(press(KeyCode::Char('q'))).unwrap();
assert!(matches!(app.mode, Mode::Board));
}
#[test]
fn help_from_detail_returns_to_detail() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
open_detail(&mut app);
let _ = app.handle_key(press(KeyCode::Char('?'))).unwrap();
assert!(
matches!(&app.mode, Mode::Help { return_to } if **return_to == Mode::Detail),
"got {:?}",
app.mode
);
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(matches!(app.mode, Mode::Detail));
}
#[test]
fn help_overlay_click_closes_via_recorded_region() {
let (_tmp, mut app) = make_app();
let _ = app.handle_key(press(KeyCode::Char('?'))).unwrap();
assert!(matches!(app.mode, Mode::Help { .. }));
let _ = draw_board(&app, 80, 24);
let action = app.handle_mouse(down_left(0, 0)).unwrap();
assert_eq!(action, Action::HelpClose);
app.close_help();
assert!(matches!(app.mode, Mode::Board));
}
#[test]
fn help_overlay_lists_mouse_bindings() {
let (_tmp, mut app) = make_app();
let _ = app.handle_key(press(KeyCode::Char('?'))).unwrap();
let cells = draw_board(&app, 80, 24);
let text: String = cells.concat();
assert!(text.contains("click"), "got {text:?}");
assert!(text.contains("double-click"), "got {text:?}");
assert!(text.contains("right-click"), "got {text:?}");
assert!(text.contains("drag"), "got {text:?}");
}
#[test]
fn filter_narrows_board_live() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "Target", "todo", None);
write_issue(&dir, "2026-01-01-b.md", 2, "Other", "todo", None);
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
app.refresh_corpus().unwrap();
assert_eq!(app.board.column_cards(0).len(), 2);
let _ = app.handle_key(press(KeyCode::Char('/'))).unwrap();
assert!(matches!(app.mode, Mode::Filter { .. }));
for c in "arget".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
assert_eq!(app.filter, Some("arget".into()));
assert_eq!(app.board.column_cards(0).len(), 1);
assert_eq!(app.board.column_cards(0)[0].title, "Target");
assert!(matches!(app.mode, Mode::Filter { .. }));
}
#[test]
fn filter_esc_clears_and_returns_to_board() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "Target", "todo", None);
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
app.refresh_corpus().unwrap();
let _ = app.handle_key(press(KeyCode::Char('/'))).unwrap();
for c in "zzw".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
assert_eq!(app.board.column_cards(0).len(), 0);
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(matches!(app.mode, Mode::Board));
assert_eq!(app.filter, None);
assert_eq!(app.board.column_cards(0).len(), 1);
}
#[test]
fn filter_enter_commits_and_stays_filtered() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "Target", "todo", None);
write_issue(&dir, "2026-01-01-b.md", 2, "Other", "todo", None);
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
app.refresh_corpus().unwrap();
let _ = app.handle_key(press(KeyCode::Char('/'))).unwrap();
for c in "arget".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Board));
assert_eq!(app.filter, Some("arget".into()));
assert_eq!(app.board.column_cards(0).len(), 1);
}
#[test]
fn filter_zero_match_reports_status() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "Target", "todo", None);
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
app.refresh_corpus().unwrap();
let _ = app.handle_key(press(KeyCode::Char('/'))).unwrap();
for c in "zzw".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
assert_eq!(app.board.column_cards(0).len(), 0);
assert!(
app.status_msg
.as_deref()
.is_some_and(|m| m.contains("No matches")),
"got {:?}",
app.status_msg
);
}
#[test]
fn filter_zero_match_number_reports_status() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 7, "Target", "todo", None);
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
app.refresh_corpus().unwrap();
let _ = app.handle_key(press(KeyCode::Char('/'))).unwrap();
for c in "999".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
assert_eq!(app.filter, Some("999".into()));
assert_eq!(app.board.column_cards(0).len(), 0);
assert!(
app.status_msg
.as_deref()
.is_some_and(|m| m.contains("No matches")),
"got {:?}",
app.status_msg
);
}
#[test]
fn comment_round_trip_via_detail() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
open_detail(&mut app);
let _ = app.handle_key(press(KeyCode::Char('c'))).unwrap();
assert!(matches!(app.mode, Mode::Comment { .. }));
for c in "hello world".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Detail));
let issue = app
.store
.get(IssueSelector::Filename("2026-01-01-a.md".into()))
.unwrap();
let comments = issue.comments();
assert_eq!(comments.len(), 1);
assert_eq!(comments[0].text.trim(), "hello world");
}
#[test]
fn comment_esc_cancels() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
open_detail(&mut app);
let _ = app.handle_key(press(KeyCode::Char('c'))).unwrap();
for c in "partial".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(matches!(app.mode, Mode::Detail));
let issue = app
.store
.get(IssueSelector::Filename("2026-01-01-a.md".into()))
.unwrap();
assert!(issue.comments().is_empty());
}
#[test]
fn comment_empty_discarded() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
open_detail(&mut app);
let _ = app.handle_key(press(KeyCode::Char('c'))).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Detail));
let issue = app
.store
.get(IssueSelector::Filename("2026-01-01-a.md".into()))
.unwrap();
assert!(issue.comments().is_empty());
assert!(
app.status_msg
.as_deref()
.is_some_and(|m| m.contains("empty"))
);
}
#[test]
fn f4_a_disclosure_is_composed_with_the_confirmation_not_substituted() {
let (_tmp, mut app) = make_app();
app.status_msg = Some("Moved #1 to in progress".to_string());
app.note_disclosure("legacy-1.md: this write removed the retired `links:` key.".into());
let status = app.status_msg.as_deref().unwrap();
assert!(
status.contains("Moved #1 to in progress"),
"the confirmation must survive: {status}"
);
assert!(
status.contains("retired `links:` key"),
"and the disclosure must be shown: {status}"
);
let disclosure_at = status.find("retired").expect("disclosure is present");
let confirmation_at = status.find("Moved #1").expect("confirmation is present");
assert!(
disclosure_at < confirmation_at,
"the disclosure leads, so its actionable tail survives truncation: {status}"
);
let (_tmp2, mut app2) = make_app();
app2.note_disclosure("alone".into());
assert_eq!(app2.status_msg.as_deref(), Some("alone"));
}
#[test]
fn f4_the_loop_drain_moves_a_raised_disclosure_into_the_status_line() {
let (_tmp, mut app) = make_app();
app.status_msg = Some("Moved #1 to in progress".to_string());
crate::storage::retired_key_disclosure("alice/2026-01-01-x.md");
assert_eq!(
crate::storage::take_disclosures().len(),
1,
"precondition: a disclosure is queued and nothing has drained it"
);
crate::storage::retired_key_disclosure("alice/2026-01-01-x.md");
super::drain_disclosures(&mut app);
let status = app.status_msg.as_deref().unwrap_or_default();
assert!(
status.contains("Moved #1 to in progress"),
"the confirmation survives: {status:?}"
);
assert!(
status.contains("retired"),
"and the loop's drain moved the disclosure in: {status:?}"
);
assert!(
crate::storage::take_disclosures().is_empty(),
"the queue is empty, so the next frame does not repeat it"
);
}
#[test]
fn every_printable_character_is_typeable_in_a_free_text_prompt() {
let (_tmp, app) = make_app();
let free_text = [
InputContext::Comment,
InputContext::Asset,
InputContext::Filter,
InputContext::MoveIntoDone,
];
for context in free_text {
for ch in ['q', 'Q'] {
let event = KeyEvent::new(KeyCode::Char(ch), crossterm::event::KeyModifiers::NONE);
assert_ne!(
app.bindings.action(context, event),
Some(crate::keybindings::TuiAction::Cancel),
"{context:?} must be able to type `{ch}`: it is bound to cancel, so \
pressing it leaves the mode and the rest of the input is applied as \
commands underneath"
);
}
}
for context in free_text {
let mut stolen: Vec<char> = Vec::new();
for code in 0x20u8..0x7f {
let ch = code as char;
let event = KeyEvent::new(KeyCode::Char(ch), crossterm::event::KeyModifiers::NONE);
if app.bindings.action(context, event).is_some() {
stolen.push(ch);
}
}
assert!(
stolen.is_empty(),
"{context:?} steals these printable characters, so they cannot be \
typed: {stolen:?}"
);
}
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
app.board = BoardState::build(&app.store, app.store.config.default_config());
open_detail(&mut app);
let _ = app.handle_key(press(KeyCode::Char('c'))).unwrap();
assert!(
matches!(app.mode, Mode::Comment { .. }),
"enter the comment prompt"
);
for c in "has a q in it".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
if let Mode::Comment { ref buffer, .. } = app.mode {
assert_eq!(
buffer, "has a q in it",
"every character reached the buffer"
);
} else {
panic!("the prompt must still be open, got {:?}", app.mode);
}
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(
!matches!(app.mode, Mode::Comment { .. }),
"esc must still cancel, got {:?}",
app.mode
);
}
#[test]
fn asset_round_trip_via_detail() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
open_detail(&mut app);
let asset = tmp.path().join("pic.png");
fs::write(&asset, b"fake-png").unwrap();
let _ = app.handle_key(press(KeyCode::Char('a'))).unwrap();
assert!(matches!(app.mode, Mode::Asset { .. }));
for c in asset.to_string_lossy().chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Detail));
assert!(
dir.join("a-assets").join("pic.png").is_file(),
"asset must be copied into the issue asset dir"
);
let issue = app
.store
.get(IssueSelector::Filename("2026-01-01-a.md".into()))
.unwrap();
assert!(
issue.body.contains("pic.png"),
"body must reference the asset, got {:?}",
issue.body
);
}
#[test]
fn asset_error_stays_in_mode() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
open_detail(&mut app);
let _ = app.handle_key(press(KeyCode::Char('a'))).unwrap();
for c in "/no/such/file.png".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(
matches!(app.mode, Mode::Asset { .. }),
"asset error must stay in entry mode, got {:?}",
app.mode
);
assert!(app.status_msg.is_some());
}
fn draw_board(app: &App, w: u16, h: u16) -> Vec<String> {
render_to_cells(app, w, h).unwrap()
}
fn flush_line(cells: &[String], width: u16, row: u16) -> String {
let start = (row as usize) * (width as usize);
cells[start..start + width as usize].concat()
}
#[test]
fn render_board_unfiltered_has_no_filter_row() {
let (_tmp, app) = make_app();
let cells = draw_board(&app, 80, 24);
assert!(
!cells.iter().any(|s| s.contains("Filters:")),
"no filter row expected"
);
}
#[test]
fn render_board_filtered_shows_filter_row_beneath_tabs() {
let (_tmp, mut app) = make_app();
app.filter = Some("55".into());
app.board = BoardState::build(&app.store, app.store.config.default_config());
let cells = draw_board(&app, 80, 24);
assert!(
flush_line(&cells, 80, 1).contains("Filters: #55"),
"row 1 should read the filter, got {}",
flush_line(&cells, 80, 1)
);
}
#[test]
fn render_board_structured_filter_shows_row() {
let (_tmp, mut app) = make_app();
app.filter = None;
app.structured = search::StructuredFilter {
status: Some("todo".into()),
..Default::default()
};
app.board = BoardState::build(&app.store, app.store.config.default_config());
let cells = draw_board(&app, 80, 24);
assert!(
flush_line(&cells, 80, 1).contains("Filters:"),
"got {}",
flush_line(&cells, 80, 1)
);
}
#[test]
fn render_board_filter_input_draws_row_and_prompt() {
let (_tmp, mut app) = make_app();
app.mode = Mode::Filter {
buffer: "55".to_string(),
};
app.filter = Some("55".into());
app.board = BoardState::build(&app.store, app.store.config.default_config());
let cells = draw_board(&app, 80, 24);
assert!(
flush_line(&cells, 80, 1).contains("Filters: #55"),
"row 1 should read the filter while typing"
);
assert!(
flush_line(&cells, 80, 23).contains("Filter: 55"),
"bottom row should hold the input prompt"
);
}
#[test]
fn render_board_tiny_terminal_filtered_does_not_panic() {
let (_tmp, mut app) = make_app();
app.filter = Some("55".into());
app.board = BoardState::build(&app.store, app.store.config.default_config());
app.mode = Mode::Filter {
buffer: "55".to_string(),
};
for h in [2_u16, 3, 4, 5] {
let _ = draw_board(&app, 80, h);
}
}
#[test]
fn filter_esc_clears_row_and_resolves_to_board() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 55, "Target", "todo", None);
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
app.refresh_corpus().unwrap();
let _ = app.handle_key(press(KeyCode::Char('/'))).unwrap();
for c in "55".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
let cells = draw_board(&app, 80, 24);
assert!(
cells.concat().contains("Filters:"),
"filtered board must show the row; filter={:?} cells_row1={}",
app.filter,
flush_line(&cells, 80, 1)
);
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(matches!(app.mode, Mode::Board));
assert_eq!(app.filter, None);
let after = draw_board(&app, 80, 24).concat();
assert!(
!after.contains("Filters:"),
"esc cleared the filter, row must disappear, got {after:?}"
);
}
#[test]
fn render_once_uses_default_board_configuration() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(&dir).unwrap();
let config_path = tmp.path().join("tix.toml");
let config = Config {
issues_dir: dir.clone(),
..Config::default()
};
config.save(&config_path).unwrap();
let mut config = Config::load_with_warning(&config_path, |_| {}).unwrap();
config.kanban.configurations.clear();
config.kanban.configurations.insert(
"release".to_string(),
crate::config::BoardConfiguration {
columns: vec!["todo".to_string(), "done".to_string()],
done_column: Some("done".to_string()),
category_map: indexmap::IndexMap::new(),
hidden_columns: vec![],
show_mismatch: false,
wrap_titles: true,
title_lines: 2,
card_order: vec![],
order_by: crate::config::OrderBy::CreatedAtDesc,
},
);
config.kanban.configurations.insert(
"default".to_string(),
crate::config::BoardConfiguration {
columns: vec!["backlog".to_string(), "done".to_string()],
done_column: Some("done".to_string()),
category_map: indexmap::IndexMap::new(),
hidden_columns: vec![],
show_mismatch: false,
wrap_titles: true,
title_lines: 2,
card_order: vec![],
order_by: crate::config::OrderBy::CreatedAtDesc,
},
);
config.save(&config_path).unwrap();
let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
write_issue(&dir, "2026-01-01-a.md", 1, "A", "backlog", None);
let rendered = render_once_at(&config, 80, 24).unwrap();
assert!(
rendered.contains("backlog"),
"rendered board should include default board columns, got:\n{rendered}"
);
}
#[test]
fn render_once_tiny_terminal_is_stable() {
let (tmp, _app) = make_app();
let config_path = tmp.path().join("tix.toml");
let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
write_issue(
&tmp.path().join("issues"),
"2026-01-01-a.md",
1,
"Alpha",
"todo",
None,
);
let first = render_once_at(&config, 20, 4).unwrap();
let second = render_once_at(&config, 20, 4).unwrap();
assert_eq!(first, second, "tiny render should be deterministic");
}
#[test]
fn render_once_omits_keyboard_hints() {
let (tmp, _app) = make_app();
let config_path = tmp.path().join("tix.toml");
let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
write_issue(
&tmp.path().join("issues"),
"2026-01-01-a.md",
1,
"Alpha",
"todo",
None,
);
let rendered = render_once_at(&config, 80, 24).unwrap();
for hint in [
"j/k nav",
"n new",
"? help",
"h/l column",
"enter open",
"d delete",
] {
assert!(
!rendered.contains(hint),
"one-shot output must not contain the keyboard hint {hint:?}:\n{rendered}"
);
}
}
#[test]
fn render_once_reserves_bottom_margin_lines() {
let (tmp, _app) = make_app();
let config_path = tmp.path().join("tix.toml");
let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
write_issue(
&tmp.path().join("issues"),
"2026-01-01-a.md",
1,
"Alpha",
"todo",
None,
);
let height = 24u16;
let rendered = render_once_at(&config, 80, height).unwrap();
let line_count = rendered.lines().count();
assert!(
line_count <= (height - RENDER_BOTTOM_MARGIN) as usize,
"one-shot render must leave {RENDER_BOTTOM_MARGIN} spare lines, \
got {line_count} lines for a {height}-row terminal:\n{rendered}"
);
assert!(
rendered.lines().any(|l| l.chars().count() == 80),
"one-shot render should still span the full width:\n{rendered}"
);
}
#[test]
fn resolve_render_size_falls_back_for_non_tty() {
assert_eq!(
resolve_render_size(None),
(RENDER_FALLBACK_WIDTH, RENDER_FALLBACK_HEIGHT)
);
assert_eq!(resolve_render_size(Some((0, 0))), (1, 1));
}
fn make_multi_board_app() -> (TempDir, App) {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(&dir).unwrap();
let config_path = tmp.path().join("tix.toml");
let mut config = Config {
issues_dir: dir.clone(),
..Config::default()
};
config.kanban.configurations.insert(
"release".into(),
crate::config::BoardConfiguration::default(),
);
config.save(&config_path).unwrap();
let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
let app = App::new(&config).unwrap();
app.frame_size.set(Rect::new(0, 0, 80, 24));
(tmp, app)
}
#[test]
fn tab_click_switches_board_via_recorded_region() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (_tmp, mut app) = make_multi_board_app();
assert!(app.tabs.needs_bar());
assert_eq!(app.active_config, 0);
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| crate::app::render(&app, f, f.area()))
.unwrap();
let tabs_area = Rect::new(0, 0, 80, 2);
let second = app.tabs.tab_rect(1, tabs_area).unwrap();
let action = app.handle_mouse(down_left(second.x + 1, 1)).unwrap();
assert_eq!(
action,
Action::SwitchBoard(1),
"tab click resolves the region"
);
if let Action::SwitchBoard(index) = action {
app.switch_board(index);
}
assert_eq!(app.active_config, 1);
assert_eq!(app.tabs.selected, 1);
assert_eq!(app.status_msg.as_deref(), Some("Switched board"));
}
#[test]
fn tab_hover_updates_hover_state_without_switching_board() {
let (_tmp, mut app) = make_multi_board_app();
let tabs_area = Rect::new(0, 0, 80, 2);
let second = app.tabs.tab_rect(1, tabs_area).unwrap();
let action = app.handle_mouse(moved_to(second.x + 1, 0)).unwrap();
assert_eq!(action, Action::None);
assert_eq!(app.active_config, 0);
assert_eq!(app.tabs.selected, 0);
assert_eq!(app.tabs.hovered, Some(1));
assert_eq!(app.board.hover, None);
}
fn drawn_confirm_buttons(app: &App) -> ((u16, u16), (u16, u16)) {
let backend = ratatui::backend::TestBackend::new(
app.frame_size.get().width,
app.frame_size.get().height,
);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal
.draw(|f| crate::app::render(app, f, f.area()))
.unwrap();
let prompt = confirm_delete_prompt(app.frame_size.get());
let (yes, no) = confirm_delete_buttons(prompt);
((yes.x + 2, yes.y), (no.x + 1, no.y))
}
#[test]
fn confirm_delete_yes_click_deletes_issue() {
let (_tmp, mut app) = make_app();
let dir = app.store.config.issues_dir.clone();
write_issue(&dir, "a.md", 1, "Alpha", "todo", None);
app.rebuild_board();
app.frame_size.set(Rect::new(0, 0, 80, 24));
app.confirm_delete_target = Some("a.md".into());
app.mode = Mode::ConfirmDelete;
let ((yes_x, yes_y), _) = drawn_confirm_buttons(&app);
let action = app.handle_mouse(down_left(yes_x, yes_y)).unwrap();
assert_eq!(action, Action::ConfirmDeleteYes);
let _ = app.confirm_delete_yes();
assert_eq!(app.mode, Mode::Board);
assert_eq!(app.confirm_delete_target, None);
assert_eq!(
app.status_msg.as_deref(),
Some("Deleted a.md"),
"Yes click deletes the issue (same branch as the y key)"
);
assert!(
!dir.join("a.md").exists(),
"the target file is actually removed"
);
}
#[test]
fn confirm_delete_no_click_cancels() {
let (_tmp, mut app) = make_app();
let dir = app.store.config.issues_dir.clone();
write_issue(&dir, "a.md", 1, "Alpha", "todo", None);
app.rebuild_board();
app.frame_size.set(Rect::new(0, 0, 80, 24));
app.confirm_delete_target = Some("a.md".into());
app.mode = Mode::ConfirmDelete;
let (_, (no_x, no_y)) = drawn_confirm_buttons(&app);
let action = app.handle_mouse(down_left(no_x, no_y)).unwrap();
assert_eq!(action, Action::ConfirmDeleteNo);
let _ = app.confirm_delete_no();
assert_eq!(app.mode, Mode::Board);
assert_eq!(app.confirm_delete_target, None);
assert!(
dir.join("a.md").exists(),
"No click cancels: the issue is left in place (same branch as Esc)"
);
}
#[test]
fn filter_modal_row_click_moves_focus_to_that_criterion() {
let (_tmp, mut app) = make_app();
app.frame_size.set(Rect::new(0, 0, 80, 24));
app.filter_modal = Some(crate::ui::filter_modal::FilterModal::new());
app.mode = Mode::FilterModal;
let backend = ratatui::backend::TestBackend::new(80, 24);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal
.draw(|f| crate::app::render(&app, f, f.area()))
.unwrap();
let rect = crate::ui::modal::modal_rect(Rect::new(0, 0, 80, 24));
let status_row = rect.y + 1 + 2;
let status_field = FilterField::ALL[2];
assert_eq!(status_field, FilterField::Status);
assert_ne!(
app.filter_modal.as_ref().unwrap().focus(),
FilterField::Status
);
let action = app.handle_mouse(down_left(rect.x + 2, status_row)).unwrap();
assert_eq!(
action,
Action::FilterModalFocus(FilterField::Status),
"row click resolves the criterion region"
);
if let Action::FilterModalFocus(field) = action {
app.filter_modal.as_mut().unwrap().focus_field(field);
}
assert_eq!(
app.filter_modal.as_ref().unwrap().focus(),
FilterField::Status,
"clicked row becomes the focused criterion"
);
}
fn drawn_settings_row(app: &App, row: usize) -> (u16, u16) {
let backend = ratatui::backend::TestBackend::new(
app.frame_size.get().width,
app.frame_size.get().height,
);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal
.draw(|f| crate::app::render(app, f, f.area()))
.unwrap();
let rect = crate::ui::modal::modal_rect(app.frame_size.get());
(rect.x + 2, rect.y + 1 + row as u16)
}
#[test]
fn settings_row_click_selects_and_toggles_like_enter() {
let (_tmp, mut app) = make_app();
app.frame_size.set(Rect::new(0, 0, 80, 24));
let board_name = app.active_board_name().to_string();
let col = "in progress".to_string();
app.settings = Some(crate::ui::settings::SettingsModal::new());
app.mode = Mode::Settings;
let (x, y) = drawn_settings_row(&app, 1);
let action = app.handle_mouse(down_left(x, y)).unwrap();
assert_eq!(action, Action::SettingsRow(1), "settings row resolves");
if let Action::SettingsRow(row) = action {
let count = app.active_board_config().columns.len() + crate::ui::settings::DISPLAY_ROWS;
app.settings.as_mut().unwrap().move_selected_to(row, count);
let _ = app.settings_toggle_selected();
}
assert_eq!(app.settings.as_ref().unwrap().selected, 1);
assert!(
app.store
.config
.kanban
.configurations
.get(&board_name)
.unwrap()
.hidden_columns
.contains(&col),
"clicking the column row runs the Enter toggle (hides the column)"
);
}
#[test]
fn settings_display_row_click_mirrors_entering_browse() {
let (_tmp, mut app) = make_app();
app.frame_size.set(Rect::new(0, 0, 80, 24));
let board_name = app.active_board_name().to_string();
app.settings = Some(crate::ui::settings::SettingsModal::new());
app.mode = Mode::Settings;
let display_row = app.active_board_config().columns.len();
let (x, y) = drawn_settings_row(&app, display_row);
let action = app.handle_mouse(down_left(x, y)).unwrap();
assert_eq!(action, Action::SettingsRow(display_row));
if let Action::SettingsRow(row) = action {
let count = app.active_board_config().columns.len() + crate::ui::settings::DISPLAY_ROWS;
app.settings.as_mut().unwrap().move_selected_to(row, count);
let _ = app.settings_toggle_selected();
}
assert_eq!(app.settings.as_ref().unwrap().selected, display_row);
assert_eq!(
app.status_msg.as_deref(),
Some("Wrap titles: off"),
"display row click runs the same edit as Enter on that row"
);
assert!(
!app.store
.config
.kanban
.configurations
.get(&board_name)
.unwrap()
.wrap_titles
);
}
fn menu_kinds(app: &App) -> Vec<MenuItemKind> {
app.context_menu
.as_ref()
.expect("menu open")
.items()
.iter()
.map(|item| item.kind.clone())
.collect()
}
#[test]
fn right_click_card_opens_menu_with_all_actions() {
let (_tmp, mut app) = make_mouse_app();
let (x, y) = card_cell(&app, 0, 1);
app.handle_mouse(down_right(x, y)).unwrap();
assert_eq!((app.board.active_col, app.board.selected), (0, 1));
let Some(menu) = app.context_menu.as_ref() else {
panic!("menu not open");
};
assert_eq!(menu.len(), 7, "open, edit, 3 moves, delete, refresh");
let kinds = menu_kinds(&app);
assert_eq!(kinds[0], MenuItemKind::OpenDetail);
assert_eq!(kinds[1], MenuItemKind::EditInEditor);
assert!(matches!(kinds[2], MenuItemKind::MoveTo { column: 1 }));
assert!(matches!(kinds[3], MenuItemKind::MoveTo { column: 2 }));
assert!(matches!(kinds[4], MenuItemKind::MoveTo { column: 3 }));
assert_eq!(kinds[5], MenuItemKind::Delete);
assert_eq!(kinds[6], MenuItemKind::RefreshBoard);
assert!(
menu.item(2).unwrap().label.contains("in progress"),
"move entries are labeled with the target column"
);
assert!(
!kinds
.iter()
.any(|k| matches!(k, MenuItemKind::MoveTo { column: 0 })),
"current column is not offered as a move target"
);
assert!(matches!(app.mode, Mode::ContextMenu { .. }));
}
#[test]
fn right_click_empty_column_space_is_a_noop() {
let (_tmp, mut app) = make_mouse_app();
let (x, y) = column_cell(&app, 1);
app.handle_mouse(down_right(x, y)).unwrap();
assert!(
!matches!(app.mode, Mode::ContextMenu { .. }),
"no menu on empty column space"
);
assert!(app.context_menu.is_none());
}
#[test]
fn context_menu_open_detail_matches_enter_key() {
let (_tmp, mut app) = make_mouse_app();
let (x, y) = card_cell(&app, 0, 1);
let issue_before = app.board.column_cards(0)[1].number;
app.handle_mouse(down_right(x, y)).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(!matches!(app.mode, Mode::ContextMenu { .. }));
assert!(app.context_menu.is_none());
let Some(detail) = &app.detail else {
panic!("menu Open detail should open the detail view");
};
assert_eq!(detail.issue.number, issue_before);
let (_tmp, mut app) = make_mouse_app();
let (x, y) = card_cell(&app, 0, 1);
let _ = app.handle_mouse(down_left(x, y)).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert_eq!(
app.detail.as_ref().map(|d| d.issue.number),
Some(issue_before),
"menu Open detail and board Enter open the same issue"
);
}
#[test]
fn context_menu_edit_in_editor_matches_e_key() {
let (_tmp, mut app) = make_mouse_app();
let (x, y) = card_cell(&app, 0, 1);
app.handle_mouse(down_right(x, y)).unwrap();
app.handle_key(press(KeyCode::Down)).unwrap(); let action = app.handle_key(press(KeyCode::Enter)).unwrap();
let selected = app.board.get_selected_card().cloned().unwrap().filename;
assert_eq!(
action,
Action::OpenEditor(selected.clone()),
"menu Edit in editor returns OpenEditor for the selected card"
);
let (_tmp, mut app) = make_mouse_app();
let (x, y) = card_cell(&app, 0, 1);
let _ = app.handle_mouse(down_left(x, y)).unwrap();
let key_action = app.handle_key(press(KeyCode::Char('e'))).unwrap();
let key_filename = match app.board.get_selected_card() {
Some(card) => card.filename.clone(),
None => String::new(),
};
assert_eq!(
key_action,
Action::OpenEditor(key_filename),
"board 'e' returns OpenEditor for the same card"
);
}
#[test]
fn context_menu_move_to_matches_cardinal_move_key() {
fn moved_via_menu() -> Status {
let (_tmp, mut app) = make_mouse_app();
let (x, y) = card_cell(&app, 0, 0);
app.handle_mouse(down_right(x, y)).unwrap();
app.handle_key(press(KeyCode::Down)).unwrap();
app.handle_key(press(KeyCode::Down)).unwrap();
app.handle_key(press(KeyCode::Down)).unwrap();
app.handle_key(press(KeyCode::Enter)).unwrap();
app.store
.get(IssueSelector::Filename("a.md".to_string()))
.unwrap()
.status
}
fn moved_via_keys() -> Status {
let (_tmp, mut app) = make_mouse_app();
let (x, y) = card_cell(&app, 0, 0);
let _ = app.handle_mouse(down_left(x, y)).unwrap();
let _ = app.handle_key(press(KeyCode::Char('>'))).unwrap();
let _ = app.handle_key(press(KeyCode::Char('>'))).unwrap();
app.store
.get(IssueSelector::Filename("a.md".to_string()))
.unwrap()
.status
}
assert_eq!(moved_via_menu(), moved_via_keys());
}
#[test]
fn context_menu_delete_sets_up_confirm() {
let (_tmp, mut app) = make_mouse_app();
let (x, y) = card_cell(&app, 0, 1);
app.handle_mouse(down_right(x, y)).unwrap();
app.handle_key(press(KeyCode::Down)).unwrap();
app.handle_key(press(KeyCode::Down)).unwrap();
app.handle_key(press(KeyCode::Down)).unwrap();
app.handle_key(press(KeyCode::Down)).unwrap();
app.handle_key(press(KeyCode::Down)).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert_eq!(app.mode, Mode::ConfirmDelete);
assert_eq!(
app.confirm_delete_target.as_deref(),
Some("b.md"),
"delete targets the right-clicked card"
);
assert!(!matches!(&app.mode, Mode::ContextMenu { .. }));
}
#[test]
fn context_menu_refresh_rebuilds_board() {
let (_tmp, mut app) = make_mouse_app();
let (x, y) = card_cell(&app, 0, 0);
app.handle_mouse(down_right(x, y)).unwrap();
app.handle_key(press(KeyCode::Char('j'))).unwrap();
app.handle_key(press(KeyCode::Char('j'))).unwrap();
app.handle_key(press(KeyCode::Char('j'))).unwrap();
app.handle_key(press(KeyCode::Char('j'))).unwrap();
app.handle_key(press(KeyCode::Char('j'))).unwrap();
app.handle_key(press(KeyCode::Char('j'))).unwrap();
app.handle_key(press(KeyCode::Char('j'))).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert_eq!(app.mode, Mode::Board);
assert_eq!(app.status_msg.as_deref(), Some("Refreshed"));
assert!(app.context_menu.is_none());
}
#[test]
fn context_menu_esc_closes_without_side_effects() {
let (_tmp, mut app) = make_mouse_app();
let (card_x, card_y) = card_cell(&app, 0, 1);
app.handle_mouse(down_right(card_x, card_y)).unwrap();
let status_before = app
.store
.get(IssueSelector::Filename("b.md".to_string()))
.unwrap()
.status
.clone();
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert_eq!(app.mode, Mode::Board);
assert!(app.context_menu.is_none());
assert_eq!((app.board.active_col, app.board.selected), (0, 1));
assert!(app.confirm_delete_target.is_none());
assert_eq!(
app.store
.get(IssueSelector::Filename("b.md".to_string()))
.unwrap()
.status,
status_before,
"Esc closes the menu without mutating any issue"
);
}
#[test]
fn context_menu_outside_click_closes_menu() {
let (_tmp, mut app) = make_mouse_app();
let (card_x, card_y) = card_cell(&app, 0, 1);
app.handle_mouse(down_right(card_x, card_y)).unwrap();
let action = app.handle_mouse(down_left(40, 39)).unwrap();
assert_eq!(action, Action::None);
assert!(app.context_menu.is_none());
assert_eq!(app.mode, Mode::Board);
assert!(
app.mouse.drag.is_none(),
"the dismissing click must not start a drag"
);
}
#[test]
fn context_menu_item_click_activates() {
let (_tmp, mut app) = make_mouse_app();
let (card_x, card_y) = card_cell(&app, 0, 1);
app.handle_mouse(down_right(card_x, card_y)).unwrap();
let menu = app.context_menu.as_ref().unwrap();
let bounds = menu.bounds(app.frame_size.get());
let item_x = bounds.x + 2;
let item_y = bounds.y + 1 + 5;
let _ = app.handle_mouse(down_left(item_x, item_y)).unwrap();
assert_eq!(app.mode, Mode::ConfirmDelete);
assert_eq!(app.confirm_delete_target.as_deref(), Some("b.md"));
assert!(app.context_menu.is_none());
}
#[test]
fn custom_board_binding_replaces_default() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(&dir).unwrap();
let mut config = Config {
issues_dir: dir,
..Config::default()
};
config.keybindings.board.new_issue = Some(vec!["ctrl+n".into()]);
let mut app = App::new(&config).unwrap();
app.handle_key(press(KeyCode::Char('n'))).unwrap();
assert!(matches!(app.mode, Mode::Board));
app.handle_key(press_with_modifiers(
KeyCode::Char('n'),
crossterm::event::KeyModifiers::CONTROL,
))
.unwrap();
assert!(matches!(app.mode, Mode::Form));
}
}