LCOV - code coverage report
Current view: top level - src - ui.rs (source / functions) Coverage Total Hit
Test: Hunky Coverage Lines: 96.8 % 689 667
Test Date: 2026-10-10 16:52:03 Functions: 50.0 % 34 17

            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;
        

Generated by: LCOV version 2.0-1