ghostscope_ui/components/source_panel/
navigation.rs

1use crate::action::Action;
2use crate::model::panel_state::{SourcePanelMode, SourcePanelState};
3
4/// Handles source panel navigation functionality
5pub struct SourceNavigation;
6
7impl SourceNavigation {
8    /// Move cursor up
9    pub fn move_up(state: &mut SourcePanelState) -> Vec<Action> {
10        if state.cursor_line > 0 {
11            state.cursor_line -= 1;
12            Self::ensure_column_bounds(state);
13        }
14        Vec::new()
15    }
16
17    /// Move cursor down
18    pub fn move_down(state: &mut SourcePanelState) -> Vec<Action> {
19        if state.cursor_line < state.content.len().saturating_sub(1) {
20            state.cursor_line += 1;
21            Self::ensure_column_bounds(state);
22        }
23        Vec::new()
24    }
25
26    /// Move cursor left
27    pub fn move_left(state: &mut SourcePanelState) -> Vec<Action> {
28        if state.cursor_col > 0 {
29            state.cursor_col -= 1;
30        } else if state.cursor_line > 0 {
31            // Move to end of previous line (last character, not newline position)
32            state.cursor_line -= 1;
33            if let Some(prev_line_content) = state.content.get(state.cursor_line) {
34                // Jump to the last character of the previous line, not the newline position
35                state.cursor_col = if prev_line_content.is_empty() {
36                    0
37                } else {
38                    prev_line_content.chars().count().saturating_sub(1)
39                };
40            }
41        }
42        Self::ensure_column_bounds(state);
43        Vec::new()
44    }
45
46    /// Move cursor right
47    pub fn move_right(state: &mut SourcePanelState) -> Vec<Action> {
48        if let Some(current_line_content) = state.content.get(state.cursor_line) {
49            let max_column = if current_line_content.is_empty() {
50                0
51            } else {
52                current_line_content.chars().count().saturating_sub(1)
53            };
54
55            if state.cursor_col < max_column {
56                state.cursor_col += 1;
57            } else if state.cursor_line < state.content.len().saturating_sub(1) {
58                // Move to beginning of next line
59                state.cursor_line += 1;
60                state.cursor_col = 0;
61            }
62        }
63        Self::ensure_column_bounds(state);
64        Vec::new()
65    }
66
67    /// Fast move up (Page Up)
68    pub fn move_up_fast(state: &mut SourcePanelState) -> Vec<Action> {
69        // Note: page_size should use actual panel height, but for now use a conservative estimate
70        let page_size = 10; // Conservative page size for compatibility
71        state.cursor_line = state.cursor_line.saturating_sub(page_size);
72        Self::ensure_column_bounds(state);
73        Vec::new()
74    }
75
76    /// Fast move down (Page Down)
77    pub fn move_down_fast(state: &mut SourcePanelState) -> Vec<Action> {
78        // Note: page_size should use actual panel height, but for now use a conservative estimate
79        let page_size = 10; // Conservative page size for compatibility
80        state.cursor_line =
81            (state.cursor_line + page_size).min(state.content.len().saturating_sub(1));
82        Self::ensure_column_bounds(state);
83        Vec::new()
84    }
85
86    /// Half page up (Ctrl+U) - move up 10 lines
87    pub fn move_half_page_up(state: &mut SourcePanelState) -> Vec<Action> {
88        state.cursor_line = state.cursor_line.saturating_sub(10);
89        Self::ensure_column_bounds(state);
90        Vec::new()
91    }
92
93    /// Half page down (Ctrl+D) - move down 10 lines
94    pub fn move_half_page_down(state: &mut SourcePanelState) -> Vec<Action> {
95        state.cursor_line = (state.cursor_line + 10).min(state.content.len().saturating_sub(1));
96        Self::ensure_column_bounds(state);
97        Vec::new()
98    }
99
100    /// Move to top of file
101    pub fn move_to_top(state: &mut SourcePanelState) -> Vec<Action> {
102        state.cursor_line = 0;
103        state.cursor_col = 0;
104        state.scroll_offset = 0;
105        state.horizontal_scroll_offset = 0;
106        Vec::new()
107    }
108
109    /// Move to bottom of file
110    pub fn move_to_bottom(state: &mut SourcePanelState) -> Vec<Action> {
111        state.cursor_line = state.content.len().saturating_sub(1);
112        state.cursor_col = 0;
113        Vec::new()
114    }
115
116    /// Move to next word (w key) - vim-style word movement
117    pub fn move_word_forward(state: &mut SourcePanelState) -> Vec<Action> {
118        if let Some(current_line) = state.content.get(state.cursor_line) {
119            let chars: Vec<char> = current_line.chars().collect();
120            let mut pos = state.cursor_col;
121
122            if pos >= chars.len() {
123                // At end of line, go to next line
124                if state.cursor_line < state.content.len().saturating_sub(1) {
125                    state.cursor_line += 1;
126                    state.cursor_col = 0;
127                    // Skip leading whitespace on next line
128                    if let Some(next_line) = state.content.get(state.cursor_line) {
129                        let next_chars: Vec<char> = next_line.chars().collect();
130                        let mut next_pos = 0;
131                        while next_pos < next_chars.len() && next_chars[next_pos].is_whitespace() {
132                            next_pos += 1;
133                        }
134                        state.cursor_col = next_pos;
135                    }
136                }
137            } else {
138                // Determine current character type
139                let current_char = chars[pos];
140
141                if current_char.is_whitespace() {
142                    // Skip whitespace to find next word
143                    while pos < chars.len() && chars[pos].is_whitespace() {
144                        pos += 1;
145                    }
146                } else if current_char.is_alphanumeric() || current_char == '_' {
147                    // Skip current word (alphanumeric)
148                    while pos < chars.len() && (chars[pos].is_alphanumeric() || chars[pos] == '_') {
149                        pos += 1;
150                    }
151                    // Skip following whitespace
152                    while pos < chars.len() && chars[pos].is_whitespace() {
153                        pos += 1;
154                    }
155                } else {
156                    // Skip current group of special characters
157                    while pos < chars.len()
158                        && !chars[pos].is_whitespace()
159                        && !chars[pos].is_alphanumeric()
160                        && chars[pos] != '_'
161                    {
162                        pos += 1;
163                    }
164                    // Skip following whitespace
165                    while pos < chars.len() && chars[pos].is_whitespace() {
166                        pos += 1;
167                    }
168                }
169
170                // If we reached end of line, go to next line
171                if pos >= chars.len() {
172                    if state.cursor_line < state.content.len().saturating_sub(1) {
173                        state.cursor_line += 1;
174                        state.cursor_col = 0;
175                        // Skip leading whitespace on next line
176                        if let Some(next_line) = state.content.get(state.cursor_line) {
177                            let next_chars: Vec<char> = next_line.chars().collect();
178                            let mut next_pos = 0;
179                            while next_pos < next_chars.len()
180                                && next_chars[next_pos].is_whitespace()
181                            {
182                                next_pos += 1;
183                            }
184                            state.cursor_col = next_pos;
185                        }
186                    } else {
187                        // Stay at end of last line
188                        state.cursor_col = chars.len().saturating_sub(1).max(0);
189                    }
190                } else {
191                    state.cursor_col = pos;
192                }
193            }
194        }
195
196        Self::ensure_column_bounds(state);
197        Vec::new()
198    }
199
200    /// Move to previous word (b key) - vim-style word movement
201    pub fn move_word_backward(state: &mut SourcePanelState) -> Vec<Action> {
202        if state.cursor_col == 0 {
203            // If at beginning of line, go to end of previous line
204            if state.cursor_line > 0 {
205                state.cursor_line -= 1;
206                if let Some(prev_line) = state.content.get(state.cursor_line) {
207                    if prev_line.is_empty() {
208                        state.cursor_col = 0;
209                    } else {
210                        // Find the beginning of the last word on previous line
211                        let chars: Vec<char> = prev_line.chars().collect();
212                        let mut pos = chars.len().saturating_sub(1);
213
214                        // Skip trailing whitespace
215                        while pos > 0 && chars[pos].is_whitespace() {
216                            pos = pos.saturating_sub(1);
217                        }
218
219                        // Move to beginning of last word
220                        if chars[pos].is_alphanumeric() || chars[pos] == '_' {
221                            while pos > 0
222                                && (chars[pos.saturating_sub(1)].is_alphanumeric()
223                                    || chars[pos.saturating_sub(1)] == '_')
224                            {
225                                pos = pos.saturating_sub(1);
226                            }
227                        } else {
228                            // Special characters
229                            while pos > 0
230                                && !chars[pos.saturating_sub(1)].is_whitespace()
231                                && !chars[pos.saturating_sub(1)].is_alphanumeric()
232                                && chars[pos.saturating_sub(1)] != '_'
233                            {
234                                pos = pos.saturating_sub(1);
235                            }
236                        }
237
238                        state.cursor_col = pos;
239                    }
240                }
241            }
242        } else if let Some(current_line) = state.content.get(state.cursor_line) {
243            let chars: Vec<char> = current_line.chars().collect();
244            let mut pos = state.cursor_col;
245
246            // Move to previous character first
247            pos = pos.saturating_sub(1);
248
249            // Skip whitespace backwards
250            while pos > 0 && chars[pos].is_whitespace() {
251                pos = pos.saturating_sub(1);
252            }
253
254            // Check what type of character we're on
255            if pos < chars.len() {
256                if chars[pos].is_alphanumeric() || chars[pos] == '_' {
257                    // Skip word backwards (alphanumeric)
258                    while pos > 0
259                        && (chars[pos.saturating_sub(1)].is_alphanumeric()
260                            || chars[pos.saturating_sub(1)] == '_')
261                    {
262                        pos = pos.saturating_sub(1);
263                    }
264                } else if !chars[pos].is_whitespace() {
265                    // Skip special characters backwards
266                    while pos > 0
267                        && !chars[pos.saturating_sub(1)].is_whitespace()
268                        && !chars[pos.saturating_sub(1)].is_alphanumeric()
269                        && chars[pos.saturating_sub(1)] != '_'
270                    {
271                        pos = pos.saturating_sub(1);
272                    }
273                }
274            }
275
276            state.cursor_col = pos;
277        }
278
279        Self::ensure_column_bounds(state);
280        Vec::new()
281    }
282
283    /// Move to line start (^ key) - beginning of line
284    pub fn move_to_line_start(state: &mut SourcePanelState) -> Vec<Action> {
285        // Move to absolute beginning of line (position 0)
286        state.cursor_col = 0;
287
288        Self::ensure_column_bounds(state);
289        Vec::new()
290    }
291
292    /// Move to line end ($ key)
293    pub fn move_to_line_end(state: &mut SourcePanelState) -> Vec<Action> {
294        if let Some(current_line) = state.content.get(state.cursor_line) {
295            if current_line.is_empty() {
296                state.cursor_col = 0;
297            } else {
298                state.cursor_col = current_line.chars().count().saturating_sub(1);
299            }
300        } else {
301            state.cursor_col = 0;
302        }
303
304        Self::ensure_column_bounds(state);
305        Vec::new()
306    }
307
308    /// Jump to specific line
309    pub fn jump_to_line(state: &mut SourcePanelState, line_number: usize) -> Vec<Action> {
310        if line_number > 0 && line_number <= state.content.len() {
311            state.cursor_line = line_number - 1; // Convert to 0-based
312            state.cursor_col = 0;
313        }
314        Vec::new()
315    }
316
317    /// Go to specific line (alias for jump_to_line to match Action handler)
318    pub fn go_to_line(state: &mut SourcePanelState, line_number: usize) -> Vec<Action> {
319        Self::jump_to_line(state, line_number)
320    }
321
322    /// Handle number input for line jumping
323    pub fn handle_number_input(state: &mut SourcePanelState, ch: char) -> Vec<Action> {
324        if ch.is_ascii_digit() {
325            state.number_buffer.push(ch);
326        }
327        Vec::new()
328    }
329
330    /// Handle 'g' key for navigation
331    pub fn handle_g_key(state: &mut SourcePanelState) -> Vec<Action> {
332        if state.g_pressed {
333            // Second 'g' - go to top
334            state.g_pressed = false;
335            state.number_buffer.clear();
336            Self::move_to_top(state)
337        } else {
338            state.g_pressed = true;
339            Vec::new()
340        }
341    }
342
343    /// Handle 'G' key for navigation
344    pub fn handle_shift_g_key(state: &mut SourcePanelState) -> Vec<Action> {
345        if state.number_buffer.is_empty() {
346            // Go to bottom
347            Self::move_to_bottom(state)
348        } else {
349            // Jump to line number
350            if let Ok(line_num) = state.number_buffer.parse::<usize>() {
351                let result = Self::jump_to_line(state, line_num);
352                state.number_buffer.clear();
353                state.g_pressed = false;
354                result
355            } else {
356                state.number_buffer.clear();
357                state.g_pressed = false;
358                Vec::new()
359            }
360        }
361    }
362
363    /// Load source file
364    pub fn load_source(
365        state: &mut SourcePanelState,
366        file_path: String,
367        highlight_line: Option<usize>,
368    ) -> Vec<Action> {
369        tracing::info!("wtf {file_path}");
370        match std::fs::read_to_string(&file_path) {
371            Ok(content) => {
372                let lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
373                state.file_path = Some(file_path.clone());
374                state.content = if lines.is_empty() {
375                    vec!["// Empty file".to_string()]
376                } else {
377                    lines
378                };
379
380                // Detect language based on file extension
381                state.language = Self::detect_language(&file_path);
382
383                // Set cursor to highlight line or start at top
384                if let Some(line) = highlight_line {
385                    state.cursor_line = line.saturating_sub(1); // Convert to 0-based
386                                                                // Center the view around the current line
387                                                                // Note: should use actual panel height, but for now use conservative estimate
388                    let estimated_half_height = 15; // Conservative estimate
389                    if state.cursor_line >= estimated_half_height {
390                        state.scroll_offset = state.cursor_line - estimated_half_height;
391                    } else {
392                        state.scroll_offset = 0;
393                    }
394                } else {
395                    state.cursor_line = 0;
396                    state.scroll_offset = 0;
397                }
398                state.cursor_col = 0;
399                state.horizontal_scroll_offset = 0;
400
401                // Clear search state
402                state.search_query.clear();
403                state.search_matches.clear();
404                state.current_match = None;
405                state.mode = SourcePanelMode::Normal;
406            }
407            Err(e) => {
408                // Show error if file cannot be read with helpful srcpath suggestion
409                let error_kind = if e.kind() == std::io::ErrorKind::NotFound {
410                    "File not found"
411                } else {
412                    "Cannot read file"
413                };
414
415                // Extract DWARF directory for better error suggestion
416                let (dwarf_dir, _, _) = Self::split_dwarf_path(&file_path);
417
418                let error_message = format!(
419                    "{error_kind}: {file_path}\n\
420                    Error: {e}\n\n\
421                    💡 Possible solution:\n\n\
422                    If source was compiled on a different machine or moved,\n\
423                    configure path mapping with 'srcpath' command:\n\n\
424                      srcpath map {dwarf_dir} /your/local/path\n\
425                      srcpath add /additional/search/directory\n\n\
426                    This will map the DWARF compilation directory to your local path,\n\
427                    and all files under it will be resolved correctly.\n\n\
428                    Type 'help srcpath' for more information.\n\n\
429                    📘 No source available? You can hide the Source panel:\n\
430                      ui source off            # in UI command mode\n\
431                      --no-source-panel        # CLI flag\n\
432                      [ui].show_source_panel=false  # in config.toml"
433                );
434
435                Self::show_error(state, &file_path, error_message);
436            }
437        }
438        Vec::new()
439    }
440
441    /// Clear source content
442    pub fn clear_source(state: &mut SourcePanelState) -> Vec<Action> {
443        state.content = vec!["// No source code loaded".to_string()];
444        state.file_path = None;
445        state.cursor_line = 0;
446        state.cursor_col = 0;
447        state.scroll_offset = 0;
448        state.horizontal_scroll_offset = 0;
449        state.search_query.clear();
450        state.search_matches.clear();
451        state.current_match = None;
452        state.mode = SourcePanelMode::Normal;
453        Vec::new()
454    }
455
456    /// Clear all transient state (ESC behavior)
457    pub fn clear_all_state(state: &mut SourcePanelState) -> Vec<Action> {
458        // Clear search state
459        state.search_query.clear();
460        state.search_matches.clear();
461        state.current_match = None;
462
463        // Clear navigation state
464        state.number_buffer.clear();
465        state.expecting_g = false;
466        state.g_pressed = false;
467
468        // Return to normal mode
469        state.mode = SourcePanelMode::Normal;
470
471        Vec::new()
472    }
473
474    /// Split file path into DWARF directory and relative path
475    /// Strategy: Find common source directory markers (src, include, lib, etc.)
476    /// and split the path there to show meaningful DWARF directory
477    /// Split DWARF path into directory, relative path, and basename
478    /// Handles invalid paths gracefully
479    fn split_dwarf_path(file_path: &str) -> (String, String, String) {
480        use std::path::Path;
481
482        // Handle empty or invalid paths
483        if file_path.is_empty() {
484            return (
485                "<unknown>".to_string(),
486                "<unknown>".to_string(),
487                "<unknown>".to_string(),
488            );
489        }
490
491        let path = Path::new(file_path);
492        let basename = path
493            .file_name()
494            .and_then(|n| n.to_str())
495            .unwrap_or("unknown")
496            .to_string();
497
498        // Common source directory markers
499        let source_markers = ["src", "include", "lib", "source", "sources", "inc", "libs"];
500
501        // Try to find a source directory marker in the path
502        let components: Vec<_> = path.components().collect();
503
504        // Handle paths with no components (invalid paths)
505        if components.is_empty() {
506            return ("<unknown>".to_string(), file_path.to_string(), basename);
507        }
508
509        for (idx, component) in components.iter().enumerate() {
510            if let Some(comp_str) = component.as_os_str().to_str() {
511                if source_markers.contains(&comp_str) {
512                    // Found a source marker, split here
513                    let dwarf_dir: std::path::PathBuf = components[..idx].iter().collect();
514                    let relative: std::path::PathBuf = components[idx..].iter().collect();
515
516                    return (
517                        dwarf_dir.to_string_lossy().to_string(),
518                        relative.to_string_lossy().to_string(),
519                        basename,
520                    );
521                }
522            }
523        }
524
525        // Fallback: use parent directory as DWARF dir
526        let dwarf_dir = path
527            .parent()
528            .map(|p| p.to_string_lossy().to_string())
529            .unwrap_or_else(|| "<unknown>".to_string());
530
531        (dwarf_dir, basename.clone(), basename)
532    }
533
534    /// Show error message in source panel
535    fn show_error(state: &mut SourcePanelState, file_path: &str, error_message: String) {
536        let (dwarf_dir, relative_path, basename) = Self::split_dwarf_path(file_path);
537
538        // Split error message by newlines and format each line with comment prefix
539        let mut content = vec![
540            "// Source code loading failed".to_string(),
541            "//".to_string(),
542            format!("// DWARF Directory: {}", dwarf_dir),
543            format!("// Relative Path: {}", relative_path),
544            format!("// Basename: {}", basename),
545            "//".to_string(),
546        ];
547
548        // Add error message lines (split by newlines)
549        for line in error_message.lines() {
550            if line.is_empty() {
551                content.push("//".to_string());
552            } else {
553                content.push(format!("// {line}"));
554            }
555        }
556
557        state.content = content;
558        state.file_path = Some(file_path.to_string());
559        state.cursor_line = 0;
560        state.cursor_col = 0;
561        state.scroll_offset = 0;
562        state.horizontal_scroll_offset = 0;
563    }
564
565    /// Public method to display error message in source panel (for initialization errors)
566    pub fn show_error_message(state: &mut SourcePanelState, error_message: String) {
567        Self::show_error(state, "(no file)", error_message);
568    }
569
570    /// Detect programming language from file extension
571    fn detect_language(file_path: &str) -> String {
572        if let Some(extension) = file_path.rsplit('.').next() {
573            match extension.to_lowercase().as_str() {
574                "c" => "c".to_string(),
575                "cpp" | "cc" | "cxx" | "hpp" | "hxx" => "cpp".to_string(),
576                "rs" => "rust".to_string(),
577                _ => "c".to_string(), // Default to C
578            }
579        } else {
580            "c".to_string() // Default to C
581        }
582    }
583
584    /// Ensure cursor is visible in the current view with vim-style scrolloff
585    pub fn ensure_cursor_visible(state: &mut SourcePanelState, panel_height: u16) {
586        let visible_lines = panel_height.saturating_sub(2) as usize; // Account for borders
587        let total_lines = state.content.len();
588
589        if visible_lines == 0 || total_lines == 0 {
590            return;
591        }
592
593        // Calculate dynamic scrolloff: 1/5 of visible lines, min 2, max 5
594        let vertical_scrolloff = (visible_lines / 5).clamp(2, 5);
595
596        // Calculate cursor position relative to current scroll
597        let cursor_in_view = state.cursor_line.saturating_sub(state.scroll_offset);
598
599        // Check if cursor is too close to top edge
600        if cursor_in_view < vertical_scrolloff && state.scroll_offset > 0 {
601            // Move scroll up to give cursor more space
602            state.scroll_offset = state.cursor_line.saturating_sub(vertical_scrolloff);
603        }
604        // Check if cursor is too close to bottom edge
605        else if cursor_in_view >= visible_lines.saturating_sub(vertical_scrolloff) {
606            // Move scroll down to give cursor more space
607            let target_pos = visible_lines.saturating_sub(vertical_scrolloff + 1);
608            state.scroll_offset = state.cursor_line.saturating_sub(target_pos);
609        }
610
611        // Handle edge cases and bounds checking
612        let max_scroll = total_lines.saturating_sub(visible_lines);
613        state.scroll_offset = state.scroll_offset.min(max_scroll);
614
615        // Special handling for beginning of file
616        if state.cursor_line < vertical_scrolloff {
617            state.scroll_offset = 0;
618        }
619
620        // Special handling for end of file - try to show as much content as possible
621        if state.cursor_line >= total_lines.saturating_sub(vertical_scrolloff)
622            && total_lines > visible_lines
623        {
624            // Near end of file, but still maintain some scrolloff if possible
625            let lines_after_cursor = total_lines.saturating_sub(state.cursor_line + 1);
626            if lines_after_cursor < vertical_scrolloff {
627                // At the very end, show as much as possible
628                state.scroll_offset = max_scroll;
629            }
630        }
631    }
632
633    /// Ensure cursor column is within line bounds (prevent cursor on newline)
634    fn ensure_column_bounds(state: &mut SourcePanelState) {
635        if let Some(current_line) = state.content.get(state.cursor_line) {
636            if current_line.is_empty() {
637                // Empty line, stay at column 0
638                state.cursor_col = 0;
639            } else {
640                // Ensure column is within bounds, but prefer last character over newline position
641                let max_column = current_line.chars().count().saturating_sub(1); // Last character position
642                if state.cursor_col > max_column {
643                    state.cursor_col = max_column;
644                }
645            }
646        }
647    }
648
649    /// Ensure horizontal cursor is visible
650    pub fn ensure_horizontal_cursor_visible(state: &mut SourcePanelState, panel_width: u16) {
651        if let Some(current_line_content) = state.content.get(state.cursor_line) {
652            // Use the same calculation as renderer for consistency
653            const LINE_NUMBER_WIDTH: u16 = 5; // "1234 " format
654            const BORDER_WIDTH: u16 = 2; // left and right borders
655
656            let available_width =
657                (panel_width.saturating_sub(LINE_NUMBER_WIDTH + BORDER_WIDTH)) as usize;
658
659            if available_width == 0 {
660                return; // Avoid division by zero or invalid calculations
661            }
662
663            let line_char_count = current_line_content.chars().count();
664
665            // Apply vim-style scrolloff regardless of line length
666            let horizontal_scrolloff = (available_width / 4).clamp(3, 8); // Dynamic scrolloff, 3-8 chars
667
668            // Calculate cursor position relative to current scroll
669            let cursor_in_view = state
670                .cursor_col
671                .saturating_sub(state.horizontal_scroll_offset);
672
673            // Check if cursor is too close to left edge
674            if cursor_in_view < horizontal_scrolloff && state.cursor_col >= horizontal_scrolloff {
675                // Move scroll left to give cursor more space
676                state.horizontal_scroll_offset =
677                    state.cursor_col.saturating_sub(horizontal_scrolloff);
678            }
679            // Check if cursor is too close to right edge
680            else if cursor_in_view + horizontal_scrolloff >= available_width {
681                // Move scroll right to give cursor more space
682                let target_pos = available_width.saturating_sub(horizontal_scrolloff + 1);
683                state.horizontal_scroll_offset = state.cursor_col.saturating_sub(target_pos);
684            }
685
686            // Ensure we don't scroll beyond reasonable bounds if line is shorter
687            if line_char_count <= available_width && state.horizontal_scroll_offset > 0 {
688                // Line fits entirely but we have scrolled - only allow minimal scroll for short lines
689                let max_scroll_for_short_line =
690                    line_char_count.saturating_sub(available_width / 2).max(0);
691                state.horizontal_scroll_offset = state
692                    .horizontal_scroll_offset
693                    .min(max_scroll_for_short_line);
694            }
695
696            // Ensure we don't scroll before the beginning
697            if state.cursor_col < horizontal_scrolloff {
698                state.horizontal_scroll_offset = 0;
699            } else if state.horizontal_scroll_offset > state.cursor_col {
700                // If scroll went beyond cursor position, adjust
701                state.horizontal_scroll_offset =
702                    state.cursor_col.saturating_sub(horizontal_scrolloff);
703            }
704
705            // Final boundary check - ensure cursor is visible
706            if state.cursor_col < state.horizontal_scroll_offset {
707                state.horizontal_scroll_offset = state.cursor_col;
708            } else if state.cursor_col >= state.horizontal_scroll_offset + available_width {
709                state.horizontal_scroll_offset =
710                    state.cursor_col.saturating_sub(available_width - 1);
711            }
712        }
713    }
714}