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