use std::time::Duration;
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, Paragraph, Wrap},
};
use crate::prelude::*;
use input::{Key, KeyEvent};
use operation::{Event, Operation, TaskArchiveOp};
use settings::Settings;
use views::View;
use widgets::focus::FocusRing;
use widgets::focusable::FocusOp;
use widgets::input_field::InputField;
use crate::task_registry::{TaskId, TaskStatus};
const FOCUS_FILTER: &str = "filter";
const FOCUS_LIST: &str = "list";
const FOCUS_DETAIL: &str = "detail";
#[derive(Debug)]
#[allow(dead_code)]
enum ViewMode {
List,
Detail {
task_id: TaskId,
title: String,
status: TaskStatus,
lines: Vec<String>,
scroll: usize,
},
}
#[derive(Debug)]
pub struct TaskArchiveView {
filter: InputField,
focus: FocusRing,
cursor: usize,
scroll: usize,
mode: ViewMode,
}
impl TaskArchiveView {
pub fn new() -> Self {
Self {
filter: InputField::new("Filter"),
focus: FocusRing::new(vec![FOCUS_FILTER, FOCUS_LIST]),
cursor: 0,
scroll: 0,
mode: ViewMode::List,
}
}
fn move_up(&mut self, n: usize) {
self.cursor = self.cursor.saturating_sub(n);
}
fn move_down(&mut self, n: usize, list_len: usize) {
if list_len == 0 { return; }
self.cursor = (self.cursor + n).min(list_len - 1);
}
pub fn clamp_cursor(&mut self, list_len: usize) {
if list_len == 0 { self.cursor = 0; }
else { self.cursor = self.cursor.min(list_len - 1); }
}
pub fn show_detail(
&mut self,
task_id: TaskId,
title: String,
status: TaskStatus,
lines: Vec<String>,
) {
self.mode = ViewMode::Detail { task_id, title, status, lines, scroll: 0 };
self.focus.set_focus(FOCUS_DETAIL);
}
pub fn close_detail(&mut self) {
self.mode = ViewMode::List;
self.focus = FocusRing::new(vec![FOCUS_FILTER, FOCUS_LIST]);
}
pub fn is_detail(&self) -> bool {
matches!(self.mode, ViewMode::Detail { .. })
}
}
impl View for TaskArchiveView {
const KIND: crate::views::ViewKind = crate::views::ViewKind::Modal;
fn save_state(&mut self, _app: &mut crate::app_state::AppState) {}
fn handle_key(&self, key: KeyEvent) -> Vec<Operation> {
match &self.mode {
ViewMode::Detail { .. } => {
match key.key {
Key::ArrowUp | Key::Char('k') => vec![Operation::NavigateUp],
Key::ArrowDown | Key::Char('j') => vec![Operation::NavigateDown],
Key::PageUp => vec![Operation::NavigatePageUp],
Key::PageDown => vec![Operation::NavigatePageDown],
Key::Home => vec![Operation::NavigateHome],
Key::End => vec![Operation::NavigateEnd],
_ => vec![],
}
}
ViewMode::List => {
if key.key == Key::Tab {
return vec![Operation::Focus(FocusOp::Next)];
}
if key.key == Key::BackTab {
return vec![Operation::Focus(FocusOp::Prev)];
}
match self.focus.current() {
FOCUS_FILTER => {
if let Some(field_op) = crate::widgets::input_field::key_to_op(key) {
return vec![Operation::TaskArchiveLocal(TaskArchiveOp::FilterInput(field_op))];
}
match key.key {
Key::ArrowUp => vec![Operation::NavigateUp],
Key::ArrowDown => vec![Operation::NavigateDown],
Key::PageUp => vec![Operation::NavigatePageUp],
Key::PageDown => vec![Operation::NavigatePageDown],
_ => vec![],
}
}
FOCUS_LIST => {
match key.key {
Key::ArrowUp => vec![Operation::NavigateUp],
Key::ArrowDown => vec![Operation::NavigateDown],
Key::PageUp => vec![Operation::NavigatePageUp],
Key::PageDown => vec![Operation::NavigatePageDown],
Key::Enter => vec![Operation::TaskArchiveLocal(TaskArchiveOp::OpenSelected)],
Key::Char('c') | Key::Char('C') => {
vec![Operation::TaskArchiveLocal(TaskArchiveOp::CancelSelected)]
}
_ => {
if let Some(field_op) = crate::widgets::input_field::key_to_op(key) {
return vec![Operation::TaskArchiveLocal(TaskArchiveOp::FilterInput(field_op))];
}
vec![]
}
}
}
_ => vec![],
}
}
}
}
fn handle_mouse(&self, _mouse: input::MouseEvent) -> Vec<Operation> {
vec![]
}
fn handle_operation(&mut self, op: &Operation, _settings: &Settings) -> Option<Event> {
match op {
Operation::Focus(focus_op) => {
match focus_op {
FocusOp::Next => self.focus.focus_next(),
FocusOp::Prev => self.focus.focus_prev(),
_ => {}
}
Some(Event::applied("task_archive", op.clone()))
}
Operation::TaskArchiveLocal(ta_op) => {
match ta_op {
TaskArchiveOp::FilterInput(field_op) => {
self.filter.apply(field_op);
self.cursor = 0;
self.scroll = 0;
}
TaskArchiveOp::CloseDetail => {
self.close_detail();
}
TaskArchiveOp::TaskUpdated { new_len } => {
self.clamp_cursor(*new_len);
}
TaskArchiveOp::OpenSelected | TaskArchiveOp::CancelSelected => {}
TaskArchiveOp::ShowDetail { task_id, title, status, lines } => {
self.show_detail(*task_id, title.clone(), status.clone(), lines.clone());
}
}
Some(Event::applied("task_archive", op.clone()))
}
Operation::NavigateUp => {
match &mut self.mode {
ViewMode::Detail { scroll, .. } => {
*scroll = scroll.saturating_sub(1);
}
ViewMode::List => self.move_up(1),
}
Some(Event::applied("task_archive", op.clone()))
}
Operation::NavigateDown => {
match &mut self.mode {
ViewMode::Detail { scroll, lines, .. } => {
*scroll = (*scroll + 1).min(lines.len().saturating_sub(1));
}
ViewMode::List => self.move_down(1, usize::MAX),
}
Some(Event::applied("task_archive", op.clone()))
}
Operation::NavigatePageUp => {
match &mut self.mode {
ViewMode::Detail { scroll, .. } => {
*scroll = scroll.saturating_sub(20);
}
ViewMode::List => self.move_up(10),
}
Some(Event::applied("task_archive", op.clone()))
}
Operation::NavigatePageDown => {
match &mut self.mode {
ViewMode::Detail { scroll, lines, .. } => {
*scroll = (*scroll + 20).min(lines.len().saturating_sub(1));
}
ViewMode::List => self.move_down(10, usize::MAX),
}
Some(Event::applied("task_archive", op.clone()))
}
Operation::NavigateHome => {
if let ViewMode::Detail { scroll, .. } = &mut self.mode {
*scroll = 0;
}
Some(Event::applied("task_archive", op.clone()))
}
Operation::NavigateEnd => {
if let ViewMode::Detail { scroll, lines, .. } = &mut self.mode {
*scroll = lines.len().saturating_sub(1);
}
Some(Event::applied("task_archive", op.clone()))
}
_ => None,
}
}
fn render(&self, frame: &mut Frame, area: Rect, theme: &crate::theme::Theme) {
let _ = (frame, area, theme);
}
fn status_bar(
&self,
_state: &crate::app_state::AppState,
bar: &mut crate::widgets::status_bar::StatusBarBuilder,
) {
match &self.mode {
ViewMode::List => bar.label("Tasks ↑↓ navigate · Enter open · c cancel · Esc close"),
ViewMode::Detail { title, .. } => bar.label(format!("Log: {} ↑↓ scroll · Esc back", title)),
};
}
}
impl TaskArchiveView {
pub fn render_with_registry(
&self,
frame: &mut Frame,
area: Rect,
theme: &crate::theme::Theme,
task_registry: &crate::task_registry::TaskRegistry,
) {
match &self.mode {
ViewMode::List => self.render_list(frame, area, theme, task_registry),
ViewMode::Detail { title, status, lines, scroll, .. } => {
self.render_detail(frame, area, theme, title, status, lines, *scroll)
}
}
}
fn render_list(
&self,
frame: &mut Frame,
area: Rect,
theme: &crate::theme::Theme,
task_registry: &crate::task_registry::TaskRegistry,
) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(1)])
.split(area);
let filter_area = chunks[0];
let list_area = chunks[1];
let bg = theme.bg();
let fg = theme.fg();
let dim = theme.fg_dim();
let accent = theme.accent();
let sel_bg = theme.selection_bg();
let filter_focused = self.focus.current() == FOCUS_FILTER;
let fstyle = if filter_focused {
Style::default().fg(accent).bg(bg)
} else {
Style::default().fg(dim).bg(bg)
};
let filter_prefix = if filter_focused { "> " } else { " " };
frame.render_widget(
Paragraph::new(format!("{}{}", filter_prefix, self.filter.text())).style(fstyle),
filter_area,
);
let q = self.filter.text().to_lowercase();
let mut entries: Vec<&crate::task_registry::Task> = task_registry
.all_tasks()
.filter(|t| {
if q.is_empty() { return true; }
t.command.to_lowercase().contains(&q)
|| t.key.queue.0.to_lowercase().contains(&q)
|| t.key.target.to_lowercase().contains(&q)
})
.collect();
entries.sort_by(|a, b| {
let a_live = matches!(a.status, TaskStatus::Running | TaskStatus::Pending);
let b_live = matches!(b.status, TaskStatus::Running | TaskStatus::Pending);
if a_live != b_live { return b_live.cmp(&a_live); }
b.created_at.cmp(&a.created_at)
});
let list_len = entries.len();
let visible_rows = list_area.height as usize;
let cursor = if list_len == 0 { 0 } else { self.cursor.min(list_len - 1) };
let mut scroll = self.scroll;
if list_len > 0 {
if cursor < scroll { scroll = cursor; }
if cursor >= scroll + visible_rows { scroll = cursor + 1 - visible_rows; }
}
let col_w = list_area.width as usize;
let items: Vec<ListItem> = entries
.iter()
.enumerate()
.skip(scroll)
.take(visible_rows)
.map(|(idx, task)| {
let is_sel = idx == cursor;
let (ind, ind_style) = task_indicator(task, theme);
let dur = task_duration(task);
let status_str = task_status_label(&task.status);
let raw = format!("{}: {}", task.key.queue.0, task.command);
let reserved = 2 + 1 + 1 + 5 + 12;
let max_lbl = col_w.saturating_sub(reserved);
let label = if unicode_width::UnicodeWidthStr::width(raw.as_str()) > max_lbl {
let mut acc = String::new();
let mut used = 0usize;
for ch in raw.chars() {
let w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1);
if used + w > max_lbl.saturating_sub(1) { break; }
acc.push(ch);
used += w;
}
format!("{}…", acc)
} else {
raw.clone()
};
let label_w = unicode_width::UnicodeWidthStr::width(label.as_str());
let pad: String = " ".repeat(max_lbl.saturating_sub(label_w));
let row_bg = if is_sel { sel_bg } else { bg };
let row_fg = if is_sel { accent } else { fg };
let line = Line::from(vec![
Span::styled(ind, ind_style.bg(row_bg)),
Span::styled(" ", Style::default().bg(row_bg)),
Span::styled(label + &pad, Style::default().fg(row_fg).bg(row_bg)),
Span::styled(format!(" {:>4} ", dur), Style::default().fg(dim).bg(row_bg)),
Span::styled(format!("{:<12}", status_str), ind_style.bg(row_bg)),
]);
ListItem::new(line)
})
.collect();
frame.render_widget(
List::new(items).style(Style::default().fg(fg).bg(bg)),
list_area,
);
if list_len == 0 {
let msg = if q.is_empty() { "No tasks have been run yet." } else { "No matching tasks." };
frame.render_widget(
Paragraph::new(msg).style(Style::default().fg(dim).bg(bg)),
list_area,
);
}
}
#[allow(clippy::too_many_arguments)]
fn render_detail(
&self,
frame: &mut Frame,
area: Rect,
theme: &crate::theme::Theme,
title: &str,
status: &TaskStatus,
lines: &[String],
scroll: usize,
) {
let bg = theme.bg();
let fg = theme.fg();
let dim = theme.fg_dim();
let (status_str, status_style) = match status {
TaskStatus::Running => ("● Running", Style::default().fg(fg)),
TaskStatus::Pending => ("○ Pending", Style::default().fg(dim)),
TaskStatus::Success => ("✓ Success", Style::default().fg(theme.level_success())),
TaskStatus::Warning => ("⚠ Warning", Style::default().fg(theme.level_warn())),
TaskStatus::Error => ("✗ Error", Style::default().fg(theme.level_error())),
TaskStatus::Cancelled => ("— Cancelled", Style::default().fg(dim)),
};
let block_title = format!(" ← {} — {} ", title, status_str);
let visible_rows = area.height.saturating_sub(2) as usize;
let max_scroll = lines.len().saturating_sub(visible_rows);
let scroll = scroll.min(max_scroll);
let rendered_lines: Vec<Line> = lines
.iter()
.skip(scroll)
.take(visible_rows)
.map(|s| Line::from(Span::raw(s.as_str())))
.collect();
let block = Block::default()
.borders(Borders::ALL)
.title(Span::styled(block_title, status_style.add_modifier(Modifier::BOLD)))
.style(Style::default().fg(fg).bg(bg));
frame.render_widget(
Paragraph::new(rendered_lines)
.block(block)
.wrap(Wrap { trim: false })
.style(Style::default().fg(fg).bg(bg)),
area,
);
if lines.len() > visible_rows {
let hint = format!(
" lines {}-{}/{} ",
scroll + 1,
(scroll + visible_rows).min(lines.len()),
lines.len()
);
let hw = hint.len() as u16;
if area.width > hw + 2 {
let hint_rect = Rect {
x: area.x + area.width - hw - 1,
y: area.y + area.height - 1,
width: hw,
height: 1,
};
frame.render_widget(
Paragraph::new(Span::styled(hint, Style::default().fg(dim).bg(bg))),
hint_rect,
);
}
}
}
pub fn selected_task_id(
&self,
task_registry: &crate::task_registry::TaskRegistry,
) -> Option<TaskId> {
let q = self.filter.text().to_lowercase();
let mut entries: Vec<&crate::task_registry::Task> = task_registry
.all_tasks()
.filter(|t| {
if q.is_empty() { return true; }
t.command.to_lowercase().contains(&q)
|| t.key.queue.0.to_lowercase().contains(&q)
|| t.key.target.to_lowercase().contains(&q)
})
.collect();
entries.sort_by(|a, b| {
let a_live = matches!(a.status, TaskStatus::Running | TaskStatus::Pending);
let b_live = matches!(b.status, TaskStatus::Running | TaskStatus::Pending);
if a_live != b_live { return b_live.cmp(&a_live); }
b.created_at.cmp(&a.created_at)
});
let idx = if entries.is_empty() { return None; } else { self.cursor.min(entries.len() - 1) };
entries.get(idx).map(|t| t.id)
}
}
fn task_indicator<'a>(task: &crate::task_registry::Task, theme: &crate::theme::Theme) -> (&'a str, Style) {
match task.status {
TaskStatus::Running => ("●", Style::default().fg(theme.fg())),
TaskStatus::Pending => ("○", Style::default().fg(theme.fg_dim())),
TaskStatus::Success => ("✓", Style::default().fg(theme.level_success())),
TaskStatus::Warning => ("⚠", Style::default().fg(theme.level_warn())),
TaskStatus::Error => ("✗", Style::default().fg(theme.level_error())),
TaskStatus::Cancelled => ("—", Style::default().fg(theme.fg_dim())),
}
}
fn task_status_label(status: &TaskStatus) -> &'static str {
match status {
TaskStatus::Running => "Running",
TaskStatus::Pending => "Pending",
TaskStatus::Success => "Success",
TaskStatus::Warning => "Warning",
TaskStatus::Error => "Error",
TaskStatus::Cancelled => "Cancelled",
}
}
fn task_duration(task: &crate::task_registry::Task) -> String {
let start = match task.started_at { Some(s) => s, None => return "--".into() };
let end = task.finished_at.unwrap_or_else(std::time::Instant::now);
format_duration(end.duration_since(start))
}
fn format_duration(d: Duration) -> String {
let s = d.as_secs();
if s < 60 { format!("{:02}s", s) } else { format!("{:02}m", s / 60) }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::input::{KeyEvent, Key, Modifiers};
#[test]
fn enter_on_list_returns_open_selected() {
let mut view = TaskArchiveView::new();
view.focus.set_focus(FOCUS_LIST);
let key = KeyEvent { modifiers: Modifiers::empty(), key: Key::Enter };
let ops = view.handle_key(key);
assert_eq!(ops.len(), 1);
match &ops[0] {
Operation::TaskArchiveLocal(TaskArchiveOp::OpenSelected) => {}
other => panic!("expected OpenSelected, got {:?}", other),
}
}
#[test]
fn selected_task_id_returns_first_running_task() {
let mut reg = crate::task_registry::TaskRegistry::new();
let k = crate::task_registry::TaskKey { queue: crate::task_registry::TaskQueueId("build".into()), target: "x".into() };
let id = reg.schedule_task(k, crate::task_registry::TaskTrigger::Manual, "cmd".into());
let view = TaskArchiveView::new();
assert_eq!(view.selected_task_id(®), Some(id));
}
}