mod detail;
mod dialog;
mod footer;
mod header;
pub(crate) use self::header::{HeaderTarget, header_target_at};
mod input;
mod overlays;
mod recent_actions;
mod scroll;
mod shortcuts;
mod sidebar;
mod task_display;
mod task_list;
mod timestamps;
mod toast;
mod truncate;
#[cfg(test)]
pub(crate) use self::sidebar::sidebar_layout;
pub(crate) use self::sidebar::{sidebar_click_at_for, sidebar_layout_for};
use self::detail::render_detail_underlay;
use self::footer::{FooterMode, footer_bar};
use self::header::render_header;
use self::overlays::{
SearchRenderStatus, SearchRenderView, render_confirm, render_database_stats,
render_multiline_input, render_picker, render_search, render_sync_status, render_tag_combobox,
render_text_input, render_text_panel,
};
use self::recent_actions::render_recent_actions;
use self::shortcuts::{render_command, render_detail_help, render_help, render_prefix_hints};
use self::sidebar::{render_sidebar, render_sidebar_overlay};
use self::task_list::render_tasks;
use self::toast::render_toast;
pub(crate) use self::detail::{
DetailMetadataTarget, detail_child_task_at_position, detail_metadata_target_at,
detail_scroll_cap,
};
pub(crate) use self::overlays::{database_stats_scroll_cap, text_panel_scroll_cap};
pub(crate) use self::recent_actions::recent_action_at_position;
pub(crate) use self::shortcuts::{detail_help_scroll_cap, help_scroll_cap, prefix_hint_scroll_cap};
pub(crate) use self::task_list::{task_at_position, task_status_at_position};
use ratatui::Frame;
use ratatui::layout::{Alignment, Constraint, Layout};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, Paragraph};
use crate::tui::app::{Focus, FooterChoiceMode, WidgetState};
use crate::tui::overlay::{
HeaderMenuKind, HeaderMenuView, OrderMenuView, OverlayRoute, OverlayView, TextInputView,
};
use crate::tui::store::{TaskOrder, TaskView, TuiStore};
use crate::tui::theme::{ACCENT, BG, BG_ALT, BG_PANEL, FG, FG_DIM, GREEN, PINK, SELECTED};
use crate::tui::toast::Toast;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ViewSurface {
Main,
AddTask,
}
#[derive(Clone)]
pub(crate) struct ViewState {
pub(crate) focus: Focus,
pub(crate) overlay: Option<OverlayView>,
pub(crate) detail_underlay: bool,
pub(crate) detail_underlay_scroll: u16,
pub(crate) hovered_detail_child_task_id: Option<String>,
pub(crate) notification: Option<Toast>,
pub(crate) pending_shortcut: Vec<String>,
pub(crate) pending_shortcut_scroll: u16,
pub(crate) footer_choice_mode: Option<FooterChoiceMode>,
pub(crate) sidebar_visible: bool,
pub(crate) surface: ViewSurface,
}
impl ViewState {
fn footer_mode(&self) -> FooterMode {
match self.footer_choice_mode {
Some(FooterChoiceMode::Status) => return FooterMode::StatusChoice,
Some(FooterChoiceMode::Priority) => return FooterMode::PriorityChoice,
None => {}
}
if matches!(
self.overlay,
Some(OverlayView::Detail { .. } | OverlayView::DetailHelp { .. })
) {
FooterMode::Detail
} else {
FooterMode::List
}
}
}
fn detail_underlay_scroll(view: &ViewState) -> u16 {
match &view.overlay {
Some(OverlayView::Detail { scroll }) => *scroll,
Some(OverlayView::DetailHelp { .. }) => 0,
_ => view.detail_underlay_scroll,
}
}
pub(crate) fn render(
frame: &mut Frame,
store: &TuiStore,
widgets: &mut WidgetState,
view: &ViewState,
) {
frame.render_widget(Block::new().style(Style::new().bg(BG)), frame.area());
if view.surface == ViewSurface::AddTask {
render_add_task_surface(frame, view);
return;
}
if frame.area().width < 70 || frame.area().height < 18 {
frame.render_widget(
Paragraph::new("terminal too small for aven tui")
.alignment(Alignment::Center)
.style(Style::new().fg(FG).bg(BG)),
frame.area(),
);
return;
}
let inner = frame.area();
let [header, body, footer] = Layout::vertical([
Constraint::Length(2),
Constraint::Fill(1),
Constraint::Length(2),
])
.areas(inner);
render_header(frame, store, header);
let inline_title_editor = inline_title_editor(view);
let inline_detail_title_editor = inline_detail_title_editor(view);
if body.width < 100 {
render_main_surface(frame, store, widgets, view.focus, body, inline_title_editor);
if let Some(layout) =
crate::tui::ui::sidebar_layout_for(inner, view.focus, view.sidebar_visible)
&& layout.overlay
{
render_sidebar_overlay(frame, store, widgets, view.focus, body);
}
} else {
if let Some(layout) =
crate::tui::ui::sidebar_layout_for(inner, view.focus, view.sidebar_visible)
{
let main = ratatui::layout::Rect {
x: layout.sidebar.x.saturating_add(layout.sidebar.width),
y: body.y,
width: body.width.saturating_sub(layout.sidebar.width),
height: body.height,
};
render_sidebar(
frame,
store,
widgets,
view.focus,
layout.sidebar,
layout.overlay,
);
render_main_surface(frame, store, widgets, view.focus, main, inline_title_editor);
} else {
render_main_surface(frame, store, widgets, view.focus, body, inline_title_editor);
}
}
frame.render_widget(footer_bar(view.footer_mode(), footer.width), footer);
if view.detail_underlay {
render_detail_underlay(
frame,
store,
widgets,
detail_underlay_scroll(view),
inline_detail_title_editor,
view.hovered_detail_child_task_id.as_deref(),
);
}
if let Some(overlay) = &view.overlay {
render_overlay(
frame,
store,
widgets,
overlay,
inline_title_editor.is_some() || inline_detail_title_editor.is_some(),
view.hovered_detail_child_task_id.as_deref(),
);
}
if !view.pending_shortcut.is_empty() && !add_task_dialog_prefix_active(view) {
render_prefix_hints(frame, view);
}
if let Some(toast) = &view.notification {
render_toast(frame, toast);
}
}
fn render_main_surface(
frame: &mut Frame,
store: &TuiStore,
widgets: &mut WidgetState,
focus: Focus,
area: ratatui::layout::Rect,
inline_title_editor: Option<&TextInputView>,
) {
if store.view_state.view == TaskView::RecentActions {
render_recent_actions(frame, store, widgets, focus, area);
} else {
render_tasks(frame, store, widgets, focus, area, inline_title_editor);
}
}
fn add_task_dialog_prefix_active(view: &ViewState) -> bool {
matches!(
&view.overlay,
Some(OverlayView::AddTask(state))
if state.status_prefix_active || state.priority_prefix_active
)
}
fn render_add_task_surface(frame: &mut Frame, view: &ViewState) {
if frame.area().width < 30 || frame.area().height < 8 {
frame.render_widget(
Paragraph::new("terminal too small for add task")
.alignment(Alignment::Center)
.style(Style::new().fg(FG).bg(BG)),
frame.area(),
);
return;
}
if let Some(overlay) = &view.overlay {
render_add_task_surface_overlay(frame, view, overlay);
}
if !view.pending_shortcut.is_empty() && !add_task_dialog_prefix_active(view) {
render_prefix_hints(frame, view);
}
if let Some(toast) = &view.notification {
render_toast(frame, toast);
}
}
fn render_add_task_surface_overlay(frame: &mut Frame, _view: &ViewState, overlay: &OverlayView) {
match overlay {
OverlayView::AddTask(state) => self::overlays::render_add_task_full_frame(frame, state),
OverlayView::MultilineInput(state)
if matches!(
state.route,
OverlayRoute::AddTaskDescription | OverlayRoute::AddTaskNatural
) =>
{
render_add_task_multiline_full_frame(frame, state)
}
_ => render_overlay_content(frame, overlay, false),
}
}
fn render_add_task_multiline_full_frame(
frame: &mut Frame,
state: &crate::tui::overlay::MultilineInputView,
) {
use crate::tui::overlay::OverlayRoute::{AddTaskDescription, AddTaskNatural};
let placeholder = match state.route {
AddTaskDescription => "Optional details, links, or handoff context...",
AddTaskNatural => "Describe the task in natural language...",
_ => return,
};
let hint_line = match state.route {
AddTaskDescription => self::overlays::add_task_description_hint_line(),
AddTaskNatural => self::overlays::add_task_natural_hint_line(),
_ => return,
};
let content = dialog::Dialog::new(&state.title, frame.area().width, frame.area().height)
.render_block_at(frame, frame.area());
let visible_rows = content.height.saturating_sub(2).max(1) as usize;
let start = self::overlays::tail_viewport_start(state.row, visible_rows);
let mut lines = Vec::new();
for (row_index, line) in state
.lines
.iter()
.enumerate()
.skip(start)
.take(visible_rows)
{
lines.push(self::overlays::add_task_free_text_input_line(
line,
if row_index == state.row {
Some(state.column)
} else {
None
},
line.is_empty() && state.lines.len() == 1,
placeholder,
));
}
while lines.len() + 1 < content.height as usize {
lines.push(Line::from(""));
}
lines.push(hint_line);
frame.render_widget(
Paragraph::new(Text::from(lines)).style(Style::new().fg(FG).bg(crate::tui::theme::BG_ALT)),
content,
);
}
fn edit_title_view(view: &ViewState) -> Option<&TextInputView> {
match &view.overlay {
Some(OverlayView::TextInput(state)) if state.route == OverlayRoute::EditTitle => {
Some(state)
}
_ => None,
}
}
fn inline_title_editor(view: &ViewState) -> Option<&TextInputView> {
if view.focus != Focus::Tasks || view.detail_underlay {
return None;
}
edit_title_view(view)
}
fn inline_detail_title_editor(view: &ViewState) -> Option<&TextInputView> {
if !view.detail_underlay {
return None;
}
edit_title_view(view)
}
fn render_header_menu(frame: &mut Frame, state: &HeaderMenuView) {
use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph};
let menu_state = crate::tui::overlay::HeaderMenuState {
kind: state.kind,
column: state.column,
row: state.row,
selected: state.selected,
items: state.items.clone(),
};
let area = menu_state.area(frame.area().width, frame.area().height);
frame.render_widget(Clear, area);
let block = Block::new()
.title(menu_title(header_menu_title(state.kind)))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::new().fg(ACCENT))
.style(Style::new().bg(BG_ALT));
let inner = block.inner(area);
frame.render_widget(block, area);
let prefix_width = if matches!(state.kind, HeaderMenuKind::Scope) {
state
.items
.iter()
.map(|item| project_prefix_and_name(&item.label).map_or(0, |(prefix, _)| prefix.len()))
.max()
.unwrap_or(0)
} else {
0
};
let lines = state
.items
.iter()
.enumerate()
.map(|(index, item)| {
header_menu_line(
state.kind,
index == state.selected,
&item.key,
&item.label,
prefix_width,
)
})
.collect::<Vec<_>>();
frame.render_widget(
Paragraph::new(Text::from(lines)).style(Style::new().fg(FG).bg(BG_ALT)),
inner,
);
}
fn render_order_menu(frame: &mut Frame, state: &OrderMenuView) {
use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph};
let menu_state = crate::tui::overlay::OrderMenuState {
column: state.column,
row: state.row,
selected: state.selected,
};
let area = menu_state.area(frame.area().width, frame.area().height);
frame.render_widget(Clear, area);
let block = Block::new()
.title(menu_title("order"))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::new().fg(ACCENT))
.style(Style::new().bg(BG_ALT));
let inner = block.inner(area);
frame.render_widget(block, area);
let mut lines = Vec::new();
for (order, key, label) in order_menu_items() {
lines.push(order_menu_line(order, key, label, state.selected));
}
lines.push(Line::from(vec![
Span::styled("Esc", Style::new().fg(FG).add_modifier(Modifier::BOLD)),
Span::styled(" close", Style::new().fg(FG_DIM)),
]));
frame.render_widget(
Paragraph::new(Text::from(lines)).style(Style::new().fg(FG).bg(BG_ALT)),
inner,
);
}
fn menu_title(title: &'static str) -> Line<'static> {
Line::from(vec![
Span::styled("─ ", Style::new().fg(ACCENT)),
Span::styled(title, Style::new().fg(FG).add_modifier(Modifier::BOLD)),
Span::styled(" ", Style::new().fg(ACCENT)),
])
}
fn header_menu_title(kind: HeaderMenuKind) -> &'static str {
match kind {
HeaderMenuKind::Workspace => "workspace",
HeaderMenuKind::Scope => "scope",
HeaderMenuKind::View => "view",
}
}
fn header_menu_line(
kind: HeaderMenuKind,
selected: bool,
key: &str,
label: &str,
prefix_width: usize,
) -> Line<'static> {
let row_style = if selected {
SELECTED
} else {
Style::new().fg(FG).bg(BG_PANEL)
};
let marker = if selected { "▸" } else { " " };
let mut spans = vec![
Span::styled(format!("{marker} "), row_style),
Span::styled(format!("{key:<2}"), row_style.add_modifier(Modifier::BOLD)),
Span::styled(" ", row_style),
];
if matches!(kind, HeaderMenuKind::Scope)
&& let Some((prefix, name)) = project_prefix_and_name(label)
{
spans.extend([
Span::styled(
format!("{prefix:<prefix_width$}"),
row_style
.fg(crate::tui::theme::project_color(name))
.add_modifier(Modifier::BOLD),
),
Span::styled(" ", row_style),
Span::styled(name.to_string(), row_style),
]);
} else {
spans.push(Span::styled(
label.to_string(),
header_menu_label_style(kind, label, row_style, selected),
));
}
Line::from(spans)
}
fn header_menu_label_style(
kind: HeaderMenuKind,
label: &str,
row_style: Style,
selected: bool,
) -> Style {
if matches!(kind, HeaderMenuKind::View) {
let bg = row_style.bg.unwrap_or(BG_PANEL);
let style = match label {
"queue" => Style::new().fg(ACCENT).bg(bg),
"open" => Style::new().fg(GREEN).bg(bg),
"conflicts" => Style::new().fg(PINK).bg(bg),
_ => row_style,
};
if selected {
style.add_modifier(Modifier::BOLD)
} else {
style
}
} else {
row_style
}
}
fn project_prefix_and_name(label: &str) -> Option<(&str, &str)> {
label
.split_once(' ')
.filter(|(prefix, name)| !prefix.is_empty() && !name.is_empty())
}
fn order_menu_line(
order: TaskOrder,
key: &'static str,
label: &'static str,
selected: TaskOrder,
) -> Line<'static> {
let row_style = if order == selected {
SELECTED
} else {
Style::new().fg(FG).bg(BG_PANEL)
};
let marker = if order == selected { "▸" } else { " " };
Line::from(vec![
Span::styled(format!("{marker} "), row_style),
Span::styled(format!("{key:<2}"), row_style.add_modifier(Modifier::BOLD)),
Span::styled(" ", row_style),
Span::styled(label, row_style),
])
}
fn order_menu_items() -> [(TaskOrder, &'static str, &'static str); 5] {
[
(TaskOrder::Created, "c", "created"),
(TaskOrder::Updated, "u", "updated"),
(TaskOrder::Priority, "p", "priority"),
(TaskOrder::Project, "g", "project"),
(TaskOrder::Title, "t", "title"),
]
}
fn render_overlay_content(frame: &mut Frame, overlay: &OverlayView, inline_title_editor: bool) {
match overlay {
OverlayView::Help { scroll } => render_help(frame, *scroll),
OverlayView::DetailHelp { scroll } => render_detail_help(frame, *scroll),
OverlayView::Search {
input,
cursor,
results,
selected,
total_matches,
stale,
no_matches_cached,
purpose,
} => render_search(
frame,
SearchRenderView {
input,
cursor: *cursor,
results,
selected: *selected,
total_matches: *total_matches,
status: SearchRenderStatus {
stale: *stale,
no_matches_cached: *no_matches_cached,
},
purpose,
},
),
OverlayView::Command {
input,
cursor,
cycle_input,
highlighted,
} => render_command(
frame,
input,
*cursor,
cycle_input.as_deref(),
highlighted.as_deref(),
),
OverlayView::AddTask(state) => self::overlays::render_add_task(frame, state),
OverlayView::TextInput(state)
if state.route == OverlayRoute::EditTitle && inline_title_editor => {}
OverlayView::TextInput(state) => render_text_input(frame, state),
OverlayView::MultilineInput(state) => render_multiline_input(frame, state),
OverlayView::Picker(state) => render_picker(frame, state),
OverlayView::TagCombobox(state) => render_tag_combobox(frame, state),
OverlayView::HeaderMenu(state) => render_header_menu(frame, state),
OverlayView::OrderMenu(state) => render_order_menu(frame, state),
OverlayView::Confirm(state) => render_confirm(frame, state),
OverlayView::TextPanel(state) => render_text_panel(frame, state),
OverlayView::SyncStatus(state) => render_sync_status(frame, state),
OverlayView::DatabaseStats { stats, scroll } => {
render_database_stats(frame, stats, *scroll)
}
OverlayView::Detail { .. } => {}
}
}
fn render_overlay(
frame: &mut Frame,
store: &TuiStore,
widgets: &mut WidgetState,
overlay: &OverlayView,
inline_title_editor: bool,
hovered_detail_child_task_id: Option<&str>,
) {
if matches!(
overlay,
OverlayView::Detail { .. } | OverlayView::DetailHelp { .. }
) {
let scroll = match overlay {
OverlayView::Detail { scroll } => *scroll,
OverlayView::DetailHelp { .. } => 0,
_ => 0,
};
render_detail_underlay(
frame,
store,
widgets,
scroll,
None,
hovered_detail_child_task_id,
);
if matches!(overlay, OverlayView::DetailHelp { .. }) {
render_overlay_content(frame, overlay, inline_title_editor);
}
return;
}
render_overlay_content(frame, overlay, inline_title_editor);
}