diff options
Diffstat (limited to 'makima/src/daemon/tui/ui.rs')
| -rw-r--r-- | makima/src/daemon/tui/ui.rs | 209 |
1 files changed, 209 insertions, 0 deletions
diff --git a/makima/src/daemon/tui/ui.rs b/makima/src/daemon/tui/ui.rs new file mode 100644 index 0000000..4003344 --- /dev/null +++ b/makima/src/daemon/tui/ui.rs @@ -0,0 +1,209 @@ +//! TUI rendering. + +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span, Text}, + widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap}, + Frame, +}; + +use super::app::{App, InputMode, ViewType}; +use super::event::get_help_text; + +/// Main render function +pub fn render(frame: &mut Frame, app: &App) { + // Create main layout + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Header + Constraint::Min(10), // Main content + Constraint::Length(3), // Status/Help + ]) + .split(frame.area()); + + render_header(frame, app, chunks[0]); + render_main_content(frame, app, chunks[1]); + render_footer(frame, app, chunks[2]); + + // Render confirmation dialog if in confirm mode + if app.input_mode == InputMode::Confirm { + render_confirm_dialog(frame, app); + } +} + +/// Render header with title and search bar +fn render_header(frame: &mut Frame, app: &App, area: Rect) { + let title = match app.view_type { + ViewType::Tasks => "Tasks", + ViewType::Contracts => "Contracts", + ViewType::Files => "Files", + }; + + let header_text = if app.input_mode == InputMode::Search || !app.search_query.is_empty() { + format!("{} [Search: {}]", title, app.search_query) + } else { + format!("{} ({} items)", title, app.filtered_items.len()) + }; + + let header = Paragraph::new(header_text) + .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)) + .block(Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(if app.input_mode == InputMode::Search { + Color::Yellow + } else { + Color::White + }))); + + frame.render_widget(header, area); +} + +/// Render main content (list + optional preview) +fn render_main_content(frame: &mut Frame, app: &App, area: Rect) { + if app.preview_visible && !app.preview_content.is_empty() { + // Split horizontally: list on left, preview on right + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(50), + Constraint::Percentage(50), + ]) + .split(area); + + render_list(frame, app, chunks[0]); + render_preview(frame, app, chunks[1]); + } else { + render_list(frame, app, area); + } +} + +/// Render the item list +fn render_list(frame: &mut Frame, app: &App, area: Rect) { + let items: Vec<ListItem> = app.filtered_items + .iter() + .enumerate() + .map(|(i, item)| { + let is_selected = i == app.selected_index; + + // Build the display line + let status_str = item.status + .as_ref() + .map(|s| format!(" [{}]", s)) + .unwrap_or_default(); + + let content = format!("{}{}", item.name, status_str); + + let style = if is_selected { + Style::default() + .fg(Color::Black) + .bg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + let status_color = item.status.as_ref().map(|s| { + match s.to_lowercase().as_str() { + "running" | "active" => Color::Green, + "pending" | "waiting" => Color::Yellow, + "completed" | "done" => Color::Blue, + "failed" | "error" => Color::Red, + _ => Color::White, + } + }).unwrap_or(Color::White); + + Style::default().fg(status_color) + }; + + ListItem::new(Line::from(vec![ + Span::styled(content, style), + ])) + }) + .collect(); + + let list = List::new(items) + .block(Block::default() + .borders(Borders::ALL) + .title(format!(" {} ", app.view_type.as_str()))); + + frame.render_widget(list, area); +} + +/// Render the preview panel +fn render_preview(frame: &mut Frame, app: &App, area: Rect) { + let preview = Paragraph::new(Text::raw(&app.preview_content)) + .wrap(Wrap { trim: false }) + .block(Block::default() + .borders(Borders::ALL) + .title(" Preview ")); + + frame.render_widget(preview, area); +} + +/// Render footer with help text and status +fn render_footer(frame: &mut Frame, app: &App, area: Rect) { + let help_text = get_help_text(app.input_mode); + + let status_text = app.status_message + .as_ref() + .map(|s| format!(" | {}", s)) + .unwrap_or_default(); + + let footer_text = format!("{}{}", help_text, status_text); + + let footer = Paragraph::new(footer_text) + .style(Style::default().fg(Color::DarkGray)) + .block(Block::default().borders(Borders::ALL)); + + frame.render_widget(footer, area); +} + +/// Render confirmation dialog as a centered popup +fn render_confirm_dialog(frame: &mut Frame, app: &App) { + let item_name = app.get_pending_delete_name() + .unwrap_or_else(|| "this item".to_string()); + + // Calculate popup size and position + let area = frame.area(); + let popup_width = 50.min(area.width.saturating_sub(4)); + let popup_height = 7; + + let popup_x = (area.width.saturating_sub(popup_width)) / 2; + let popup_y = (area.height.saturating_sub(popup_height)) / 2; + + let popup_area = Rect { + x: popup_x, + y: popup_y, + width: popup_width, + height: popup_height, + }; + + // Clear the area behind the popup + frame.render_widget(Clear, popup_area); + + // Build popup content + let text = vec![ + Line::from(""), + Line::from(Span::styled( + "Delete Confirmation", + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from(format!("Delete '{}'?", item_name)), + Line::from(""), + Line::from(vec![ + Span::styled("y", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)), + Span::raw(": confirm "), + Span::styled("n", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)), + Span::raw(": cancel"), + ]), + ]; + + let popup = Paragraph::new(text) + .alignment(Alignment::Center) + .block(Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Red)) + .title(" Confirm ")); + + frame.render_widget(popup, popup_area); +} |
