Line data Source code
1 : use ratatui::{
2 : layout::{Constraint, Direction, Layout, Rect},
3 : style::{Color, Modifier, Style},
4 : text::{Line, Span, Text},
5 : widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap},
6 : Frame,
7 : };
8 :
9 : use crate::app::{App, FocusPane, Mode, StreamSpeed, StreamingType};
10 : use crate::syntax::SyntaxHighlighter;
11 :
12 : /// Fade a color by reducing its brightness (for context lines)
13 32 : fn fade_color(color: Color) -> Color {
14 32 : match color {
15 1 : Color::Rgb(r, g, b) => {
16 : // Reduce brightness by about 60%
17 1 : let factor = 0.4;
18 1 : Color::Rgb(
19 1 : (r as f32 * factor) as u8,
20 1 : (g as f32 * factor) as u8,
21 1 : (b as f32 * factor) as u8,
22 1 : )
23 : }
24 31 : _ => Color::DarkGray,
25 : }
26 32 : }
27 :
28 : pub struct UI<'a> {
29 : app: &'a App,
30 : highlighter: SyntaxHighlighter,
31 : }
32 :
33 : impl<'a> UI<'a> {
34 31 : pub fn new(app: &'a App) -> Self {
35 31 : Self {
36 31 : app,
37 31 : highlighter: SyntaxHighlighter::new(),
38 31 : }
39 31 : }
40 :
41 31 : pub fn draw(&self, frame: &mut Frame) -> (u16, u16, u16) {
42 : // Always use compact layout (no footer)
43 31 : let chunks = Layout::default()
44 31 : .direction(Direction::Vertical)
45 31 : .constraints([
46 31 : Constraint::Length(3), // Header
47 31 : Constraint::Min(0), // Main content
48 31 : ])
49 31 : .split(frame.area());
50 :
51 31 : self.draw_header(frame, chunks[0]);
52 31 : let (diff_height, help_height, file_list_height) = self.draw_main_content(frame, chunks[1]);
53 :
54 : // Return viewport heights for clamping scroll offsets
55 : // file_list_height is unused but kept for API compatibility
56 31 : (diff_height, help_height, file_list_height)
57 31 : }
58 :
59 31 : fn draw_header(&self, frame: &mut Frame, area: Rect) {
60 31 : let available_width = area.width.saturating_sub(2) as usize; // Subtract borders
61 31 : let help_text = "H: Help";
62 31 : let help_width = help_text.len();
63 :
64 : // Determine which layout to use based on available width
65 : // Wide: > 80, Medium: > 50, Compact: > 40, Mini: <= 40
66 31 : let (mode_label, mode_text, title_text) = if available_width > 80 {
67 : // Full layout
68 18 : let mode_text = match self.app.mode() {
69 5 : Mode::View => "VIEW",
70 3 : Mode::Review => "REVIEW",
71 1 : Mode::Streaming(StreamingType::Buffered) => "STREAMING (Buffered)",
72 : Mode::Streaming(StreamingType::Auto(StreamSpeed::Fast)) => {
73 3 : "STREAMING (Auto - Fast)"
74 : }
75 : Mode::Streaming(StreamingType::Auto(StreamSpeed::Medium)) => {
76 5 : "STREAMING (Auto - Medium)"
77 : }
78 : Mode::Streaming(StreamingType::Auto(StreamSpeed::Slow)) => {
79 1 : "STREAMING (Auto - Slow)"
80 : }
81 : };
82 18 : ("Mode: ", mode_text, "Hunky")
83 13 : } else if available_width > 50 {
84 : // Medium layout
85 5 : let mode_text = match self.app.mode() {
86 1 : Mode::View => "VIEW",
87 0 : Mode::Review => "REVIEW",
88 1 : Mode::Streaming(StreamingType::Buffered) => "STREAM (Buff)",
89 1 : Mode::Streaming(StreamingType::Auto(StreamSpeed::Fast)) => "STREAM (Fast)",
90 1 : Mode::Streaming(StreamingType::Auto(StreamSpeed::Medium)) => "STREAM (Med)",
91 1 : Mode::Streaming(StreamingType::Auto(StreamSpeed::Slow)) => "STREAM (Slow)",
92 : };
93 5 : ("M: ", mode_text, "Hunky")
94 8 : } else if available_width > 40 {
95 : // Compact layout
96 4 : let mode_text = match self.app.mode() {
97 0 : Mode::View => "VIEW",
98 0 : Mode::Review => "REV",
99 1 : Mode::Streaming(StreamingType::Buffered) => "STM:B",
100 1 : Mode::Streaming(StreamingType::Auto(StreamSpeed::Fast)) => "STM:F",
101 1 : Mode::Streaming(StreamingType::Auto(StreamSpeed::Medium)) => "STM:M",
102 1 : Mode::Streaming(StreamingType::Auto(StreamSpeed::Slow)) => "STM:S",
103 : };
104 4 : ("M:", mode_text, "Hunky")
105 : } else {
106 : // Mini layout - minimal info
107 4 : let mode_text = match self.app.mode() {
108 1 : Mode::View => "V",
109 0 : Mode::Review => "R",
110 1 : Mode::Streaming(StreamingType::Buffered) => "B",
111 1 : Mode::Streaming(StreamingType::Auto(StreamSpeed::Fast)) => "F",
112 0 : Mode::Streaming(StreamingType::Auto(StreamSpeed::Medium)) => "M",
113 1 : Mode::Streaming(StreamingType::Auto(StreamSpeed::Slow)) => "S",
114 : };
115 4 : ("", mode_text, "Hunky")
116 : };
117 :
118 : // Build title with help hint on the right side
119 31 : let mut title_left = vec![
120 31 : Span::styled(
121 31 : title_text,
122 31 : Style::default()
123 31 : .fg(Color::Cyan)
124 31 : .add_modifier(Modifier::BOLD),
125 : ),
126 31 : Span::raw(" | "),
127 : ];
128 :
129 31 : if !mode_label.is_empty() {
130 27 : title_left.push(Span::raw(mode_label));
131 27 : }
132 31 : title_left.push(Span::styled(mode_text, Style::default().fg(Color::Yellow)));
133 :
134 : // Calculate padding to right-align help hint
135 120 : let left_width = title_left.iter().map(|s| s.content.len()).sum::<usize>();
136 31 : let padding_width = available_width.saturating_sub(left_width + help_width);
137 :
138 31 : let mut title_line = title_left;
139 31 : if padding_width > 0 {
140 31 : title_line.push(Span::raw(" ".repeat(padding_width)));
141 31 : title_line.push(Span::styled(help_text, Style::default().fg(Color::Gray)));
142 31 : }
143 :
144 31 : let header =
145 31 : Paragraph::new(Line::from(title_line)).block(Block::default().borders(Borders::ALL));
146 :
147 31 : frame.render_widget(header, area);
148 31 : }
149 :
150 31 : fn draw_main_content(&self, frame: &mut Frame, area: Rect) -> (u16, u16, u16) {
151 : // Check if commit picker overlay should be shown
152 31 : if self.app.review_selecting_commit() {
153 1 : self.draw_commit_picker(frame, area);
154 1 : return (0, 0, 0);
155 30 : }
156 :
157 : // Check if extended help view should be shown
158 30 : if self.app.show_extended_help() {
159 1 : let help_height = self.draw_extended_help(frame, area);
160 1 : return (0, help_height, 0);
161 29 : }
162 :
163 : // Check if help sidebar should be shown
164 29 : if self.app.show_help() {
165 : // Split into 3 columns: file list, diff, help
166 5 : let chunks = Layout::default()
167 5 : .direction(Direction::Horizontal)
168 5 : .constraints([
169 5 : Constraint::Percentage(25), // File list
170 5 : Constraint::Min(0), // Diff content (takes remaining space)
171 5 : Constraint::Length(20), // Help sidebar
172 5 : ])
173 5 : .split(area);
174 :
175 5 : self.draw_file_list(frame, chunks[0]);
176 5 : let diff_height = self.draw_diff_content(frame, chunks[1]);
177 5 : let help_height = self.draw_help_sidebar(frame, chunks[2]);
178 5 : (diff_height, help_height, 0)
179 : } else {
180 : // No help shown, just file list and diff
181 24 : let chunks = Layout::default()
182 24 : .direction(Direction::Horizontal)
183 24 : .constraints([
184 24 : Constraint::Percentage(25), // File list
185 24 : Constraint::Percentage(75), // Diff content
186 24 : ])
187 24 : .split(area);
188 :
189 24 : self.draw_file_list(frame, chunks[0]);
190 24 : let diff_height = self.draw_diff_content(frame, chunks[1]);
191 24 : (diff_height, 0, 0)
192 : }
193 31 : }
194 :
195 29 : fn draw_file_list(&self, frame: &mut Frame, area: Rect) {
196 29 : let snapshot = match self.app.current_snapshot() {
197 28 : Some(s) => s,
198 : None => {
199 1 : let empty = Paragraph::new("No changes")
200 1 : .block(Block::default().borders(Borders::ALL).title("Files"));
201 1 : frame.render_widget(empty, area);
202 1 : return;
203 : }
204 : };
205 :
206 28 : let is_review_mode = self.app.mode() == Mode::Review;
207 :
208 28 : let items: Vec<ListItem> = snapshot
209 28 : .files
210 28 : .iter()
211 28 : .enumerate()
212 28 : .map(|(idx, file)| {
213 14 : let file_name = file
214 14 : .path
215 14 : .file_name()
216 14 : .and_then(|n| n.to_str())
217 14 : .unwrap_or("unknown");
218 :
219 14 : let is_selected = idx == self.app.current_file_index();
220 14 : let name_style = if is_selected {
221 12 : Style::default()
222 12 : .fg(Color::Yellow)
223 12 : .add_modifier(Modifier::BOLD)
224 : } else {
225 2 : Style::default()
226 : };
227 :
228 14 : let hunk_count = file.hunks.len();
229 :
230 14 : let count_text = if is_review_mode {
231 2 : let accepted_count = file.hunks.iter().filter(|h| h.accepted).count();
232 2 : if accepted_count > 0 {
233 1 : format!(" ({}) [{}✓]", hunk_count, accepted_count)
234 : } else {
235 1 : format!(" ({})", hunk_count)
236 : }
237 : } else {
238 12 : let staged_count = file.hunks.iter().filter(|h| h.staged).count();
239 :
240 : // Count partially staged hunks
241 12 : let partial_count = file
242 12 : .hunks
243 12 : .iter()
244 13 : .filter(|h| {
245 13 : let total_change_lines = h
246 13 : .lines
247 13 : .iter()
248 50 : .filter(|line| {
249 50 : (line.starts_with('+') && !line.starts_with("+++"))
250 27 : || (line.starts_with('-') && !line.starts_with("---"))
251 50 : })
252 13 : .count();
253 13 : let staged_lines = h.staged_line_indices.len();
254 13 : staged_lines > 0 && staged_lines < total_change_lines
255 13 : })
256 12 : .count();
257 :
258 12 : if staged_count > 0 || partial_count > 0 {
259 1 : if partial_count > 0 {
260 1 : format!(" ({}) [{}✓ {}⚠]", hunk_count, staged_count, partial_count)
261 : } else {
262 0 : format!(" ({}) [{}✓]", hunk_count, staged_count)
263 : }
264 : } else {
265 11 : format!(" ({})", hunk_count)
266 : }
267 : };
268 :
269 14 : let content = Line::from(vec![
270 14 : Span::styled(file_name, name_style),
271 14 : Span::styled(count_text, Style::default().fg(Color::DarkGray)),
272 : ]);
273 :
274 14 : ListItem::new(content)
275 14 : })
276 28 : .collect();
277 :
278 28 : let title = if self.app.focus() == FocusPane::FileList {
279 2 : "Files [FOCUSED]"
280 : } else {
281 26 : "Files"
282 : };
283 :
284 28 : let border_style = if self.app.focus() == FocusPane::FileList {
285 2 : Style::default().fg(Color::Cyan)
286 : } else {
287 26 : Style::default()
288 : };
289 :
290 28 : let list = List::new(items).block(
291 28 : Block::default()
292 28 : .borders(Borders::ALL)
293 28 : .title(title)
294 28 : .border_style(border_style),
295 : );
296 :
297 : // Use stateful widget to handle scrolling automatically
298 28 : let mut state = ratatui::widgets::ListState::default();
299 28 : state.select(Some(self.app.current_file_index()));
300 28 : frame.render_stateful_widget(list, area, &mut state);
301 29 : }
302 :
303 29 : fn draw_diff_content(&self, frame: &mut Frame, area: Rect) -> u16 {
304 : // Return viewport height for clamping
305 29 : let viewport_height = area.height.saturating_sub(2); // Subtract borders
306 :
307 29 : let file = match self.app.current_file() {
308 12 : Some(f) => f,
309 : None => {
310 17 : let empty = Paragraph::new("No file selected")
311 17 : .block(Block::default().borders(Borders::ALL).title("Diff"));
312 17 : frame.render_widget(empty, area);
313 17 : return viewport_height;
314 : }
315 : };
316 :
317 12 : if self.app.show_filenames_only() {
318 1 : let content = format!(
319 : "File: {}\nStatus: {}\nHunks: {}",
320 1 : file.path.display(),
321 : file.status,
322 1 : file.hunks.len()
323 : );
324 1 : let file_info_title = "File Info".to_string();
325 1 : let paragraph = Paragraph::new(content)
326 1 : .block(
327 1 : Block::default()
328 1 : .borders(Borders::ALL)
329 1 : .title(file_info_title),
330 : )
331 1 : .wrap(Wrap { trim: true });
332 1 : frame.render_widget(paragraph, area);
333 1 : return viewport_height;
334 11 : }
335 :
336 : // Get only the current hunk (one hunk at a time UX)
337 11 : let current_hunk = file.hunks.get(self.app.current_hunk_index());
338 :
339 11 : if current_hunk.is_none() {
340 1 : let file_title = file.path.to_string_lossy().to_string();
341 1 : let empty = Paragraph::new("No hunks to display yet")
342 1 : .block(Block::default().borders(Borders::ALL).title(file_title));
343 1 : frame.render_widget(empty, area);
344 1 : return viewport_height;
345 10 : }
346 :
347 10 : let hunk = current_hunk.unwrap();
348 :
349 : // Build the text with syntax highlighting
350 10 : let mut lines = Vec::new();
351 :
352 : // Add file header
353 10 : let file_path_str = file.path.to_string_lossy().to_string();
354 10 : lines.push(Line::from(vec![
355 10 : Span::styled("--- ", Style::default().fg(Color::Red)),
356 10 : Span::styled(file_path_str.clone(), Style::default().fg(Color::White)),
357 : ]));
358 10 : lines.push(Line::from(vec![
359 10 : Span::styled("+++ ", Style::default().fg(Color::Green)),
360 10 : Span::styled(file_path_str.clone(), Style::default().fg(Color::White)),
361 : ]));
362 10 : lines.push(Line::from(""));
363 :
364 : // Add hunk header with seen, staged, and accepted indicators
365 : // Check if partially staged
366 10 : let total_change_lines = hunk
367 10 : .lines
368 10 : .iter()
369 42 : .filter(|line| {
370 42 : (line.starts_with('+') && !line.starts_with("+++"))
371 25 : || (line.starts_with('-') && !line.starts_with("---"))
372 42 : })
373 10 : .count();
374 10 : let staged_lines_count = hunk.staged_line_indices.len();
375 10 : let is_partially_staged = staged_lines_count > 0 && staged_lines_count < total_change_lines;
376 10 : let is_review_mode = self.app.mode() == Mode::Review;
377 :
378 10 : let base_header = format!(
379 : "@@ -{},{} +{},{} @@",
380 : hunk.old_start,
381 10 : hunk.lines.len(),
382 : hunk.new_start,
383 10 : hunk.lines.len()
384 : );
385 :
386 10 : let hunk_header = if is_review_mode {
387 : // In review mode, show accepted state
388 2 : if hunk.accepted {
389 1 : format!("{} [ACCEPTED ✓]", base_header)
390 : } else {
391 1 : base_header
392 : }
393 8 : } else if is_partially_staged {
394 1 : match hunk.seen {
395 1 : true => format!("{} [PARTIAL ⚠] [SEEN]", base_header),
396 0 : false => format!("{} [PARTIAL ⚠]", base_header),
397 : }
398 : } else {
399 7 : match (hunk.staged, hunk.seen) {
400 0 : (true, true) => format!("{} [STAGED ✓] [SEEN]", base_header),
401 0 : (true, false) => format!("{} [STAGED ✓]", base_header),
402 0 : (false, true) => format!("{} [SEEN]", base_header),
403 7 : (false, false) => base_header,
404 : }
405 : };
406 :
407 10 : let header_style = if is_review_mode && hunk.accepted {
408 1 : Style::default().fg(Color::Green)
409 9 : } else if is_partially_staged {
410 1 : Style::default().fg(Color::Yellow)
411 8 : } else if hunk.staged {
412 0 : Style::default().fg(Color::Green)
413 8 : } else if hunk.seen {
414 0 : Style::default().fg(Color::DarkGray)
415 : } else {
416 8 : Style::default().fg(Color::Cyan)
417 : };
418 :
419 10 : lines.push(Line::from(Span::styled(hunk_header, header_style)));
420 10 : lines.push(Line::from("")); // Empty line for spacing
421 :
422 : // Separate lines into context before, changes, and context after
423 10 : let mut context_before = Vec::new();
424 10 : let mut changes = Vec::new();
425 10 : let mut context_after = Vec::new();
426 :
427 10 : let mut in_changes = false;
428 :
429 42 : for (idx, line) in hunk.lines.iter().enumerate() {
430 42 : if line.starts_with('+') || line.starts_with('-') {
431 26 : in_changes = true;
432 26 : changes.push((idx, line.clone()));
433 26 : } else if !in_changes {
434 10 : context_before.push(line.clone());
435 10 : } else {
436 6 : context_after.push(line.clone());
437 6 : }
438 : }
439 :
440 : // Create syntax highlighter for this file if enabled
441 10 : let mut file_highlighter = if self.app.syntax_highlighting() {
442 8 : Some(self.highlighter.create_highlighter(&file.path))
443 : } else {
444 2 : None
445 : };
446 :
447 : // Show up to 5 lines of context before
448 10 : let context_before_start = if context_before.len() > 5 {
449 1 : context_before.len() - 5
450 : } else {
451 9 : 0
452 : };
453 :
454 10 : for line in &context_before[context_before_start..] {
455 9 : let content = line.strip_prefix(' ').unwrap_or(line);
456 9 : if let Some(ref mut highlighter) = file_highlighter {
457 : // Apply syntax highlighting with faded colors
458 3 : let highlighted = highlighter.highlight_line(content);
459 3 : let mut spans = vec![Span::raw(" ")]; // 6 spaces: 4 for indicators + 1 for +/- + 1 space
460 24 : for (color, text) in highlighted {
461 24 : // Make syntax colors darker/faded for context
462 24 : let faded_color = fade_color(color);
463 24 : spans.push(Span::styled(text, Style::default().fg(faded_color)));
464 24 : }
465 3 : lines.push(Line::from(spans));
466 6 : } else {
467 6 : lines.push(Line::from(Span::styled(
468 6 : format!(" {}", content), // 6 spaces: 4 for indicators + 1 for +/- + 1 space
469 6 : Style::default().fg(Color::DarkGray),
470 6 : )));
471 6 : }
472 : }
473 :
474 : // Show changes with background colors for better visibility
475 : // Using very subtle colors: 233 (near-black with slight tint), 234 for contrast
476 : // Green additions: bg 22 → 236 (darker gray-green), prefix 28 → 34 (softer green)
477 : // Red additions: bg 52 → 235 (darker gray-red), prefix 88 → 124 (softer red)
478 10 : let line_selection_mode = self.app.line_selection_mode();
479 10 : let selected_line = self.app.selected_line_index();
480 :
481 26 : for (original_idx, line) in &changes {
482 26 : let is_selected = line_selection_mode && *original_idx == selected_line;
483 26 : let is_staged = hunk.staged_line_indices.contains(original_idx);
484 :
485 : // Build 4-character indicator prefix: [selection (2)][staged (2)]
486 26 : let selection_marker = if is_selected { "► " } else { " " };
487 26 : let staged_marker = if is_staged { "✓ " } else { " " };
488 26 : let indicator_prefix = format!("{}{}", selection_marker, staged_marker);
489 :
490 26 : if line.starts_with('+') {
491 17 : let content = line.strip_prefix('+').unwrap_or(line);
492 17 : if let Some(ref mut highlighter) = file_highlighter {
493 : // Apply syntax highlighting with very subtle green background
494 14 : let highlighted = highlighter.highlight_line(content);
495 14 : let bg_color = if is_selected {
496 0 : Color::Indexed(28)
497 : } else {
498 14 : Color::Indexed(236)
499 : };
500 14 : let fg_color = if is_selected {
501 0 : Color::Indexed(46)
502 : } else {
503 14 : Color::Indexed(34)
504 : };
505 14 : let mut spans = vec![Span::styled(
506 14 : format!("{}+ ", indicator_prefix),
507 14 : Style::default().fg(fg_color).bg(bg_color),
508 : )];
509 62 : for (color, text) in highlighted {
510 62 : // Apply syntax colors with subtle green-tinted background
511 62 : spans.push(Span::styled(text, Style::default().fg(color).bg(bg_color)));
512 62 : }
513 14 : lines.push(Line::from(spans));
514 : } else {
515 3 : let bg_color = if is_selected {
516 0 : Color::Indexed(28)
517 : } else {
518 3 : Color::Indexed(236)
519 : };
520 3 : let fg_color = if is_selected {
521 0 : Color::Indexed(46)
522 : } else {
523 3 : Color::Indexed(34)
524 : };
525 3 : lines.push(Line::from(Span::styled(
526 3 : format!("{}+ {}", indicator_prefix, content),
527 3 : Style::default().fg(fg_color).bg(bg_color),
528 : )));
529 : }
530 9 : } else if line.starts_with('-') {
531 9 : let content = line.strip_prefix('-').unwrap_or(line);
532 9 : if let Some(ref mut highlighter) = file_highlighter {
533 : // Apply syntax highlighting with very subtle red background
534 7 : let highlighted = highlighter.highlight_line(content);
535 7 : let bg_color = if is_selected {
536 0 : Color::Indexed(52)
537 : } else {
538 7 : Color::Indexed(235)
539 : };
540 7 : let fg_color = if is_selected {
541 0 : Color::Indexed(196)
542 : } else {
543 7 : Color::Indexed(124)
544 : };
545 7 : let mut spans = vec![Span::styled(
546 7 : format!("{}- ", indicator_prefix),
547 7 : Style::default().fg(fg_color).bg(bg_color),
548 : )];
549 31 : for (color, text) in highlighted {
550 31 : // Apply syntax colors with subtle red-tinted background
551 31 : spans.push(Span::styled(text, Style::default().fg(color).bg(bg_color)));
552 31 : }
553 7 : lines.push(Line::from(spans));
554 : } else {
555 2 : let bg_color = if is_selected {
556 1 : Color::Indexed(52)
557 : } else {
558 1 : Color::Indexed(235)
559 : };
560 2 : let fg_color = if is_selected {
561 1 : Color::Indexed(196)
562 : } else {
563 1 : Color::Indexed(124)
564 : };
565 2 : lines.push(Line::from(Span::styled(
566 2 : format!("{}- {}", indicator_prefix, content),
567 2 : Style::default().fg(fg_color).bg(bg_color),
568 : )));
569 : }
570 0 : }
571 : }
572 :
573 : // Show up to 5 lines of context after
574 10 : let context_after_end = context_after.len().min(5);
575 :
576 10 : for line in &context_after[..context_after_end] {
577 6 : let content = line.strip_prefix(' ').unwrap_or(line);
578 6 : if let Some(ref mut highlighter) = file_highlighter {
579 : // Apply syntax highlighting with faded colors
580 3 : let highlighted = highlighter.highlight_line(content);
581 3 : let mut spans = vec![Span::raw(" ")]; // 6 spaces: 4 for indicators + 1 for +/- + 1 space
582 6 : for (color, text) in highlighted {
583 6 : // Make syntax colors darker/faded for context
584 6 : let faded_color = fade_color(color);
585 6 : spans.push(Span::styled(text, Style::default().fg(faded_color)));
586 6 : }
587 3 : lines.push(Line::from(spans));
588 3 : } else {
589 3 : lines.push(Line::from(Span::styled(
590 3 : format!(" {}", content), // 6 spaces: 4 for indicators + 1 for +/- + 1 space
591 3 : Style::default().fg(Color::DarkGray),
592 3 : )));
593 3 : }
594 : }
595 :
596 10 : let text = Text::from(lines);
597 :
598 10 : let title_focus = if self.app.focus() == FocusPane::HunkView {
599 7 : " [FOCUSED]"
600 : } else {
601 3 : ""
602 : };
603 :
604 10 : let border_style = if self.app.focus() == FocusPane::HunkView {
605 7 : Style::default().fg(Color::Cyan)
606 : } else {
607 3 : Style::default()
608 : };
609 :
610 10 : let mut paragraph = Paragraph::new(text)
611 10 : .block(
612 10 : Block::default()
613 10 : .borders(Borders::ALL)
614 10 : .title(format!(
615 : "{} (Hunk {}/{}{})",
616 10 : file.path.to_string_lossy(),
617 10 : self.app.current_hunk_index() + 1,
618 10 : file.hunks.len(),
619 : title_focus
620 : ))
621 10 : .border_style(border_style),
622 : )
623 10 : .scroll((self.app.scroll_offset(), 0));
624 :
625 : // Apply wrapping if enabled
626 10 : if self.app.wrap_lines() {
627 1 : paragraph = paragraph.wrap(Wrap { trim: false });
628 9 : }
629 :
630 10 : frame.render_widget(Clear, area);
631 10 : frame.render_widget(paragraph, area);
632 10 : viewport_height
633 29 : }
634 :
635 5 : fn draw_help_sidebar(&self, frame: &mut Frame, area: Rect) -> u16 {
636 : // Return viewport height for clamping
637 5 : let viewport_height = area.height.saturating_sub(2); // Subtract borders
638 :
639 5 : let help_lines = vec![
640 5 : Line::from(Span::styled(
641 : "Navigation",
642 5 : Style::default()
643 5 : .fg(Color::Yellow)
644 5 : .add_modifier(Modifier::BOLD),
645 : )),
646 5 : Line::from("Q: Quit"),
647 5 : Line::from("Tab/Shift+Tab: Focus"),
648 5 : Line::from("Space: Next Hunk"),
649 5 : Line::from("B: Prev Hunk"),
650 5 : Line::from("J/K: Scroll/Nav"),
651 5 : Line::from("N/P: Next/Prev File"),
652 5 : Line::from(""),
653 5 : Line::from(Span::styled(
654 : "Modes",
655 5 : Style::default()
656 5 : .fg(Color::Yellow)
657 5 : .add_modifier(Modifier::BOLD),
658 : )),
659 5 : Line::from("M: Cycle Mode"),
660 5 : Line::from(" View → Streaming"),
661 5 : Line::from(" (Buffered/Auto)"),
662 5 : Line::from(""),
663 5 : Line::from(Span::styled(
664 : "Display",
665 5 : Style::default()
666 5 : .fg(Color::Yellow)
667 5 : .add_modifier(Modifier::BOLD),
668 : )),
669 5 : Line::from("W: Toggle Wrap"),
670 5 : Line::from("Y: Toggle Syntax"),
671 5 : Line::from("F: Filenames Only"),
672 5 : Line::from("H: Toggle Help"),
673 5 : Line::from("Shift+H: Extended Help"),
674 5 : Line::from(""),
675 5 : Line::from(Span::styled(
676 : "Staging",
677 5 : Style::default()
678 5 : .fg(Color::Yellow)
679 5 : .add_modifier(Modifier::BOLD),
680 : )),
681 5 : Line::from("L: Line Mode"),
682 5 : Line::from("S: Stage/Unstage"),
683 5 : Line::from(""),
684 5 : Line::from(Span::styled(
685 : "Review",
686 5 : Style::default()
687 5 : .fg(Color::Yellow)
688 5 : .add_modifier(Modifier::BOLD),
689 : )),
690 5 : Line::from("R: Review Commit"),
691 5 : Line::from("S: Accept (in review)"),
692 5 : Line::from("ESC: Exit Review"),
693 5 : Line::from(""),
694 5 : Line::from(Span::styled(
695 : "Other",
696 5 : Style::default()
697 5 : .fg(Color::Yellow)
698 5 : .add_modifier(Modifier::BOLD),
699 : )),
700 5 : Line::from("C: Commit (Open Editor)"),
701 5 : Line::from("ESC: Reset to Defaults"),
702 : ];
703 :
704 5 : let is_focused = self.app.focus() == FocusPane::HelpSidebar;
705 5 : let border_color = if is_focused {
706 2 : Color::Cyan
707 : } else {
708 3 : Color::White
709 : };
710 5 : let title = if is_focused { "Keys [FOCUSED]" } else { "Keys" };
711 :
712 5 : let help = Paragraph::new(help_lines)
713 5 : .block(
714 5 : Block::default()
715 5 : .borders(Borders::ALL)
716 5 : .border_style(Style::default().fg(border_color))
717 5 : .title(title),
718 : )
719 5 : .style(Style::default().fg(Color::Gray))
720 5 : .scroll((self.app.help_scroll_offset(), 0));
721 :
722 5 : frame.render_widget(help, area);
723 5 : viewport_height
724 5 : }
725 :
726 1 : fn draw_commit_picker(&self, frame: &mut Frame, area: Rect) {
727 1 : let commits = self.app.review_commits();
728 1 : let cursor = self.app.review_commit_cursor();
729 :
730 1 : let items: Vec<ListItem> = commits
731 1 : .iter()
732 1 : .enumerate()
733 2 : .map(|(idx, commit)| {
734 2 : let is_selected = idx == cursor;
735 2 : let style = if is_selected {
736 1 : Style::default()
737 1 : .fg(Color::Yellow)
738 1 : .add_modifier(Modifier::BOLD)
739 : } else {
740 1 : Style::default()
741 : };
742 :
743 2 : let content = Line::from(vec![
744 2 : Span::styled(
745 2 : format!("{} ", commit.short_sha),
746 2 : Style::default().fg(if is_selected {
747 1 : Color::Cyan
748 : } else {
749 1 : Color::DarkGray
750 : }),
751 : ),
752 2 : Span::styled(&commit.summary, style),
753 2 : Span::styled(
754 2 : format!(" ({})", commit.author),
755 2 : Style::default().fg(Color::DarkGray),
756 : ),
757 : ]);
758 :
759 2 : ListItem::new(content)
760 2 : })
761 1 : .collect();
762 :
763 1 : let list = List::new(items).block(
764 1 : Block::default()
765 1 : .borders(Borders::ALL)
766 1 : .border_style(Style::default().fg(Color::Cyan))
767 1 : .title(
768 : "Select a commit to review (↑/↓ to navigate, Enter to select, Esc to cancel)",
769 : ),
770 : );
771 :
772 1 : let mut state = ratatui::widgets::ListState::default();
773 1 : state.select(Some(cursor));
774 1 : frame.render_stateful_widget(list, area, &mut state);
775 1 : }
776 :
777 1 : fn draw_extended_help(&self, frame: &mut Frame, area: Rect) -> u16 {
778 : // Return viewport height for clamping
779 1 : let viewport_height = area.height.saturating_sub(2); // Subtract borders
780 :
781 1 : let help_content = vec![
782 1 : Line::from(Span::styled(
783 : "HUNKY - Extended Help",
784 1 : Style::default()
785 1 : .fg(Color::Cyan)
786 1 : .add_modifier(Modifier::BOLD),
787 : )),
788 1 : Line::from(""),
789 1 : Line::from(Span::styled(
790 : "═══════════════════════════════════════════════════════════",
791 1 : Style::default().fg(Color::DarkGray),
792 : )),
793 1 : Line::from(""),
794 1 : Line::from(Span::styled(
795 : "OVERVIEW",
796 1 : Style::default()
797 1 : .fg(Color::Yellow)
798 1 : .add_modifier(Modifier::BOLD),
799 : )),
800 1 : Line::from("Hunky is a terminal UI for reviewing and staging git changes at the hunk"),
801 1 : Line::from("or line level. It provides two main modes for different workflows:"),
802 1 : Line::from(""),
803 1 : Line::from(Span::styled(
804 : "MODES",
805 1 : Style::default()
806 1 : .fg(Color::Yellow)
807 1 : .add_modifier(Modifier::BOLD),
808 : )),
809 1 : Line::from(""),
810 1 : Line::from(vec![
811 1 : Span::styled(
812 : "View Mode",
813 1 : Style::default()
814 1 : .fg(Color::Green)
815 1 : .add_modifier(Modifier::BOLD),
816 : ),
817 1 : Span::raw(" - Browse all current changes"),
818 : ]),
819 1 : Line::from(" • Shows all changes from HEAD to working directory"),
820 1 : Line::from(" • Full navigation with Space (next) and Shift+Space (previous)"),
821 1 : Line::from(" • Ideal for reviewing existing changes before committing"),
822 1 : Line::from(" • Default mode when starting Hunky"),
823 1 : Line::from(""),
824 1 : Line::from(vec![
825 1 : Span::styled(
826 : "Streaming Mode",
827 1 : Style::default()
828 1 : .fg(Color::Magenta)
829 1 : .add_modifier(Modifier::BOLD),
830 : ),
831 1 : Span::raw(" - Watch new changes as they appear"),
832 : ]),
833 1 : Line::from(" • Only shows hunks that appear after entering this mode"),
834 1 : Line::from(" • Two sub-modes:"),
835 1 : Line::from(" - Buffered: Manual advance with Space key"),
836 1 : Line::from(" - Auto (Fast/Medium/Slow): Automatic advancement with timing"),
837 1 : Line::from(" • Perfect for TDD workflows or watching build output changes"),
838 1 : Line::from(" • Press M to cycle through streaming options"),
839 1 : Line::from(""),
840 1 : Line::from(Span::styled(
841 : "NAVIGATION",
842 1 : Style::default()
843 1 : .fg(Color::Yellow)
844 1 : .add_modifier(Modifier::BOLD),
845 : )),
846 1 : Line::from(""),
847 1 : Line::from(" Space Next hunk (all modes)"),
848 1 : Line::from(" B Previous hunk (View & Buffered modes)"),
849 1 : Line::from(" J/K or ↓/↑ Scroll hunk view or navigate in line mode"),
850 1 : Line::from(" N/P Next/Previous file"),
851 1 : Line::from(" Tab Cycle focus forward (File → Hunk → Help)"),
852 1 : Line::from(" Shift+Tab Cycle focus backward"),
853 1 : Line::from(""),
854 1 : Line::from(Span::styled(
855 : "STAGING",
856 1 : Style::default()
857 1 : .fg(Color::Yellow)
858 1 : .add_modifier(Modifier::BOLD),
859 : )),
860 1 : Line::from(""),
861 1 : Line::from(" S Smart stage/unstage toggle"),
862 1 : Line::from(" L Toggle Line Mode for line-level staging"),
863 1 : Line::from(""),
864 1 : Line::from("Smart Toggle Behavior (Hunk Mode):"),
865 1 : Line::from(" • Unstaged → Press S → Fully staged"),
866 1 : Line::from(" • Partially staged → Press S → Fully staged"),
867 1 : Line::from(" • Fully staged → Press S → Fully unstaged"),
868 1 : Line::from(""),
869 1 : Line::from("In Line Mode:"),
870 1 : Line::from(" • Use J/K to navigate between changed lines (+ or -)"),
871 1 : Line::from(" • Press S to toggle staging for the selected line"),
872 1 : Line::from(" • Staged lines show a ✓ indicator"),
873 1 : Line::from(" • External changes (e.g., git add -p) are detected automatically"),
874 1 : Line::from(""),
875 1 : Line::from(Span::styled(
876 : "DISPLAY OPTIONS",
877 1 : Style::default()
878 1 : .fg(Color::Yellow)
879 1 : .add_modifier(Modifier::BOLD),
880 : )),
881 1 : Line::from(""),
882 1 : Line::from(" H Toggle help sidebar"),
883 1 : Line::from(" Shift+H Toggle this extended help view"),
884 1 : Line::from(" F Toggle filenames-only mode (hide diffs)"),
885 1 : Line::from(" W Toggle line wrapping"),
886 1 : Line::from(" Y Toggle syntax highlighting"),
887 1 : Line::from(""),
888 1 : Line::from(Span::styled(
889 : "MODE SWITCHING",
890 1 : Style::default()
891 1 : .fg(Color::Yellow)
892 1 : .add_modifier(Modifier::BOLD),
893 : )),
894 1 : Line::from(""),
895 1 : Line::from(" M Cycle through modes:"),
896 1 : Line::from(" View → Streaming (Buffered) → Streaming (Auto Fast)"),
897 1 : Line::from(
898 : " → Streaming (Auto Medium) → Streaming (Auto Slow) → View",
899 : ),
900 1 : Line::from(""),
901 1 : Line::from("When switching to Streaming mode, Hunky captures the current state and"),
902 1 : Line::from("will only show new hunks that appear after the switch."),
903 1 : Line::from(""),
904 1 : Line::from(Span::styled(
905 : "RESET TO DEFAULTS",
906 1 : Style::default()
907 1 : .fg(Color::Yellow)
908 1 : .add_modifier(Modifier::BOLD),
909 : )),
910 1 : Line::from(""),
911 1 : Line::from(" ESC Reset everything to defaults:"),
912 1 : Line::from(" • Exit extended help view"),
913 1 : Line::from(" • Set mode to View"),
914 1 : Line::from(" • Exit line mode"),
915 1 : Line::from(" • Focus hunk view"),
916 1 : Line::from(" • Hide help sidebar"),
917 1 : Line::from(""),
918 1 : Line::from(Span::styled(
919 : "WORKFLOWS",
920 1 : Style::default()
921 1 : .fg(Color::Yellow)
922 1 : .add_modifier(Modifier::BOLD),
923 : )),
924 1 : Line::from(""),
925 1 : Line::from(vec![
926 1 : Span::styled("Code Review", Style::default().fg(Color::Green)),
927 1 : Span::raw(" - Use View mode to browse all changes, stage what you want"),
928 : ]),
929 1 : Line::from("to commit, then press C to open your configured git editor."),
930 1 : Line::from(""),
931 1 : Line::from(vec![
932 1 : Span::styled("TDD Workflow", Style::default().fg(Color::Magenta)),
933 1 : Span::raw(" - Switch to Streaming (Auto) mode, run tests in"),
934 : ]),
935 1 : Line::from("another terminal, and watch test changes flow through Hunky as you"),
936 1 : Line::from("iterate on your code."),
937 1 : Line::from(""),
938 1 : Line::from(vec![
939 1 : Span::styled("Partial Staging", Style::default().fg(Color::Cyan)),
940 1 : Span::raw(" - Enable Line Mode (L) to stage specific lines"),
941 : ]),
942 1 : Line::from(
943 : "within a hunk. Great for separating formatting changes from logic changes.",
944 : ),
945 1 : Line::from(""),
946 1 : Line::from(Span::styled(
947 : "═══════════════════════════════════════════════════════════",
948 1 : Style::default().fg(Color::DarkGray),
949 : )),
950 1 : Line::from(""),
951 1 : Line::from("Press ESC to exit this help view and return to normal operation."),
952 1 : Line::from("Press J/K to scroll through this help."),
953 : ];
954 :
955 1 : let help = Paragraph::new(help_content)
956 1 : .block(
957 1 : Block::default()
958 1 : .borders(Borders::ALL)
959 1 : .border_style(Style::default().fg(Color::Cyan))
960 1 : .title("Extended Help (Shift+H to close, ESC to reset)"),
961 : )
962 1 : .style(Style::default().fg(Color::White))
963 1 : .wrap(Wrap { trim: false })
964 1 : .scroll((self.app.extended_help_scroll_offset(), 0));
965 :
966 1 : frame.render_widget(help, area);
967 1 : viewport_height
968 1 : }
969 : }
970 :
971 : #[cfg(test)]
972 : #[path = "../tests/ui.rs"]
973 : mod tests;
|