LCOV - code coverage report
Current view: top level - src - ui.rs (source / functions) Coverage Total Hit
Test: Hunky Coverage Lines: 95.0 % 563 535
Test Date: 2026-02-20 16:10:39 Functions: 100.0 % 21 21

            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              : }
        

Generated by: LCOV version 2.0-1