summaryrefslogtreecommitdiff
path: root/makima/src/daemon/tui/ui.rs
diff options
context:
space:
mode:
Diffstat (limited to 'makima/src/daemon/tui/ui.rs')
-rw-r--r--makima/src/daemon/tui/ui.rs209
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);
+}