Skip to main content

ck_tui/
rendering.rs

1use crate::colors::*;
2use crate::state::TuiState;
3use crate::utils::score_to_color;
4use ck_core::SearchMode;
5use ratatui::Frame;
6use ratatui::layout::Rect;
7use ratatui::style::{Modifier, Style};
8use ratatui::text::{Line, Span};
9use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
10
11pub fn draw_query_input(f: &mut Frame, area: Rect, state: &TuiState) {
12    let (title, style) = if state.command_mode {
13        // In command mode
14        (
15            "Command (Enter to execute, /help for help)".to_string(),
16            Style::default().fg(COLOR_CYAN).add_modifier(Modifier::BOLD),
17        )
18    } else {
19        // In search mode
20        let mode_indicator = match state.mode {
21            SearchMode::Semantic => "[SEM]",
22            SearchMode::Regex => "[REG]",
23            SearchMode::Hybrid => "[HYB]",
24            SearchMode::Lexical => "[LEX]",
25        };
26        (
27            format!(
28                "Search {} (Tab to cycle, /help for commands)",
29                mode_indicator
30            ),
31            Style::default().fg(COLOR_YELLOW),
32        )
33    };
34
35    let input = Paragraph::new(state.query.as_str())
36        .style(style)
37        .block(Block::default().borders(Borders::ALL).title(title));
38    f.render_widget(input, area);
39}
40
41pub fn draw_results_list(f: &mut Frame, area: Rect, state: &TuiState, list_state: &mut ListState) {
42    let items: Vec<ListItem> = state
43        .results
44        .iter()
45        .enumerate()
46        .map(|(idx, result)| {
47            let score_color = score_to_color(result.score);
48            let is_selected = state.selected_files.contains(&result.file);
49            let prefix = if is_selected { "✓ " } else { "  " };
50            let content = format!(
51                "{}[{:.3}] {}:{}",
52                prefix,
53                result.score,
54                result.file.display(),
55                result.span.line_start
56            );
57            let style = if idx == state.selected_idx {
58                Style::default()
59                    .fg(COLOR_BLACK)
60                    .bg(score_color)
61                    .add_modifier(Modifier::BOLD)
62            } else if is_selected {
63                Style::default()
64                    .fg(score_color)
65                    .add_modifier(Modifier::BOLD)
66            } else {
67                Style::default().fg(score_color)
68            };
69            ListItem::new(content).style(style)
70        })
71        .collect();
72
73    let title = format!("Results ({}/{})", state.results.len(), state.results.len());
74    let list = List::new(items)
75        .block(Block::default().borders(Borders::ALL).title(title))
76        .highlight_style(Style::default().add_modifier(Modifier::BOLD));
77
78    f.render_stateful_widget(list, area, list_state);
79}
80
81pub fn draw_preview(f: &mut Frame, area: Rect, state: &TuiState) {
82    // Determine title based on preview mode and context mode
83    let view_mode = if state.full_file_mode {
84        "Full File"
85    } else {
86        "Snippet"
87    };
88    let title = format!(
89        "{}: {:?} (^V: view | ^F: toggle | PgUp/Dn: scroll)",
90        view_mode, state.preview_mode
91    );
92
93    let preview = if !state.preview_lines.is_empty() {
94        Paragraph::new(state.preview_lines.clone())
95            .block(Block::default().borders(Borders::ALL).title(title.clone()))
96    } else {
97        // Fallback to plain text
98        let preview_text = if state.preview_content.is_empty() {
99            "No preview available"
100        } else {
101            &state.preview_content
102        };
103        Paragraph::new(preview_text)
104            .style(Style::default().fg(COLOR_WHITE))
105            .block(Block::default().borders(Borders::ALL).title(title))
106    };
107
108    f.render_widget(preview, area);
109}
110
111pub fn draw_status_bar(f: &mut Frame, area: Rect, state: &TuiState) {
112    let help_text = " ↑↓: Nav | Tab: Mode | ^V: View | ^Space: Select | Enter: Open | ^↑↓: History | Esc/q: Quit ";
113
114    let mut status_spans = vec![Span::styled(
115        state.status_message.clone(),
116        Style::default().fg(COLOR_CYAN),
117    )];
118
119    if state.indexing_active {
120        let spinner_idx = state
121            .indexing_started_at
122            .map(|start| ((start.elapsed().as_millis() / 120) as usize) % SPINNER_FRAMES.len())
123            .unwrap_or(0);
124        let spinner = SPINNER_FRAMES[spinner_idx];
125
126        status_spans.push(Span::raw(" | "));
127        status_spans.push(Span::styled(
128            format!("{} ", spinner),
129            Style::default().fg(COLOR_YELLOW),
130        ));
131
132        // Overall percentage in fixed width, appears before the detailed message
133        if let Some(progress) = state.indexing_progress {
134            let pct = (progress * 100.0).clamp(0.0, 100.0).round() as i32;
135            status_spans.push(Span::styled(
136                format!("[{:>3}%] ", pct),
137                Style::default()
138                    .fg(COLOR_GREEN)
139                    .add_modifier(Modifier::BOLD),
140            ));
141        }
142
143        // Parse the detailed message to colorize parts
144        if let Some(message) = state.indexing_message.as_ref() {
145            // Split on bullet points to colorize differently
146            let parts: Vec<&str> = message.split(" • ").collect();
147            for (i, part) in parts.iter().enumerate() {
148                if i > 0 {
149                    status_spans.push(Span::styled(" • ", Style::default().fg(COLOR_DARK_GRAY)));
150                }
151                let color = if i == 0 {
152                    COLOR_CYAN // Filename in cyan
153                } else {
154                    COLOR_GRAY // Counts in gray
155                };
156                status_spans.push(Span::styled(*part, Style::default().fg(color)));
157            }
158        } else {
159            status_spans.push(Span::styled("Indexing...", Style::default().fg(COLOR_CYAN)));
160        }
161    } else if let Some(message) = state.indexing_message.as_ref() {
162        status_spans.push(Span::raw(" | "));
163        status_spans.push(Span::styled(
164            message.clone(),
165            Style::default().fg(COLOR_GRAY),
166        ));
167    }
168
169    if !state.selected_files.is_empty() {
170        status_spans.push(Span::raw(" | "));
171        status_spans.push(Span::styled(
172            format!("{} selected", state.selected_files.len()),
173            Style::default().fg(COLOR_MAGENTA),
174        ));
175    }
176
177    let index_info = if let Some(stats) = state.index_stats.as_ref() {
178        format!(
179            "Index: {} files, {} chunks",
180            stats.total_files, stats.total_chunks
181        )
182    } else if let Some(err) = state.index_stats_error.as_ref() {
183        format!("Index error: {}", err)
184    } else {
185        "Index: --".to_string()
186    };
187    status_spans.push(Span::raw(" | "));
188    status_spans.push(Span::styled(index_info, Style::default().fg(COLOR_GRAY)));
189
190    status_spans.push(Span::raw(" | "));
191    status_spans.push(Span::styled(
192        help_text,
193        Style::default().fg(COLOR_DARK_GRAY),
194    ));
195
196    let status =
197        Paragraph::new(Line::from(status_spans)).block(Block::default().borders(Borders::ALL));
198    f.render_widget(status, area);
199}