use super::keymap::KeyMap;
use super::{BoardView, MIN_COLUMN_WIDTH};
use anyhow::Result;
use pinto::i18n::{Message, current};
use pinto::kanban_keys::{KeyAction, KeyBindings};
use pinto::service::{Board, BoardQuery, SearchMode, board};
use pinto::timezone::DisplayTimezone;
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use std::path::{Path, PathBuf};
use std::time::Duration;
use tokio::runtime::Handle;
mod input;
mod terminal;
mod actions;
mod render;
#[cfg(test)]
mod tests;
use actions::{
abort_search, apply_incremental_filter, clear_filter, commit_search, edit_selected, reload,
reorder, submit_input, transition,
};
#[cfg(test)]
pub(super) use render::header;
pub(super) use render::{help_max_scroll, popup_max_scroll, render};
#[cfg(test)]
use input::text_entry_key_is_accepted;
use input::{
HelpKeyAction, PopupAction, QuitIntent, help_key_action, popup_action, quit_intent,
should_close_help_after_key,
};
use terminal::initialize_terminal;
const POLL: Duration = Duration::from_millis(250);
pub(super) fn capacity_for(width: u16) -> usize {
(width / MIN_COLUMN_WIDTH).max(1) as usize
}
pub(super) fn effective_capacity(width: u16, maximized: bool) -> usize {
if maximized { 1 } else { capacity_for(width) }
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ExitMode {
Quit,
Shell,
}
pub(super) struct RunOptions {
pub(super) dir: PathBuf,
pub(super) confirm_quit: bool,
pub(super) bindings: KeyBindings,
pub(super) markdown: bool,
pub(super) timezone: DisplayTimezone,
pub(super) display_columns: Vec<String>,
pub(super) initial_column: Option<String>,
pub(super) maximize: bool,
pub(super) query: BoardQuery,
}
pub(super) async fn run(options: RunOptions) -> Result<ExitMode> {
let RunOptions {
dir,
confirm_quit,
bindings,
markdown,
timezone,
display_columns,
initial_column,
maximize,
query,
} = options;
let loaded = load_display_board(&dir, &query, &display_columns).await?;
let mut view =
BoardView::new_with_scope_and_query(loaded.display, loaded.full, display_columns, query);
if let Some(column) = initial_column.as_deref()
&& !view.select_column(column)
{
anyhow::bail!("column not found: {column}");
}
view.set_maximized(maximize);
view.set_render_markdown(markdown);
view.set_display_timezone(timezone);
let keymap = KeyMap::from_bindings(&bindings)?;
let handle = Handle::current();
tokio::task::spawn_blocking(move || event_loop(handle, dir, view, keymap, confirm_quit)).await?
}
struct LoadedBoard {
display: Board,
full: Board,
}
async fn load_display_board(
project_dir: &Path,
query: &BoardQuery,
display_columns: &[String],
) -> Result<LoadedBoard> {
let full = board(project_dir, query).await?;
let display = filter_display_columns(full.clone(), display_columns);
Ok(LoadedBoard { display, full })
}
fn filter_display_columns(mut board: Board, display_columns: &[String]) -> Board {
board.columns.retain(|column| {
display_columns
.iter()
.any(|status| status == column.status.as_str())
});
board
}
fn event_loop(
handle: Handle,
dir: PathBuf,
mut view: BoardView,
keymap: KeyMap,
confirm_quit: bool,
) -> Result<ExitMode> {
let (mut terminal, _panic_hook) = initialize_terminal()?;
let mut confirming: Option<ExitMode> = None;
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
loop {
match terminal.size() {
Ok(size) => {
view.scroll_to_visible(effective_capacity(size.width, view.is_maximized()));
}
Err(e) => break Err(e.into()),
}
if let Err(e) =
terminal.draw(|frame| render(frame, &view, confirming.is_some(), &keymap))
{
break Err(e.into());
}
match event::poll(POLL) {
Ok(false) => continue,
Err(e) => break Err(e.into()),
Ok(true) => {}
}
let key = match event::read() {
Ok(Event::Key(key)) if key.kind == KeyEventKind::Press => key,
Ok(_) => continue,
Err(e) => break Err(e.into()),
};
if let Some(mode) = confirming {
if keymap.matches(KeyAction::ConfirmQuit, key) {
break Ok(mode);
}
if keymap.matches(KeyAction::CancelQuit, key) {
confirming = None;
}
continue;
}
if view.is_help_open() {
let max_scroll = help_max_scroll(&view, terminal.size().ok(), &keymap);
match help_key_action(&keymap, key) {
HelpKeyAction::Close => {
view.close_help();
continue;
}
HelpKeyAction::ScrollUp => view.scroll_help(-1, max_scroll),
HelpKeyAction::ScrollDown => view.scroll_help(1, max_scroll),
HelpKeyAction::PassThrough => {}
}
if should_close_help_after_key(&view, &keymap, key) {
view.close_help();
}
}
if view.is_popup_open() {
let max_scroll = popup_max_scroll(&view, terminal.size().ok(), &keymap);
if keymap.matches(KeyAction::Help, key) {
view.open_help();
continue;
}
view.clear_status_message();
match popup_action(&keymap, key) {
PopupAction::Close => view.close_popup(),
PopupAction::ScrollUp => view.scroll_popup(-1, max_scroll),
PopupAction::ScrollDown => view.scroll_popup(1, max_scroll),
PopupAction::SelectUp => view.select_up(),
PopupAction::SelectDown => view.select_down(),
PopupAction::SelectLeft => view.select_left(),
PopupAction::SelectRight => view.select_right(),
PopupAction::Edit => {
if let Err(e) = edit_selected(&mut terminal, &handle, &dir, &mut view) {
break Err(e);
}
view.reset_popup_scroll();
}
PopupAction::None => {}
}
continue;
}
if view.is_input_active() {
let selecting_target = view.is_relation_input()
&& view.input_buffer().is_empty()
&& if keymap.matches(KeyAction::SelectLeft, key) {
view.select_left();
true
} else if keymap.matches(KeyAction::SelectRight, key) {
view.select_right();
true
} else if keymap.matches(KeyAction::SelectUp, key) {
view.select_up();
true
} else if keymap.matches(KeyAction::SelectDown, key) {
view.select_down();
true
} else {
false
};
let step = if selecting_target {
Ok(())
} else {
match key.code {
KeyCode::Esc => {
view.end_input();
Ok(())
}
KeyCode::Enter => submit_input(&handle, &dir, &mut view),
KeyCode::Backspace => {
view.pop_input_char();
Ok(())
}
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
view.push_input_char(c);
Ok(())
}
_ => Ok(()),
}
};
if let Err(e) = step {
break Err(e);
}
continue;
}
if view.is_searching() {
let outcome = match key.code {
KeyCode::Esc => abort_search(&handle, &dir, &mut view),
KeyCode::Enter => commit_search(&handle, &dir, &mut view),
KeyCode::Backspace => {
view.pop_search_char();
apply_incremental_filter(&handle, &dir, &mut view)
}
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
view.push_search_char(c);
apply_incremental_filter(&handle, &dir, &mut view)
}
_ => Ok(()),
};
if let Err(e) = outcome {
break Err(e);
}
continue;
}
view.clear_status_message();
let step = if view.search_filter().is_some()
&& keymap.matches(KeyAction::ClearFilter, key)
{
clear_filter(&handle, &dir, &mut view)
} else if keymap.matches(KeyAction::Shell, key) || keymap.matches(KeyAction::Quit, key)
{
match quit_intent(&keymap, key, confirm_quit) {
QuitIntent::Leave(mode) => break Ok(mode),
QuitIntent::Confirm(mode) => {
confirming = Some(mode);
Ok(())
}
QuitIntent::None => Ok(()),
}
} else if keymap.matches(KeyAction::SelectLeft, key) {
view.select_left();
Ok(())
} else if keymap.matches(KeyAction::SelectRight, key) {
view.select_right();
Ok(())
} else if keymap.matches(KeyAction::SelectUp, key) {
view.select_up();
Ok(())
} else if keymap.matches(KeyAction::SelectDown, key) {
view.select_down();
Ok(())
} else if keymap.matches(KeyAction::MoveLeft, key) {
transition(&handle, &dir, &mut view, -1)
} else if keymap.matches(KeyAction::MoveRight, key) {
transition(&handle, &dir, &mut view, 1)
} else if keymap.matches(KeyAction::ReorderUp, key) {
reorder(&handle, &dir, &mut view, -1)
} else if keymap.matches(KeyAction::ReorderDown, key) {
reorder(&handle, &dir, &mut view, 1)
} else if keymap.matches(KeyAction::ToggleExpand, key) {
view.toggle_expand();
Ok(())
} else if keymap.matches(KeyAction::Add, key) {
view.begin_add();
Ok(())
} else if keymap.matches(KeyAction::DependencyAdd, key) {
if !view.begin_dependency_add() {
view.set_status_message(current().text(Message::KanbanNoSelection));
}
Ok(())
} else if keymap.matches(KeyAction::DependencyRemove, key) {
if !view.begin_dependency_remove() {
view.set_status_message(current().text(Message::KanbanNoSelection));
}
Ok(())
} else if keymap.matches(KeyAction::Parent, key) {
if !view.begin_parent() {
view.set_status_message(current().text(Message::KanbanNoSelection));
}
Ok(())
} else if keymap.matches(KeyAction::Edit, key) {
edit_selected(&mut terminal, &handle, &dir, &mut view)
} else if keymap.matches(KeyAction::Reload, key) {
reload(&handle, &dir, &mut view)
} else if keymap.matches(KeyAction::Maximize, key) {
view.toggle_maximize();
Ok(())
} else if keymap.matches(KeyAction::Search, key) {
view.begin_search(SearchMode::Contains);
apply_incremental_filter(&handle, &dir, &mut view)
} else if keymap.matches(KeyAction::RegexSearch, key) {
view.begin_search(SearchMode::Regex);
Ok(())
} else if keymap.matches(KeyAction::Help, key) {
view.open_help();
Ok(())
} else if keymap.matches(KeyAction::Details, key) {
view.open_popup();
Ok(())
} else {
Ok(())
};
if let Err(e) = step {
break Err(e);
}
}
}))
.unwrap_or_else(|payload| {
let _ = terminal.restore();
drop(_panic_hook);
std::panic::resume_unwind(payload);
});
let mode = outcome?;
terminal.restore()?;
Ok(mode)
}