summaryrefslogtreecommitdiff
path: root/makima/src/daemon/tui/widgets
diff options
context:
space:
mode:
Diffstat (limited to 'makima/src/daemon/tui/widgets')
-rw-r--r--makima/src/daemon/tui/widgets/list_view.rs127
-rw-r--r--makima/src/daemon/tui/widgets/mod.rs4
-rw-r--r--makima/src/daemon/tui/widgets/preview_pane.rs21
-rw-r--r--makima/src/daemon/tui/widgets/search_input.rs82
-rw-r--r--makima/src/daemon/tui/widgets/status_bar.rs19
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);
+}