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

Generated by: LCOV version 2.0-1