Skip to main content

trueno_explain/tui/
mod.rs

1//! Interactive TUI mode for trueno-explain
2//!
3//! Implements Genchi Genbutsu (Go and See) through interactive exploration.
4//!
5//! Layout:
6//! - Left Pane: Source/PTX code with syntax highlighting
7//! - Right Pane: Analysis dashboard (registers, memory, warnings, bugs)
8//! - Bottom: Status bar with keybindings
9
10mod highlight;
11mod widgets;
12
13use crate::analyzer::AnalysisReport;
14use crate::ptx::{PtxBugAnalyzer, PtxBugReport};
15use crossterm::{
16    event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind},
17    execute,
18    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
19};
20use presentar_core::{Canvas, Color, Point, TextStyle};
21use presentar_terminal::direct::{CellBuffer, DiffRenderer, DirectTerminalCanvas};
22use presentar_terminal::ColorMode;
23use std::io;
24
25use highlight::highlight_ptx_line;
26use widgets::render_sidebar;
27
28/// TUI application state
29pub struct TuiApp {
30    /// The PTX source code to display
31    pub ptx_source: String,
32    /// Analysis report
33    pub report: AnalysisReport,
34    /// Bug hunting report (probar-style)
35    pub bug_report: PtxBugReport,
36    /// Current scroll position in source pane
37    pub source_scroll: u16,
38    /// Whether sidebar is visible
39    pub sidebar_visible: bool,
40    /// Should quit
41    pub should_quit: bool,
42    /// Total lines in source
43    source_lines: usize,
44}
45
46impl TuiApp {
47    /// Create a new TUI application
48    #[must_use]
49    pub fn new(ptx_source: String, report: AnalysisReport) -> Self {
50        let source_lines = ptx_source.lines().count();
51        // Perform PTX pattern analysis in strict mode for TUI
52        let bug_report = PtxBugAnalyzer::strict().analyze(&ptx_source);
53        Self {
54            ptx_source,
55            report,
56            bug_report,
57            source_scroll: 0,
58            sidebar_visible: true,
59            should_quit: false,
60            source_lines,
61        }
62    }
63
64    /// Handle keyboard input
65    pub fn handle_key(&mut self, key: KeyCode) {
66        match key {
67            KeyCode::Char('q') | KeyCode::Esc => self.should_quit = true,
68            KeyCode::Char('s') => self.sidebar_visible = !self.sidebar_visible,
69            KeyCode::Down | KeyCode::Char('j') => self.scroll_down(),
70            KeyCode::Up | KeyCode::Char('k') => self.scroll_up(),
71            KeyCode::PageDown => self.page_down(),
72            KeyCode::PageUp => self.page_up(),
73            KeyCode::Home => self.source_scroll = 0,
74            KeyCode::End => self.scroll_to_end(),
75            _ => {}
76        }
77    }
78
79    fn scroll_down(&mut self) {
80        if (self.source_scroll as usize) < self.source_lines.saturating_sub(1) {
81            self.source_scroll = self.source_scroll.saturating_add(1);
82        }
83    }
84
85    fn scroll_up(&mut self) {
86        self.source_scroll = self.source_scroll.saturating_sub(1);
87    }
88
89    fn page_down(&mut self) {
90        self.source_scroll = self
91            .source_scroll
92            .saturating_add(20)
93            .min(self.source_lines.saturating_sub(1) as u16);
94    }
95
96    fn page_up(&mut self) {
97        self.source_scroll = self.source_scroll.saturating_sub(20);
98    }
99
100    fn scroll_to_end(&mut self) {
101        self.source_scroll = self.source_lines.saturating_sub(1) as u16;
102    }
103}
104
105/// Run the TUI application
106///
107/// # Errors
108///
109/// Returns `io::Error` if terminal operations fail.
110pub fn run_tui(ptx_source: String, report: AnalysisReport) -> io::Result<()> {
111    // Setup terminal
112    enable_raw_mode()?;
113    let mut stdout = io::stdout();
114    execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
115    execute!(stdout, crossterm::cursor::Hide)?;
116
117    // Create app state
118    let mut app = TuiApp::new(ptx_source, report);
119
120    // Create rendering state
121    let (width, height) = crossterm::terminal::size()?;
122    let mut buffer = CellBuffer::new(width, height);
123    let mut renderer = DiffRenderer::with_color_mode(ColorMode::TrueColor);
124
125    // Main loop
126    let result = run_app(&mut app, &mut buffer, &mut renderer);
127
128    // Restore terminal
129    disable_raw_mode()?;
130    execute!(io::stdout(), crossterm::cursor::Show)?;
131    execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture)?;
132
133    result
134}
135
136fn run_app(
137    app: &mut TuiApp,
138    buffer: &mut CellBuffer,
139    renderer: &mut DiffRenderer,
140) -> io::Result<()> {
141    loop {
142        // Resize buffer if terminal size changed
143        let (width, height) = crossterm::terminal::size()?;
144        if buffer.width() != width || buffer.height() != height {
145            *buffer = CellBuffer::new(width, height);
146        }
147
148        // Render frame
149        {
150            let mut canvas = DirectTerminalCanvas::new(buffer);
151            ui(&mut canvas, app, width, height);
152        }
153
154        // Flush to terminal
155        let mut output = Vec::with_capacity(8192);
156        renderer
157            .flush(buffer, &mut output)
158            .map_err(|e| io::Error::other(e.to_string()))?;
159        io::Write::write_all(&mut io::stdout(), &output)?;
160
161        // Handle input
162        if let Event::Key(key) = event::read()? {
163            if key.kind == KeyEventKind::Press {
164                app.handle_key(key.code);
165            }
166        }
167
168        if app.should_quit {
169            return Ok(());
170        }
171    }
172}
173
174/// Color constants for the main UI.
175const COLOR_CYAN: Color = Color {
176    r: 0.3,
177    g: 1.0,
178    b: 1.0,
179    a: 1.0,
180};
181const COLOR_YELLOW: Color = Color {
182    r: 1.0,
183    g: 1.0,
184    b: 0.3,
185    a: 1.0,
186};
187const COLOR_DIM: Color = Color {
188    r: 0.5,
189    g: 0.5,
190    b: 0.5,
191    a: 1.0,
192};
193const COLOR_LINENUM: Color = Color {
194    r: 0.5,
195    g: 0.5,
196    b: 0.5,
197    a: 1.0,
198};
199const COLOR_BG: Color = Color {
200    r: 0.1,
201    g: 0.1,
202    b: 0.1,
203    a: 1.0,
204};
205const COLOR_TEXT: Color = Color {
206    r: 0.8,
207    g: 0.8,
208    b: 0.8,
209    a: 1.0,
210};
211const COLOR_SCROLL_TRACK: Color = Color {
212    r: 0.5,
213    g: 0.5,
214    b: 0.5,
215    a: 1.0,
216};
217const COLOR_SCROLL_THUMB: Color = Color {
218    r: 1.0,
219    g: 1.0,
220    b: 1.0,
221    a: 1.0,
222};
223
224fn ui(canvas: &mut DirectTerminalCanvas<'_>, app: &TuiApp, width: u16, height: u16) {
225    // Clear background
226    canvas.fill_rect(
227        presentar_core::Rect::new(0.0, 0.0, f32::from(width), f32::from(height)),
228        COLOR_BG,
229    );
230
231    // Layout: source pane width vs sidebar width
232    #[allow(clippy::cast_sign_loss)]
233    let (source_width, sidebar_width) = if app.sidebar_visible {
234        let sw = (f32::from(width) * 0.4).round() as u16;
235        (width.saturating_sub(sw), sw)
236    } else {
237        (width, 0)
238    };
239
240    // Render source pane (top area minus 3-line status bar)
241    let source_height = height.saturating_sub(3);
242    render_source_pane(canvas, app, 0.0, 0.0, source_width, source_height);
243
244    // Render sidebar if visible
245    if app.sidebar_visible && sidebar_width > 0 {
246        render_sidebar(
247            canvas,
248            app,
249            f32::from(source_width),
250            0.0,
251            sidebar_width,
252            source_height,
253        );
254    }
255
256    // Render status bar at the bottom
257    render_status_bar(canvas, width, height);
258}
259
260fn render_source_pane(
261    canvas: &mut DirectTerminalCanvas<'_>,
262    app: &TuiApp,
263    x: f32,
264    y: f32,
265    width: u16,
266    height: u16,
267) {
268    let border_style = TextStyle {
269        color: COLOR_CYAN,
270        ..Default::default()
271    };
272    let inner_width = (width as usize).saturating_sub(2); // borders
273
274    // Top border with title
275    let title = format!(" PTX: {} ", app.report.name);
276    let title_len = title.len();
277    let fill_len = inner_width.saturating_sub(title_len);
278    let top_line = format!("┌{}{}┐", title, "─".repeat(fill_len));
279    canvas.draw_text(&top_line, Point::new(x, y), &border_style);
280
281    // Source content lines
282    let content_height = height.saturating_sub(2); // top + bottom borders
283    let scroll = app.source_scroll as usize;
284    let lines: Vec<&str> = app.ptx_source.lines().collect();
285
286    for row in 0..content_height {
287        let line_idx = scroll + row as usize;
288        let cy = y + 1.0 + f32::from(row);
289
290        // Left border
291        canvas.draw_text("│", Point::new(x, cy), &border_style);
292
293        if line_idx < lines.len() {
294            // Line number
295            let line_num = format!("{:4} ", line_idx + 1);
296            let linenum_style = TextStyle {
297                color: COLOR_LINENUM,
298                ..Default::default()
299            };
300            canvas.draw_text(&line_num, Point::new(x + 1.0, cy), &linenum_style);
301
302            // Highlighted source text
303            let (text, color) = highlight_ptx_line(lines[line_idx]);
304            let text_style = TextStyle {
305                color,
306                ..Default::default()
307            };
308            // Truncate to fit: inner_width - 5 (line number) - 1 (scrollbar)
309            let max_text_len = inner_width.saturating_sub(6);
310            let display_text: String = text.chars().take(max_text_len).collect();
311            canvas.draw_text(&display_text, Point::new(x + 6.0, cy), &text_style);
312        }
313
314        // Right border (leave room for scrollbar)
315        canvas.draw_text(
316            "│",
317            Point::new(x + f32::from(width) - 1.0, cy),
318            &border_style,
319        );
320    }
321
322    // Bottom border
323    let bottom_line = format!("└{}┘", "─".repeat(inner_width));
324    canvas.draw_text(
325        &bottom_line,
326        Point::new(x, y + f32::from(height) - 1.0),
327        &border_style,
328    );
329
330    // Scrollbar (inside the right border area)
331    if app.source_lines > 0 {
332        draw_scrollbar(
333            canvas,
334            x + f32::from(width) - 2.0,
335            y + 1.0,
336            content_height,
337            app.source_scroll as usize,
338            app.source_lines,
339        );
340    }
341}
342
343fn draw_scrollbar(
344    canvas: &mut DirectTerminalCanvas<'_>,
345    x: f32,
346    top_y: f32,
347    height: u16,
348    position: usize,
349    total: usize,
350) {
351    let pos_ratio = position as f32 / total.max(1) as f32;
352    #[allow(clippy::cast_sign_loss)]
353    let thumb_y = (pos_ratio * f32::from(height - 1)).round() as u16;
354    let track_style = TextStyle {
355        color: COLOR_SCROLL_TRACK,
356        ..Default::default()
357    };
358    let thumb_style = TextStyle {
359        color: COLOR_SCROLL_THUMB,
360        ..Default::default()
361    };
362
363    for y in 0..height {
364        if y == thumb_y {
365            canvas.draw_text(
366                "\u{2588}",
367                Point::new(x, top_y + f32::from(y)),
368                &thumb_style,
369            );
370        } else {
371            canvas.draw_text(
372                "\u{2502}",
373                Point::new(x, top_y + f32::from(y)),
374                &track_style,
375            );
376        }
377    }
378}
379
380fn render_status_bar(canvas: &mut DirectTerminalCanvas<'_>, width: u16, height: u16) {
381    let status_y = f32::from(height - 3);
382    let border_style = TextStyle {
383        color: COLOR_DIM,
384        ..Default::default()
385    };
386    let inner_width = (width as usize).saturating_sub(2);
387
388    // Status bar top border
389    let top = format!("┌{}┐", "─".repeat(inner_width));
390    canvas.draw_text(&top, Point::new(0.0, status_y), &border_style);
391
392    // Status content line
393    canvas.draw_text("│", Point::new(0.0, status_y + 1.0), &border_style);
394
395    let key_style = TextStyle {
396        color: COLOR_YELLOW,
397        ..Default::default()
398    };
399    let text_style = TextStyle {
400        color: COLOR_TEXT,
401        ..Default::default()
402    };
403
404    let mut cx: f32 = 1.0;
405    let items: &[(&str, &str)] = &[
406        (" q", ":Quit "),
407        ("s", ":Sidebar "),
408        ("jk", ":Scroll "),
409        ("PgUp/Dn", ":Page "),
410    ];
411
412    for &(key, desc) in items {
413        canvas.draw_text(key, Point::new(cx, status_y + 1.0), &key_style);
414        cx += key.len() as f32;
415        canvas.draw_text(desc, Point::new(cx, status_y + 1.0), &text_style);
416        cx += desc.len() as f32;
417    }
418
419    canvas.draw_text(
420        "│",
421        Point::new(f32::from(width) - 1.0, status_y + 1.0),
422        &border_style,
423    );
424
425    // Status bar bottom border
426    let bottom = format!("└{}┘", "─".repeat(inner_width));
427    canvas.draw_text(&bottom, Point::new(0.0, status_y + 2.0), &border_style);
428}
429
430#[cfg(test)]
431mod tests;