Skip to main content

chronicle/show/
tui.rs

1use crate::error::Result;
2
3use super::data::ShowData;
4
5/// Launch the interactive TUI.
6pub fn run_tui(data: ShowData) -> Result<()> {
7    use crossterm::{
8        event::{self, Event, KeyCode, KeyModifiers},
9        execute,
10        terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
11    };
12    use ratatui::prelude::*;
13
14    let mut terminal = {
15        enable_raw_mode().map_err(io_err)?;
16        let mut stdout = std::io::stdout();
17        execute!(stdout, EnterAlternateScreen).map_err(io_err)?;
18        let backend = CrosstermBackend::new(stdout);
19        Terminal::new(backend).map_err(io_err)?
20    };
21
22    let mut app = AppState::new(data);
23
24    loop {
25        terminal.draw(|f| views::render(f, &app)).map_err(io_err)?;
26
27        if let Event::Key(key) = event::read().map_err(io_err)? {
28            match key.code {
29                KeyCode::Char('q') => break,
30                KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => break,
31                _ => super::keymap::handle_key(&mut app, key),
32            }
33        }
34    }
35
36    // Restore terminal
37    disable_raw_mode().map_err(io_err)?;
38    execute!(std::io::stdout(), LeaveAlternateScreen).map_err(io_err)?;
39
40    Ok(())
41}
42
43fn io_err(e: std::io::Error) -> crate::error::ChronicleError {
44    crate::error::ChronicleError::Io {
45        source: e,
46        location: snafu::Location::default(),
47    }
48}
49
50/// TUI application state.
51pub struct AppState {
52    pub data: ShowData,
53    pub scroll_offset: usize,
54    pub selected_region: Option<usize>,
55    pub panel_expanded: bool,
56    pub panel_scroll: usize,
57    pub show_help: bool,
58}
59
60impl AppState {
61    pub fn new(data: ShowData) -> Self {
62        let initial_region = if data.regions.is_empty() {
63            None
64        } else {
65            Some(0)
66        };
67        Self {
68            data,
69            scroll_offset: 0,
70            selected_region: initial_region,
71            panel_expanded: true,
72            panel_scroll: 0,
73            show_help: false,
74        }
75    }
76
77    pub fn total_lines(&self) -> usize {
78        self.data.source_lines.len()
79    }
80
81    /// Jump to the next annotated region.
82    pub fn next_region(&mut self) {
83        if self.data.regions.is_empty() {
84            return;
85        }
86        let next = match self.selected_region {
87            Some(i) if i + 1 < self.data.regions.len() => i + 1,
88            _ => 0,
89        };
90        self.selected_region = Some(next);
91        self.panel_scroll = 0;
92        // Scroll to make the region visible
93        let line = self.data.regions[next].region.lines.start as usize;
94        if line > 0 {
95            self.scroll_offset = line.saturating_sub(3);
96        }
97    }
98
99    /// Jump to the previous annotated region.
100    pub fn prev_region(&mut self) {
101        if self.data.regions.is_empty() {
102            return;
103        }
104        let prev = match self.selected_region {
105            Some(0) | None => self.data.regions.len() - 1,
106            Some(i) => i - 1,
107        };
108        self.selected_region = Some(prev);
109        self.panel_scroll = 0;
110        let line = self.data.regions[prev].region.lines.start as usize;
111        if line > 0 {
112            self.scroll_offset = line.saturating_sub(3);
113        }
114    }
115}
116
117mod views {
118    use super::AppState;
119    use ratatui::prelude::*;
120    use ratatui::widgets::*;
121
122    pub fn render(f: &mut Frame, app: &AppState) {
123        let chunks = Layout::default()
124            .direction(Direction::Vertical)
125            .constraints([
126                Constraint::Length(1), // header
127                Constraint::Min(1),    // main
128                Constraint::Length(1), // status
129            ])
130            .split(f.area());
131
132        render_header(f, app, chunks[0]);
133        render_main(f, app, chunks[1]);
134        render_status(f, app, chunks[2]);
135
136        if app.show_help {
137            render_help(f, f.area());
138        }
139    }
140
141    fn render_header(f: &mut Frame, app: &AppState, area: Rect) {
142        let commit_short = &app.data.commit[..7.min(app.data.commit.len())];
143        let region_count = app.data.regions.len();
144        let text = format!(
145            " {} @ {} [{region_count} region{}]  [q]uit [n/N]ext/prev [Enter]expand [?]help",
146            app.data.file_path,
147            commit_short,
148            if region_count == 1 { "" } else { "s" },
149        );
150        let header =
151            Paragraph::new(text).style(Style::default().bg(Color::DarkGray).fg(Color::White));
152        f.render_widget(header, area);
153    }
154
155    fn render_main(f: &mut Frame, app: &AppState, area: Rect) {
156        if app.panel_expanded && app.selected_region.is_some() {
157            // Split: source left, annotation right
158            let panes = Layout::default()
159                .direction(Direction::Horizontal)
160                .constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
161                .split(area);
162
163            render_source(f, app, panes[0]);
164            render_annotation_panel(f, app, panes[1]);
165        } else {
166            render_source(f, app, area);
167        }
168    }
169
170    fn render_source(f: &mut Frame, app: &AppState, area: Rect) {
171        let visible_height = area.height as usize;
172        let line_count = app.data.source_lines.len();
173        let line_num_width = format!("{}", line_count).len();
174
175        let mut lines: Vec<Line> = Vec::new();
176
177        for i in app.scroll_offset..line_count.min(app.scroll_offset + visible_height) {
178            let line_num = i + 1;
179            let region_indices = app.data.annotation_map.regions_at_line(line_num as u32);
180
181            // Gutter indicator
182            let gutter = if !region_indices.is_empty() {
183                // Check if this region is selected
184                let is_selected = app
185                    .selected_region
186                    .is_some_and(|sel| region_indices.contains(&sel));
187                if is_selected {
188                    Span::styled("█ ", Style::default().fg(Color::Cyan))
189                } else {
190                    Span::styled("█ ", Style::default().fg(Color::DarkGray))
191                }
192            } else {
193                Span::raw("  ")
194            };
195
196            let num = Span::styled(
197                format!("{:>width$} ", line_num, width = line_num_width),
198                Style::default().fg(Color::DarkGray),
199            );
200
201            let source_text = app
202                .data
203                .source_lines
204                .get(i)
205                .map(|s| s.as_str())
206                .unwrap_or("");
207
208            let source_span = Span::raw(source_text);
209
210            lines.push(Line::from(vec![gutter, num, source_span]));
211        }
212
213        let source_widget = Paragraph::new(lines);
214        f.render_widget(source_widget, area);
215    }
216
217    fn render_annotation_panel(f: &mut Frame, app: &AppState, area: Rect) {
218        let region_idx = match app.selected_region {
219            Some(i) => i,
220            None => return,
221        };
222        let r = match app.data.regions.get(region_idx) {
223            Some(r) => r,
224            None => return,
225        };
226
227        let mut text_lines: Vec<Line> = Vec::new();
228
229        // Anchor header
230        text_lines.push(Line::from(vec![
231            Span::styled(
232                format!("{} ", r.region.ast_anchor.unit_type),
233                Style::default().fg(Color::Yellow),
234            ),
235            Span::styled(
236                r.region.ast_anchor.name.clone(),
237                Style::default()
238                    .fg(Color::Cyan)
239                    .add_modifier(Modifier::BOLD),
240            ),
241        ]));
242        text_lines.push(Line::from(format!(
243            "lines {}-{}",
244            r.region.lines.start, r.region.lines.end,
245        )));
246        text_lines.push(Line::raw(""));
247
248        // Intent
249        text_lines.push(Line::styled(
250            "Intent",
251            Style::default().add_modifier(Modifier::BOLD),
252        ));
253        for wrapped in wrap_text(&r.region.intent, area.width.saturating_sub(2) as usize) {
254            text_lines.push(Line::raw(format!("  {wrapped}")));
255        }
256        text_lines.push(Line::raw(""));
257
258        // Reasoning
259        if let Some(ref reasoning) = r.region.reasoning {
260            text_lines.push(Line::styled(
261                "Reasoning",
262                Style::default().add_modifier(Modifier::BOLD),
263            ));
264            for wrapped in wrap_text(reasoning, area.width.saturating_sub(2) as usize) {
265                text_lines.push(Line::raw(format!("  {wrapped}")));
266            }
267            text_lines.push(Line::raw(""));
268        }
269
270        // Constraints
271        if !r.region.constraints.is_empty() {
272            text_lines.push(Line::styled(
273                "Constraints",
274                Style::default().add_modifier(Modifier::BOLD),
275            ));
276            for c in &r.region.constraints {
277                let source = match c.source {
278                    crate::schema::annotation::ConstraintSource::Author => "author",
279                    crate::schema::annotation::ConstraintSource::Inferred => "inferred",
280                };
281                text_lines.push(Line::raw(format!("  - {} [{source}]", c.text)));
282            }
283            text_lines.push(Line::raw(""));
284        }
285
286        // Dependencies
287        if !r.region.semantic_dependencies.is_empty() {
288            text_lines.push(Line::styled(
289                "Dependencies",
290                Style::default().add_modifier(Modifier::BOLD),
291            ));
292            for d in &r.region.semantic_dependencies {
293                text_lines.push(Line::raw(format!("  -> {} :: {}", d.file, d.anchor)));
294            }
295            text_lines.push(Line::raw(""));
296        }
297
298        // Risk notes
299        if let Some(ref risk) = r.region.risk_notes {
300            text_lines.push(Line::styled(
301                "Risk",
302                Style::default().add_modifier(Modifier::BOLD).fg(Color::Red),
303            ));
304            for wrapped in wrap_text(risk, area.width.saturating_sub(2) as usize) {
305                text_lines.push(Line::raw(format!("  {wrapped}")));
306            }
307            text_lines.push(Line::raw(""));
308        }
309
310        // Corrections
311        if !r.region.corrections.is_empty() {
312            text_lines.push(Line::styled(
313                format!("Corrections ({})", r.region.corrections.len()),
314                Style::default()
315                    .add_modifier(Modifier::BOLD)
316                    .fg(Color::Yellow),
317            ));
318            text_lines.push(Line::raw(""));
319        }
320
321        // Metadata
322        text_lines.push(Line::styled(
323            "Metadata",
324            Style::default().fg(Color::DarkGray),
325        ));
326        text_lines.push(Line::raw(format!(
327            "  commit: {}",
328            &r.commit[..7.min(r.commit.len())]
329        )));
330        text_lines.push(Line::raw(format!("  time: {}", r.timestamp)));
331        if !r.region.tags.is_empty() {
332            text_lines.push(Line::raw(format!("  tags: {}", r.region.tags.join(", "))));
333        }
334
335        // Apply scroll offset
336        let scrolled: Vec<Line> = text_lines.into_iter().skip(app.panel_scroll).collect();
337
338        let panel = Paragraph::new(scrolled).block(
339            Block::default()
340                .borders(Borders::LEFT)
341                .border_style(Style::default().fg(Color::DarkGray)),
342        );
343
344        f.render_widget(panel, area);
345    }
346
347    fn render_status(f: &mut Frame, app: &AppState, area: Rect) {
348        let status = if let Some(idx) = app.selected_region {
349            let r = &app.data.regions[idx];
350            format!(
351                " region {}/{} │ {} │ lines {}-{} │ {} deps",
352                idx + 1,
353                app.data.regions.len(),
354                r.region.ast_anchor.name,
355                r.region.lines.start,
356                r.region.lines.end,
357                r.region.semantic_dependencies.len(),
358            )
359        } else {
360            format!(
361                " {} lines │ {} regions │ scroll: {}",
362                app.data.source_lines.len(),
363                app.data.regions.len(),
364                app.scroll_offset + 1,
365            )
366        };
367
368        let status_bar =
369            Paragraph::new(status).style(Style::default().bg(Color::DarkGray).fg(Color::White));
370        f.render_widget(status_bar, area);
371    }
372
373    fn render_help(f: &mut Frame, area: Rect) {
374        let help_text = vec![
375            Line::styled(
376                "Keyboard Shortcuts",
377                Style::default().add_modifier(Modifier::BOLD),
378            ),
379            Line::raw(""),
380            Line::raw("  j/↓         Scroll down"),
381            Line::raw("  k/↑         Scroll up"),
382            Line::raw("  Ctrl-d/PgDn Half page down"),
383            Line::raw("  Ctrl-u/PgUp Half page up"),
384            Line::raw("  g/Home      Jump to top"),
385            Line::raw("  G/End       Jump to bottom"),
386            Line::raw("  n           Next annotated region"),
387            Line::raw("  N           Previous annotated region"),
388            Line::raw("  Enter       Toggle annotation panel"),
389            Line::raw("  J/K         Scroll annotation panel"),
390            Line::raw("  q           Quit"),
391            Line::raw("  ?           Toggle this help"),
392        ];
393
394        let block = Block::default()
395            .title(" Help ")
396            .borders(Borders::ALL)
397            .border_style(Style::default().fg(Color::Cyan));
398
399        let help_width = 50.min(area.width);
400        let help_height = 16.min(area.height);
401        let x = area.x + (area.width.saturating_sub(help_width)) / 2;
402        let y = area.y + (area.height.saturating_sub(help_height)) / 2;
403        let help_area = Rect::new(x, y, help_width, help_height);
404
405        // Clear background
406        f.render_widget(Clear, help_area);
407
408        let help = Paragraph::new(help_text).block(block);
409        f.render_widget(help, help_area);
410    }
411
412    /// Simple word-wrapping for annotation text.
413    fn wrap_text(text: &str, width: usize) -> Vec<String> {
414        if width == 0 {
415            return vec![text.to_string()];
416        }
417        let mut lines = Vec::new();
418        let mut current = String::new();
419        for word in text.split_whitespace() {
420            if current.len() + word.len() + 1 > width && !current.is_empty() {
421                lines.push(current);
422                current = word.to_string();
423            } else {
424                if !current.is_empty() {
425                    current.push(' ');
426                }
427                current.push_str(word);
428            }
429        }
430        if !current.is_empty() {
431            lines.push(current);
432        }
433        if lines.is_empty() {
434            lines.push(String::new());
435        }
436        lines
437    }
438}