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/widgets | |
| 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/widgets')
| -rw-r--r-- | makima/src/daemon/tui/widgets/list_view.rs | 127 | ||||
| -rw-r--r-- | makima/src/daemon/tui/widgets/mod.rs | 4 | ||||
| -rw-r--r-- | makima/src/daemon/tui/widgets/preview_pane.rs | 21 | ||||
| -rw-r--r-- | makima/src/daemon/tui/widgets/search_input.rs | 82 | ||||
| -rw-r--r-- | makima/src/daemon/tui/widgets/status_bar.rs | 19 |
5 files changed, 253 insertions, 0 deletions
diff --git a/makima/src/daemon/tui/widgets/list_view.rs b/makima/src/daemon/tui/widgets/list_view.rs new file mode 100644 index 0000000..ff8269a --- /dev/null +++ b/makima/src/daemon/tui/widgets/list_view.rs @@ -0,0 +1,127 @@ +//! List view widget with fuzzy match highlighting. + +use std::collections::HashSet; + +use ratatui::{ + prelude::*, + widgets::{Block, Borders, List, ListItem, ListState}, +}; + +use crate::daemon::tui::app::{App, ViewMode}; + +/// Style for matched characters in search results +const MATCH_HIGHLIGHT_COLOR: Color = Color::Yellow; +const MATCH_HIGHLIGHT_MODIFIER: Modifier = Modifier::BOLD; + +/// Build a Line with highlighted characters based on matched indices +fn build_highlighted_name(name: &str, matched_indices: &[usize]) -> Vec<Span<'static>> { + if matched_indices.is_empty() { + return vec![Span::raw(name.to_string())]; + } + + let matched_set: HashSet<usize> = matched_indices.iter().cloned().collect(); + let mut spans = Vec::new(); + let mut current_run = String::new(); + let mut is_highlighted = false; + + for (byte_idx, ch) in name.char_indices() { + let should_highlight = matched_set.contains(&byte_idx); + + if should_highlight != is_highlighted { + // Flush current run + if !current_run.is_empty() { + if is_highlighted { + spans.push(Span::styled( + current_run.clone(), + Style::default() + .fg(MATCH_HIGHLIGHT_COLOR) + .add_modifier(MATCH_HIGHLIGHT_MODIFIER), + )); + } else { + spans.push(Span::raw(current_run.clone())); + } + current_run.clear(); + } + is_highlighted = should_highlight; + } + + current_run.push(ch); + } + + // Flush remaining + if !current_run.is_empty() { + if is_highlighted { + spans.push(Span::styled( + current_run, + Style::default() + .fg(MATCH_HIGHLIGHT_COLOR) + .add_modifier(MATCH_HIGHLIGHT_MODIFIER), + )); + } else { + spans.push(Span::raw(current_run)); + } + } + + spans +} + +/// Get status icon and color for an item +fn get_status_display(status: Option<&str>) -> (&'static str, Color) { + match status { + Some("running") => ("▸", Color::Green), + Some("done") => ("✓", Color::Blue), + Some("failed") => ("✗", Color::Red), + Some("pending") => ("○", Color::Yellow), + Some("paused") => ("⏸", Color::Cyan), + _ => (" ", Color::Gray), + } +} + +pub fn render(f: &mut Frame, area: Rect, app: &mut App) { + let items: Vec<ListItem> = app + .filtered_items + .iter() + .map(|filtered_item| { + let item = &app.items[filtered_item.index]; + let (status_icon, status_color) = get_status_display(item.status.as_deref()); + + // Build spans with highlighted matched characters + let mut spans = vec![Span::styled( + format!("{} ", status_icon), + Style::default().fg(status_color), + )]; + + // Add name with match highlighting + spans.extend(build_highlighted_name(&item.name, &filtered_item.matched_indices)); + + ListItem::new(Line::from(spans)) + }) + .collect(); + + let view_label = match app.view_mode { + ViewMode::Tasks => "Tasks", + ViewMode::Contracts => "Contracts", + ViewMode::Files => "Files", + }; + + let title = format!( + " {} ({}{}) ", + view_label, + app.filtered_items.len(), + if app.filtered_items.len() != app.items.len() { + format!("/{}", app.items.len()) + } else { + String::new() + } + ); + + let list = List::new(items) + .block(Block::default().title(title).borders(Borders::ALL)) + .highlight_style(Style::default().add_modifier(Modifier::REVERSED)) + .highlight_symbol("> "); + + let mut state = ListState::default(); + state.select(Some(app.selected_index)); + + f.render_stateful_widget(list, area, &mut state); +} diff --git a/makima/src/daemon/tui/widgets/mod.rs b/makima/src/daemon/tui/widgets/mod.rs new file mode 100644 index 0000000..ddea546 --- /dev/null +++ b/makima/src/daemon/tui/widgets/mod.rs @@ -0,0 +1,4 @@ +pub mod list_view; +pub mod preview_pane; +pub mod search_input; +pub mod status_bar; diff --git a/makima/src/daemon/tui/widgets/preview_pane.rs b/makima/src/daemon/tui/widgets/preview_pane.rs new file mode 100644 index 0000000..84095d0 --- /dev/null +++ b/makima/src/daemon/tui/widgets/preview_pane.rs @@ -0,0 +1,21 @@ +//! Preview pane widget. + +use ratatui::{ + prelude::*, + widgets::{Block, Borders, Paragraph, Wrap}, +}; + +use crate::daemon::tui::app::App; + +pub fn render(f: &mut Frame, area: Rect, app: &App) { + let content = app + .preview_content + .as_deref() + .unwrap_or("No preview available"); + + let preview = Paragraph::new(content) + .block(Block::default().title(" Preview ").borders(Borders::ALL)) + .wrap(Wrap { trim: true }); + + f.render_widget(preview, area); +} diff --git a/makima/src/daemon/tui/widgets/search_input.rs b/makima/src/daemon/tui/widgets/search_input.rs new file mode 100644 index 0000000..311b4f0 --- /dev/null +++ b/makima/src/daemon/tui/widgets/search_input.rs @@ -0,0 +1,82 @@ +//! Search input widget with match count and visual feedback. + +use ratatui::{ + prelude::*, + widgets::{Block, Borders, Paragraph}, +}; + +use crate::daemon::tui::app::{App, InputMode, ViewMode}; + +/// Color for the search bar when there are no matches +const NO_MATCH_COLOR: Color = Color::Red; +/// Color for the search bar when actively searching +const SEARCH_ACTIVE_COLOR: Color = Color::Yellow; + +pub fn render(f: &mut Frame, area: Rect, app: &App) { + let view_label = match app.view_mode { + ViewMode::Tasks => "Tasks", + ViewMode::Contracts => "Contracts", + ViewMode::Files => "Files", + }; + + let (matched, total) = app.match_count(); + let has_no_matches = app.has_no_matches(); + let is_searching = matches!(app.input_mode, InputMode::Search); + let has_query = !app.search_query.is_empty(); + + // Determine border style based on state + let border_style = if has_no_matches { + Style::default().fg(NO_MATCH_COLOR) + } else if is_searching { + Style::default().fg(SEARCH_ACTIVE_COLOR) + } else { + Style::default() + }; + + // Build the search input content + let search_text = if app.search_query.is_empty() { + if is_searching { + " Type to search...".to_string() + } else { + " Press / to search".to_string() + } + } else { + format!(" {}", app.search_query) + }; + + // Build the title with match count + let title = if has_query { + if has_no_matches { + format!(" 🔍 Search [{}] - No matches ", view_label) + } else { + format!(" 🔍 Search [{}] - {}/{} matches ", view_label, matched, total) + } + } else { + format!(" 🔍 Search [{}] ", view_label) + }; + + // Create input text with appropriate style + let text_style = if app.search_query.is_empty() && !is_searching { + Style::default().fg(Color::DarkGray) + } else if has_no_matches { + Style::default().fg(NO_MATCH_COLOR) + } else { + Style::default() + }; + + let input = Paragraph::new(Span::styled(search_text, text_style)).block( + Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(border_style), + ); + + f.render_widget(input, area); + + // Show cursor in search mode + if is_searching { + // Calculate cursor position based on actual search query length + let cursor_x = area.x + app.search_query.len() as u16 + 2; + f.set_cursor_position(Position::new(cursor_x, area.y + 1)); + } +} diff --git a/makima/src/daemon/tui/widgets/status_bar.rs b/makima/src/daemon/tui/widgets/status_bar.rs new file mode 100644 index 0000000..3357c58 --- /dev/null +++ b/makima/src/daemon/tui/widgets/status_bar.rs @@ -0,0 +1,19 @@ +//! Status bar widget. + +use ratatui::{prelude::*, widgets::Paragraph}; + +use crate::daemon::tui::app::{App, InputMode}; + +pub fn render(f: &mut Frame, area: Rect, app: &App) { + let keybindings = match app.input_mode { + InputMode::Normal => { + "↑↓:Navigate Enter:View e:Edit d:Delete Tab:Preview /:Search q:Quit" + } + InputMode::Search => "Type to search Enter:Select Esc:Cancel", + InputMode::Confirm => "y:Confirm n:Cancel", + }; + + let status = Paragraph::new(keybindings).style(Style::default().bg(Color::DarkGray)); + + f.render_widget(status, area); +} |
