1use crate::error::Result;
2
3use super::data::ShowData;
4
5pub 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 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
50pub 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 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 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 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), Constraint::Min(1), Constraint::Length(1), ])
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 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 let gutter = if !region_indices.is_empty() {
183 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 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 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 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 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::v1::ConstraintSource::Author => "author",
279 crate::schema::v1::ConstraintSource::Inferred => "inferred",
280 };
281 text_lines.push(Line::raw(format!(" - {} [{source}]", c.text)));
282 }
283 text_lines.push(Line::raw(""));
284 }
285
286 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 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 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 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 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 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 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}