diff options
| author | soryu <soryu@soryu.co> | 2026-01-19 13:47:32 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-01-19 13:47:32 +0000 |
| commit | 0833fb1f30c0c3b920157deb882e0e902c3af02a (patch) | |
| tree | 45110fb8cb9277dfbaccfeb53ed9c1f76975022b /makima/src/daemon/tui/ui.rs | |
| parent | 786510379bed060db2b3742b7dfca671552d2c34 (diff) | |
| download | soryu-0833fb1f30c0c3b920157deb882e0e902c3af02a.tar.gz soryu-0833fb1f30c0c3b920157deb882e0e902c3af02a.zip | |
Add interactive TUI browser for tasks, contracts, and files (makima view) (#7)
* feat(tui): Implement fuzzy search with real-time filtering and highlighting
Adds comprehensive fuzzy search functionality to the TUI browser:
## Fuzzy Matching (fuzzy.rs)
- FuzzyMatcher wrapper using SkimMatcherV2 from fuzzy-matcher crate
- fuzzy_match() returns score and matched character indices
- fuzzy_match_all() supports multi-term search (space-separated)
- Recency-aware scoring to boost recent items in results
- Unit tests for all matching scenarios
## App State (app.rs)
- FilteredItem struct with index, score, and matched_indices
- apply_filter() uses fuzzy matching with score-based sorting
- match_count() and has_no_matches() helper methods
- Results sorted by match score (highest first)
## List View (list_view.rs)
- Highlighted matched characters in search results
- Yellow bold styling for matched chars
- Status icons with color coding
## Search Input (search_input.rs)
- Real-time match count display (X/Y matches)
- Visual feedback for no matches (red border)
- Placeholder text when search is empty
- Active search mode indication (yellow border)
## Event Handling (event.rs)
- Arrow key navigation while in search mode
- Ctrl+K/J for vim-style navigation during search
- Delete key support alongside backspace
- Ctrl+U to clear search query
- Tab toggles preview while searching
- Escape clears search and exits search mode
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Task completion checkpoint
* [WIP] Heartbeat checkpoint - 2026-01-19 11:20:34 UTC
* Task completion checkpoint
* [WIP] Heartbeat checkpoint - 2026-01-19 11:31:19 UTC
* Task completion checkpoint
* [WIP] Heartbeat checkpoint - 2026-01-19 11:39:07 UTC
* fix(tui): Fix module exports and main binary integration
- Update mod.rs to properly export app, event, fuzzy, and ui modules
- Add run() function for TUI entry point
- Fix run_view() to use ViewCommand enum instead of ViewArgs
- Fix event handling to use poll_event and handle_key_event
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
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); +} |
