use std::collections::BTreeSet;
use std::time::{Duration, Instant};
use anyhow::{Result, bail};
use aven_core::db::Database;
use ratatui::widgets::{ListState, TableState};
use crate::config::AppConfig;
use crate::tui::app_intake::IntakeController;
use crate::tui::authoring::AuthoringState;
use crate::tui::bounded_history::BoundedHistory;
use crate::tui::conflict_flow::ConflictFlowState;
use crate::tui::overlay::OverlayState;
use crate::tui::shortcut_buffer::ShortcutBuffer;
use crate::tui::store::{TaskOrder, TaskViewState, TuiStore};
use crate::tui::toast::{Toast, ToastSeverity};
pub(crate) const TASK_ROW_DOUBLE_CLICK: Duration = Duration::from_millis(500);
pub(super) const MAX_EXTERNAL_IMAGE_EXPORTS: usize = 8;
#[cfg(not(test))]
fn default_image_viewer_launcher() -> fn(&std::path::Path) -> anyhow::Result<()> {
crate::tui::platform::open_image_in_default_viewer
}
#[cfg(test)]
fn default_image_viewer_launcher() -> fn(&std::path::Path) -> anyhow::Result<()> {
|_| Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TaskRowClick {
pub(crate) task_id: crate::ids::TaskId,
pub(crate) viewport_row: u16,
pub(crate) at: Instant,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum TaskRefKind {
Short,
Durable,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum TaskCopyKind {
Title,
Description,
TitleAndDescription,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Focus {
Sidebar,
Tasks,
}
pub(super) const SEARCH_PREVIEW_LIMIT: usize = 8;
const NAVIGATION_HISTORY_LIMIT: usize = 32;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Notification {
Toast {
toast: Toast,
created_at: Instant,
},
Loading {
message: String,
started_at: Instant,
},
}
impl Notification {
pub(crate) fn toast(message: impl Into<String>, severity: ToastSeverity) -> Self {
Self::Toast {
toast: Toast::new(message, severity),
created_at: Instant::now(),
}
}
pub(crate) fn loading(message: impl Into<String>) -> Self {
Self::Loading {
message: message.into(),
started_at: Instant::now(),
}
}
pub(crate) fn toast_view(&self) -> Toast {
match self {
Self::Toast { toast, .. } => toast.clone(),
Self::Loading {
message,
started_at,
} => Toast::new(
format!("{} {message}", loading_frame(*started_at)),
ToastSeverity::Info,
)
.without_icon(),
}
}
}
fn loading_frame(started_at: Instant) -> &'static str {
let frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
let elapsed = started_at.elapsed().as_millis() as usize;
frames[(elapsed / 120) % frames.len()]
}
#[derive(Debug, Clone)]
pub(crate) struct WidgetState {
pub(crate) sidebar: ListState,
pub(crate) table: TableState,
pub(crate) marked_task_ids: BTreeSet<crate::ids::TaskId>,
pub(crate) inline_image_placements: Vec<crate::tui::ui::DetailInlineImagePlacement>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum FooterChoiceMode {
Status,
Priority,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct DetailNavigationState {
pub(super) task_id: crate::ids::TaskId,
pub(super) scroll: u16,
}
pub(crate) struct App {
pub(crate) store: TuiStore,
pub(crate) should_quit: bool,
pub(crate) focus: Focus,
pub(super) intake: IntakeController,
pub(crate) widgets: WidgetState,
pub(crate) overlay: Option<OverlayState>,
pub(super) onboarding_intro: Option<crate::tui::app_onboarding::OnboardingIntro>,
pub(crate) notification: Option<Notification>,
pub(super) pending_shortcut: ShortcutBuffer,
pub(super) pending_shortcut_scroll: u16,
pub(super) footer_choice_mode: Option<FooterChoiceMode>,
pub(super) detail_context: bool,
pub(super) sidebar_visible: bool,
pub(super) detail_context_scroll: u16,
pub(super) authoring: AuthoringState,
pub(super) conflict_flow: ConflictFlowState,
pub(super) pending_rename_project: Option<String>,
pub(super) pending_delete_project: Option<String>,
pub(super) pending_delete_attachment: Option<String>,
pub(super) needs_terminal_clear: bool,
pub(super) search: crate::tui::app_search::SearchController,
pub(super) update: crate::tui::app_update::UpdateController,
pub(super) next_refresh_at: Instant,
pub(crate) last_task_click: Option<TaskRowClick>,
pub(crate) hovered_detail_child_task_id: Option<crate::ids::TaskId>,
pub(crate) selected_detail_child_task_id: Option<crate::ids::TaskId>,
pub(crate) selected_detail_attachment_id: Option<String>,
pub(crate) detail_text_selection: Option<crate::tui::detail_selection::DetailTextSelection>,
pub(crate) detail_text_dragging: bool,
pub(super) previous_inline_image_placements: Vec<crate::tui::ui::DetailInlineImagePlacement>,
pub(super) previous_inline_image_backend: crate::tui::inline_images::InlineImageBackend,
pub(super) preview_controller: crate::tui::preview_controller::PreviewController,
pub(super) attachment_controller: crate::tui::attachment_controller::AttachmentController,
pub(super) image_viewer_launcher: fn(&std::path::Path) -> anyhow::Result<()>,
pub(super) external_image_exports: Vec<(String, tempfile::TempDir)>,
#[cfg(test)]
pub(crate) inline_image_context_override: Option<crate::tui::ui::DetailInlineImageContext>,
pub(super) navigation_history: BoundedHistory<TaskViewState>,
pub(super) detail_navigation_history: BoundedHistory<DetailNavigationState>,
}
impl App {
pub(crate) async fn new_with_view_state(
database: Database,
workspace: crate::workspaces::Workspace,
view_state: TaskViewState,
) -> Result<Self> {
Self::new_with_store(TuiStore::new_with_view_state(database, workspace, view_state).await?)
}
#[cfg(test)]
pub(crate) async fn new_for_tests(database: Database) -> Result<Self> {
let store = TuiStore::new(database, crate::workspaces::Workspace::default()).await?;
Self::new_with_store(store)
}
fn new_with_store(store: TuiStore) -> Result<Self> {
let next_refresh_at = store.last_refresh + crate::tui::app_lifecycle::REFRESH_INTERVAL;
let mut app = Self {
store,
should_quit: false,
focus: Focus::Tasks,
intake: IntakeController::new(),
widgets: WidgetState {
sidebar: ListState::default(),
table: TableState::default(),
marked_task_ids: BTreeSet::new(),
inline_image_placements: Vec::new(),
},
overlay: None,
onboarding_intro: None,
notification: None,
pending_shortcut: ShortcutBuffer::default(),
pending_shortcut_scroll: 0,
footer_choice_mode: None,
detail_context: false,
sidebar_visible: true,
detail_context_scroll: 0,
authoring: AuthoringState::default(),
conflict_flow: ConflictFlowState::default(),
pending_rename_project: None,
pending_delete_project: None,
pending_delete_attachment: None,
needs_terminal_clear: false,
search: crate::tui::app_search::SearchController::new(),
update: crate::tui::app_update::UpdateController::new(),
next_refresh_at,
last_task_click: None,
hovered_detail_child_task_id: None,
selected_detail_child_task_id: None,
selected_detail_attachment_id: None,
detail_text_selection: None,
detail_text_dragging: false,
previous_inline_image_placements: Vec::new(),
previous_inline_image_backend: crate::tui::inline_images::InlineImageBackend::None,
preview_controller: crate::tui::preview_controller::PreviewController::new(),
attachment_controller: crate::tui::attachment_controller::AttachmentController::new(),
image_viewer_launcher: default_image_viewer_launcher(),
external_image_exports: Vec::new(),
#[cfg(test)]
inline_image_context_override: None,
navigation_history: BoundedHistory::new(NAVIGATION_HISTORY_LIMIT),
detail_navigation_history: BoundedHistory::new(NAVIGATION_HISTORY_LIMIT),
};
app.restore_sidebar_selection();
app.widgets
.table
.select((app.store.main_row_count() > 0).then_some(0));
Ok(app)
}
pub(crate) fn open_task_on_start(&mut self, task_id: &crate::ids::TaskId) -> Result<()> {
if !self.select_task_by_id(task_id) {
bail!("task target disappeared before the TUI loaded");
}
self.focus = Focus::Tasks;
self.overlay = Some(OverlayState::Detail { scroll: 0 });
Ok(())
}
pub(super) fn select_task_by_id(&mut self, task_id: &crate::ids::TaskId) -> bool {
let Some(index) = self
.store
.tasks
.iter()
.position(|item| &item.task.id == task_id)
else {
return false;
};
self.widgets.table.select(Some(index));
true
}
pub(crate) fn set_config(&mut self, config: AppConfig) {
self.store.task_columns = config.tui.columns.clone();
self.intake.set_config(config);
}
pub(crate) fn set_add_task_db_path(&mut self, db_path: std::path::PathBuf) {
self.intake.set_db_path(db_path);
}
pub(crate) fn begin_command(&mut self) {
self.pending_shortcut.clear();
self.overlay = Some(OverlayState::Command {
state: crate::tui::overlay::CommandState::blank(),
});
}
pub(super) fn push_navigation_state(&mut self, previous: TaskViewState) {
if previous == self.store.view_state {
return;
}
self.navigation_history.push(previous);
}
pub(super) fn clear_navigation_history(&mut self) {
self.navigation_history.clear();
}
pub(super) fn push_detail_navigation_state(&mut self, previous: DetailNavigationState) {
self.detail_navigation_history.push(previous);
}
pub(super) fn go_back_in_detail(&mut self) -> bool {
let Some(previous) = self.detail_navigation_history.pop() else {
return false;
};
let Some(index) = self
.store
.tasks
.iter()
.position(|item| item.task.id == previous.task_id)
else {
self.set_warning("previous task is hidden by the current view");
return false;
};
self.widgets.table.select(Some(index));
self.focus = Focus::Tasks;
self.detail_context = false;
self.detail_context_scroll = previous.scroll;
self.overlay = Some(crate::tui::overlay::OverlayState::Detail {
scroll: previous.scroll,
});
true
}
pub(super) async fn go_back(&mut self) -> Result<()> {
let Some(previous) = self.navigation_history.pop() else {
self.set_info("no previous navigation state");
return Ok(());
};
let result = self.store.restore_view_state(previous).await?;
self.apply_filter_selection(result.selected);
if let Some(project) = result.fallback_scope {
self.set_warning(format!("project scope {project} is no longer available"));
} else {
self.set_info("returned to previous navigation state");
}
Ok(())
}
pub(super) fn clear_live_search_preview(&mut self) {
self.search.cancel();
}
pub(super) fn search_preview_work_pending(&self) -> bool {
self.search.work_pending()
}
pub(super) async fn set_sort(&mut self, sort: TaskOrder) -> Result<()> {
let previous = self.store.view_state.clone();
let selected = self.store.set_order(sort).await?;
self.push_navigation_state(previous);
self.apply_filter_selection(selected);
self.set_info(format!(
"order {} {}",
self.store.sort_label(),
self.store.sort_direction_label()
));
Ok(())
}
pub(super) async fn reverse_sort(&mut self) -> Result<()> {
let previous = self.store.view_state.clone();
let selected = self.store.reverse_sort().await?;
self.push_navigation_state(previous);
self.apply_filter_selection(selected);
self.set_info(format!(
"order {} {}",
self.store.sort_label(),
self.store.sort_direction_label()
));
Ok(())
}
pub(super) fn set_info(&mut self, message: impl Into<String>) {
self.set_toast(message, ToastSeverity::Info);
}
pub(super) fn set_warning(&mut self, message: impl Into<String>) {
self.set_toast(message, ToastSeverity::Warning);
}
pub(super) fn set_error(&mut self, message: impl Into<String>) {
self.set_toast(message, ToastSeverity::Error);
}
pub(super) fn set_success(&mut self, message: impl Into<String>) {
self.set_toast(message, ToastSeverity::Success);
}
fn set_toast(&mut self, message: impl Into<String>, severity: ToastSeverity) {
self.notification = Some(Notification::toast(message, severity));
}
}
#[cfg(test)]
#[path = "app_tests.rs"]
mod tests;