diff options
Diffstat (limited to 'makima/src/daemon/tui/widgets/list_view.rs')
| -rw-r--r-- | makima/src/daemon/tui/widgets/list_view.rs | 127 |
1 files changed, 127 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); +} |
