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