1mod 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
28pub struct TuiApp {
30 pub ptx_source: String,
32 pub report: AnalysisReport,
34 pub bug_report: PtxBugReport,
36 pub source_scroll: u16,
38 pub sidebar_visible: bool,
40 pub should_quit: bool,
42 source_lines: usize,
44}
45
46impl TuiApp {
47 #[must_use]
49 pub fn new(ptx_source: String, report: AnalysisReport) -> Self {
50 let source_lines = ptx_source.lines().count();
51 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 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
105pub fn run_tui(ptx_source: String, report: AnalysisReport) -> io::Result<()> {
111 enable_raw_mode()?;
113 let mut stdout = io::stdout();
114 execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
115 execute!(stdout, crossterm::cursor::Hide)?;
116
117 let mut app = TuiApp::new(ptx_source, report);
119
120 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 let result = run_app(&mut app, &mut buffer, &mut renderer);
127
128 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 let (width, height) = crossterm::terminal::size()?;
144 if buffer.width() != width || buffer.height() != height {
145 *buffer = CellBuffer::new(width, height);
146 }
147
148 {
150 let mut canvas = DirectTerminalCanvas::new(buffer);
151 ui(&mut canvas, app, width, height);
152 }
153
154 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 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
174const 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 canvas.fill_rect(
227 presentar_core::Rect::new(0.0, 0.0, f32::from(width), f32::from(height)),
228 COLOR_BG,
229 );
230
231 #[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 let source_height = height.saturating_sub(3);
242 render_source_pane(canvas, app, 0.0, 0.0, source_width, source_height);
243
244 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(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); 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 let content_height = height.saturating_sub(2); 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 canvas.draw_text("│", Point::new(x, cy), &border_style);
292
293 if line_idx < lines.len() {
294 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 let (text, color) = highlight_ptx_line(lines[line_idx]);
304 let text_style = TextStyle {
305 color,
306 ..Default::default()
307 };
308 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 canvas.draw_text(
316 "│",
317 Point::new(x + f32::from(width) - 1.0, cy),
318 &border_style,
319 );
320 }
321
322 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 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 let top = format!("┌{}┐", "─".repeat(inner_width));
390 canvas.draw_text(&top, Point::new(0.0, status_y), &border_style);
391
392 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 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;