Skip to main content

fresh/app/
types.rs

1use crate::app::file_open::SortMode;
2use crate::input::keybindings::Action;
3use crate::model::event::{BufferId, ContainerId, LeafId, SplitDirection};
4use crate::services::async_bridge::LspMessageType;
5use ratatui::layout::Rect;
6use rust_i18n::t;
7use std::collections::{HashMap, HashSet};
8use std::ops::Range;
9use std::path::{Path, PathBuf};
10
11pub const DEFAULT_BACKGROUND_FILE: &str = "scripts/landscape-wide.txt";
12
13/// Pre-calculated line information for an event
14/// Calculated BEFORE buffer modification so line numbers are accurate
15#[derive(Debug, Clone, Default)]
16pub(super) struct EventLineInfo {
17    /// Start line (0-indexed) where the change begins
18    pub start_line: usize,
19    /// End line (0-indexed) where the change ends (in original buffer for deletes)
20    pub end_line: usize,
21    /// Number of lines added (for inserts) or removed (for deletes)
22    pub line_delta: i32,
23}
24
25/// Search state for find/replace functionality
26#[derive(Debug, Clone)]
27pub(super) struct SearchState {
28    /// The search query
29    pub query: String,
30    /// All match positions in the buffer (byte offsets)
31    pub matches: Vec<usize>,
32    /// Match lengths parallel to `matches` (needed for viewport overlay creation)
33    pub match_lengths: Vec<usize>,
34    /// Index of the currently selected match
35    pub current_match_index: Option<usize>,
36    /// Whether search wraps around at document boundaries
37    pub wrap_search: bool,
38    /// Optional search range (for search in selection)
39    pub search_range: Option<Range<usize>>,
40    /// True if the match count was capped at MAX_MATCHES
41    #[allow(dead_code)]
42    pub capped: bool,
43}
44
45impl SearchState {
46    /// Maximum number of search matches to collect before stopping.
47    /// Prevents unbounded memory usage when searching for common patterns
48    /// in large files.
49    pub const MAX_MATCHES: usize = 100_000;
50}
51
52/// A bookmark in the editor (position in a specific buffer)
53#[derive(Debug, Clone)]
54pub(super) struct Bookmark {
55    /// Buffer ID where the bookmark is set
56    pub buffer_id: BufferId,
57    /// Byte offset position in the buffer
58    pub position: usize,
59}
60
61/// State for interactive replace (query-replace)
62#[derive(Debug, Clone)]
63pub(super) struct InteractiveReplaceState {
64    /// The search pattern
65    pub search: String,
66    /// The replacement text
67    pub replacement: String,
68    /// Current match position (byte offset of the match we're at)
69    pub current_match_pos: usize,
70    /// Length of the current match in bytes (may differ from search.len() for regex)
71    pub current_match_len: usize,
72    /// Starting position (to detect when we've wrapped around full circle)
73    pub start_pos: usize,
74    /// Whether we've wrapped around to the beginning
75    pub has_wrapped: bool,
76    /// Number of replacements made so far
77    pub replacements_made: usize,
78    /// Compiled regex for regex-mode replace (None when regex mode is off)
79    pub regex: Option<regex::bytes::Regex>,
80}
81
82/// The kind of buffer (file-backed or virtual)
83#[derive(Debug, Clone, PartialEq)]
84pub enum BufferKind {
85    /// A buffer backed by a file on disk
86    File {
87        /// Path to the file
88        path: PathBuf,
89        /// LSP URI for the file
90        uri: Option<lsp_types::Uri>,
91    },
92    /// A virtual buffer (not backed by a file)
93    /// Used for special buffers like *Diagnostics*, *Grep*, etc.
94    Virtual {
95        /// The buffer's mode (e.g., "diagnostics-list", "grep-results")
96        mode: String,
97    },
98}
99
100/// Metadata associated with a buffer
101#[derive(Debug, Clone)]
102pub struct BufferMetadata {
103    /// The kind of buffer (file or virtual)
104    pub kind: BufferKind,
105
106    /// Display name for the buffer (project-relative path or filename or *BufferName*)
107    pub display_name: String,
108
109    /// Whether LSP is enabled for this buffer (always false for virtual buffers)
110    pub lsp_enabled: bool,
111
112    /// Reason LSP is disabled (if applicable)
113    pub lsp_disabled_reason: Option<String>,
114
115    /// Whether the buffer is read-only (typically true for virtual buffers)
116    pub read_only: bool,
117
118    /// Whether the buffer contains binary content
119    /// Binary buffers are automatically read-only and render unprintable chars as code points
120    pub binary: bool,
121
122    /// LSP server instance IDs that have received didOpen for this buffer.
123    /// Used to ensure didOpen is sent before any requests to a new/restarted server.
124    /// When a server restarts, it gets a new ID, so didOpen is automatically resent.
125    /// Old IDs are harmless - they just remain in the set but don't match any active server.
126    pub lsp_opened_with: HashSet<u64>,
127
128    /// Whether this buffer should be hidden from tabs (used for composite source buffers)
129    pub hidden_from_tabs: bool,
130
131    /// Stable recovery ID for unnamed buffers.
132    /// For file-backed buffers, recovery ID is computed from the path hash.
133    /// For unnamed buffers, this is generated once and reused across auto-saves.
134    pub recovery_id: Option<String>,
135}
136
137impl BufferMetadata {
138    /// Get the file path if this is a file-backed buffer
139    pub fn file_path(&self) -> Option<&PathBuf> {
140        match &self.kind {
141            BufferKind::File { path, .. } => Some(path),
142            BufferKind::Virtual { .. } => None,
143        }
144    }
145
146    /// Get the file URI if this is a file-backed buffer
147    pub fn file_uri(&self) -> Option<&lsp_types::Uri> {
148        match &self.kind {
149            BufferKind::File { uri, .. } => uri.as_ref(),
150            BufferKind::Virtual { .. } => None,
151        }
152    }
153
154    /// Check if this is a virtual buffer
155    pub fn is_virtual(&self) -> bool {
156        matches!(self.kind, BufferKind::Virtual { .. })
157    }
158
159    /// Get the mode name for virtual buffers
160    pub fn virtual_mode(&self) -> Option<&str> {
161        match &self.kind {
162            BufferKind::Virtual { mode } => Some(mode),
163            BufferKind::File { .. } => None,
164        }
165    }
166}
167
168impl Default for BufferMetadata {
169    fn default() -> Self {
170        Self::new()
171    }
172}
173
174impl BufferMetadata {
175    /// Create new metadata for a buffer (unnamed, file-backed)
176    pub fn new() -> Self {
177        Self {
178            kind: BufferKind::File {
179                path: PathBuf::new(),
180                uri: None,
181            },
182            display_name: t!("buffer.no_name").to_string(),
183            lsp_enabled: true,
184            lsp_disabled_reason: None,
185            read_only: false,
186            binary: false,
187            lsp_opened_with: HashSet::new(),
188            hidden_from_tabs: false,
189            recovery_id: None,
190        }
191    }
192
193    /// Create new metadata for an unnamed buffer with a custom display name
194    /// Used for buffers created from stdin or other non-file sources
195    pub fn new_unnamed(display_name: String) -> Self {
196        Self {
197            kind: BufferKind::File {
198                path: PathBuf::new(),
199                uri: None,
200            },
201            display_name,
202            lsp_enabled: false, // No file path, so no LSP
203            lsp_disabled_reason: Some(t!("lsp.disabled.unnamed").to_string()),
204            read_only: false,
205            binary: false,
206            lsp_opened_with: HashSet::new(),
207            hidden_from_tabs: false,
208            recovery_id: None,
209        }
210    }
211
212    /// Create metadata for a file-backed buffer
213    ///
214    /// # Arguments
215    /// * `path` - The canonical absolute path to the file
216    /// * `working_dir` - The canonical working directory for computing relative display name
217    pub fn with_file(path: PathBuf, working_dir: &Path) -> Self {
218        // Compute URI from the absolute path
219        let file_uri = file_path_to_lsp_uri(&path);
220
221        // Compute display name (project-relative when under working_dir, else absolute path).
222        // Use canonicalized forms first to handle macOS /var -> /private/var differences.
223        let display_name = Self::display_name_for_path(&path, working_dir);
224
225        // Check if this is a library file (in vendor directories or standard libraries)
226        let is_library = Self::is_library_path(&path, working_dir);
227        let (lsp_enabled, lsp_disabled_reason) = if is_library {
228            (false, Some(t!("lsp.disabled.library_file").to_string()))
229        } else {
230            (true, None)
231        };
232
233        Self {
234            kind: BufferKind::File {
235                path,
236                uri: file_uri,
237            },
238            display_name,
239            lsp_enabled,
240            lsp_disabled_reason,
241            read_only: is_library,
242            binary: false,
243            lsp_opened_with: HashSet::new(),
244            hidden_from_tabs: false,
245            recovery_id: None,
246        }
247    }
248
249    /// Check if a path is a library file (in vendor directories or standard libraries)
250    ///
251    /// Library files include:
252    /// - Files in common vendor/dependency directories (.cargo, node_modules, etc.)
253    /// - Standard library / toolchain files (rustup toolchains, system includes, etc.)
254    pub fn is_library_path(path: &Path, _working_dir: &Path) -> bool {
255        // Check for common library paths
256        let path_str = path.to_string_lossy();
257
258        // Rust: .cargo directory (can be within project for vendor'd crates)
259        if path_str.contains("/.cargo/") || path_str.contains("\\.cargo\\") {
260            return true;
261        }
262
263        // Rust: rustup toolchains (standard library source files)
264        if path_str.contains("/rustup/toolchains/") || path_str.contains("\\rustup\\toolchains\\") {
265            return true;
266        }
267
268        // Node.js: node_modules
269        if path_str.contains("/node_modules/") || path_str.contains("\\node_modules\\") {
270            return true;
271        }
272
273        // Python: site-packages, dist-packages
274        if path_str.contains("/site-packages/")
275            || path_str.contains("\\site-packages\\")
276            || path_str.contains("/dist-packages/")
277            || path_str.contains("\\dist-packages\\")
278        {
279            return true;
280        }
281
282        // Go: pkg/mod
283        if path_str.contains("/pkg/mod/") || path_str.contains("\\pkg\\mod\\") {
284            return true;
285        }
286
287        // Ruby: gems
288        if path_str.contains("/gems/") || path_str.contains("\\gems\\") {
289            return true;
290        }
291
292        // Java/Gradle: .gradle
293        if path_str.contains("/.gradle/") || path_str.contains("\\.gradle\\") {
294            return true;
295        }
296
297        // Maven: .m2
298        if path_str.contains("/.m2/") || path_str.contains("\\.m2\\") {
299            return true;
300        }
301
302        // C/C++: system include directories
303        if path_str.starts_with("/usr/include/") || path_str.starts_with("/usr/local/include/") {
304            return true;
305        }
306
307        // Nix store (system-managed packages)
308        if path_str.starts_with("/nix/store/") {
309            return true;
310        }
311
312        // Homebrew (macOS system-managed packages)
313        if path_str.starts_with("/opt/homebrew/Cellar/")
314            || path_str.starts_with("/usr/local/Cellar/")
315        {
316            return true;
317        }
318
319        // .NET / C#: NuGet packages
320        if path_str.contains("/.nuget/") || path_str.contains("\\.nuget\\") {
321            return true;
322        }
323
324        // Swift / Xcode toolchains
325        if path_str.contains("/Xcode.app/Contents/Developer/")
326            || path_str.contains("/CommandLineTools/SDKs/")
327        {
328            return true;
329        }
330
331        false
332    }
333
334    /// Compute display name relative to working_dir when possible, otherwise absolute
335    pub fn display_name_for_path(path: &Path, working_dir: &Path) -> String {
336        // Canonicalize working_dir to normalize platform-specific prefixes
337        let canonical_working_dir = working_dir
338            .canonicalize()
339            .unwrap_or_else(|_| working_dir.to_path_buf());
340
341        // Try to canonicalize the file path; if it fails (e.g., new file), fall back to absolute
342        let absolute_path = if path.is_absolute() {
343            path.to_path_buf()
344        } else {
345            // If we were given a relative path, anchor it to working_dir
346            canonical_working_dir.join(path)
347        };
348        let canonical_path = absolute_path
349            .canonicalize()
350            .unwrap_or_else(|_| absolute_path.clone());
351
352        // Prefer canonical comparison first, then raw prefix as a fallback
353        let relative = canonical_path
354            .strip_prefix(&canonical_working_dir)
355            .or_else(|_| path.strip_prefix(working_dir))
356            .ok()
357            .and_then(|rel| rel.to_str().map(|s| s.to_string()));
358
359        relative
360            .or_else(|| canonical_path.to_str().map(|s| s.to_string()))
361            .unwrap_or_else(|| t!("buffer.unknown").to_string())
362    }
363
364    /// Create metadata for a virtual buffer (not backed by a file)
365    ///
366    /// # Arguments
367    /// * `name` - Display name (e.g., "*Diagnostics*")
368    /// * `mode` - Buffer mode for keybindings (e.g., "diagnostics-list")
369    /// * `read_only` - Whether the buffer should be read-only
370    pub fn virtual_buffer(name: String, mode: String, read_only: bool) -> Self {
371        Self {
372            kind: BufferKind::Virtual { mode },
373            display_name: name,
374            lsp_enabled: false, // Virtual buffers don't use LSP
375            lsp_disabled_reason: Some(t!("lsp.disabled.virtual").to_string()),
376            read_only,
377            binary: false,
378            lsp_opened_with: HashSet::new(),
379            hidden_from_tabs: false,
380            recovery_id: None,
381        }
382    }
383
384    /// Create metadata for a hidden virtual buffer (for composite source buffers)
385    /// These buffers are not shown in tabs and are managed by their parent composite buffer.
386    /// Hidden buffers are always read-only to prevent accidental edits.
387    pub fn hidden_virtual_buffer(name: String, mode: String) -> Self {
388        Self {
389            kind: BufferKind::Virtual { mode },
390            display_name: name,
391            lsp_enabled: false,
392            lsp_disabled_reason: Some(t!("lsp.disabled.virtual").to_string()),
393            read_only: true, // Hidden buffers are always read-only
394            binary: false,
395            lsp_opened_with: HashSet::new(),
396            hidden_from_tabs: true,
397            recovery_id: None,
398        }
399    }
400
401    /// Disable LSP for this buffer with a reason
402    pub fn disable_lsp(&mut self, reason: String) {
403        self.lsp_enabled = false;
404        self.lsp_disabled_reason = Some(reason);
405    }
406}
407
408/// State for macro recording
409#[derive(Debug, Clone)]
410pub(super) struct MacroRecordingState {
411    /// The register key for this macro
412    pub key: char,
413    /// Actions recorded so far
414    pub actions: Vec<Action>,
415}
416
417/// LSP progress information
418#[derive(Debug, Clone)]
419pub(super) struct LspProgressInfo {
420    pub language: String,
421    pub title: String,
422    pub message: Option<String>,
423    pub percentage: Option<u32>,
424}
425
426/// LSP message entry (for window messages and logs)
427#[derive(Debug, Clone)]
428#[allow(dead_code)]
429pub(super) struct LspMessageEntry {
430    pub language: String,
431    pub message_type: LspMessageType,
432    pub message: String,
433    pub timestamp: std::time::Instant,
434}
435
436/// Types of UI elements that can be hovered over
437#[derive(Debug, Clone, PartialEq)]
438pub enum HoverTarget {
439    /// Hovering over a split separator (container_id, direction)
440    SplitSeparator(ContainerId, SplitDirection),
441    /// Hovering over a scrollbar thumb (split_id)
442    ScrollbarThumb(LeafId),
443    /// Hovering over a scrollbar track (split_id)
444    ScrollbarTrack(LeafId),
445    /// Hovering over a menu bar item (menu_index)
446    MenuBarItem(usize),
447    /// Hovering over a menu dropdown item (menu_index, item_index)
448    MenuDropdownItem(usize, usize),
449    /// Hovering over a submenu item (depth, item_index) - depth 1+ for nested submenus
450    SubmenuItem(usize, usize),
451    /// Hovering over a popup list item (popup_index in stack, item_index)
452    PopupListItem(usize, usize),
453    /// Hovering over a suggestion item (item_index)
454    SuggestionItem(usize),
455    /// Hovering over the file explorer border (for resize)
456    FileExplorerBorder,
457    /// Hovering over a file browser navigation shortcut
458    FileBrowserNavShortcut(usize),
459    /// Hovering over a file browser file/directory entry
460    FileBrowserEntry(usize),
461    /// Hovering over a file browser column header
462    FileBrowserHeader(SortMode),
463    /// Hovering over the file browser scrollbar
464    FileBrowserScrollbar,
465    /// Hovering over the file browser "Show Hidden" checkbox
466    FileBrowserShowHiddenCheckbox,
467    /// Hovering over the file browser "Detect Encoding" checkbox
468    FileBrowserDetectEncodingCheckbox,
469    /// Hovering over a tab name (buffer_id, split_id) - for non-active tabs
470    TabName(BufferId, LeafId),
471    /// Hovering over a tab close button (buffer_id, split_id)
472    TabCloseButton(BufferId, LeafId),
473    /// Hovering over a close split button (split_id)
474    CloseSplitButton(LeafId),
475    /// Hovering over a maximize/unmaximize split button (split_id)
476    MaximizeSplitButton(LeafId),
477    /// Hovering over the file explorer close button
478    FileExplorerCloseButton,
479    /// Hovering over a file explorer item's status indicator (path)
480    FileExplorerStatusIndicator(std::path::PathBuf),
481    /// Hovering over the status bar LSP indicator
482    StatusBarLspIndicator,
483    /// Hovering over the status bar warning badge
484    StatusBarWarningBadge,
485    /// Hovering over the status bar line ending indicator
486    StatusBarLineEndingIndicator,
487    /// Hovering over the status bar encoding indicator
488    StatusBarEncodingIndicator,
489    /// Hovering over the status bar language indicator
490    StatusBarLanguageIndicator,
491    /// Hovering over the search options "Case Sensitive" checkbox
492    SearchOptionCaseSensitive,
493    /// Hovering over the search options "Whole Word" checkbox
494    SearchOptionWholeWord,
495    /// Hovering over the search options "Regex" checkbox
496    SearchOptionRegex,
497    /// Hovering over the search options "Confirm Each" checkbox
498    SearchOptionConfirmEach,
499    /// Hovering over a tab context menu item (item_index)
500    TabContextMenuItem(usize),
501}
502
503/// Tab context menu items
504#[derive(Debug, Clone, Copy, PartialEq, Eq)]
505pub enum TabContextMenuItem {
506    /// Close this tab
507    Close,
508    /// Close all other tabs
509    CloseOthers,
510    /// Close tabs to the right
511    CloseToRight,
512    /// Close tabs to the left
513    CloseToLeft,
514    /// Close all tabs
515    CloseAll,
516}
517
518impl TabContextMenuItem {
519    /// Get all menu items in order
520    pub fn all() -> &'static [Self] {
521        &[
522            Self::Close,
523            Self::CloseOthers,
524            Self::CloseToRight,
525            Self::CloseToLeft,
526            Self::CloseAll,
527        ]
528    }
529
530    /// Get the display label for this menu item
531    pub fn label(&self) -> String {
532        match self {
533            Self::Close => t!("tab.close").to_string(),
534            Self::CloseOthers => t!("tab.close_others").to_string(),
535            Self::CloseToRight => t!("tab.close_to_right").to_string(),
536            Self::CloseToLeft => t!("tab.close_to_left").to_string(),
537            Self::CloseAll => t!("tab.close_all").to_string(),
538        }
539    }
540}
541
542/// State for tab context menu (right-click popup on tabs)
543#[derive(Debug, Clone)]
544pub struct TabContextMenu {
545    /// The buffer ID this context menu is for
546    pub buffer_id: BufferId,
547    /// The split ID where the tab is located
548    pub split_id: LeafId,
549    /// Screen position where the menu should appear (x, y)
550    pub position: (u16, u16),
551    /// Currently highlighted menu item index
552    pub highlighted: usize,
553}
554
555impl TabContextMenu {
556    /// Create a new tab context menu
557    pub fn new(buffer_id: BufferId, split_id: LeafId, x: u16, y: u16) -> Self {
558        Self {
559            buffer_id,
560            split_id,
561            position: (x, y),
562            highlighted: 0,
563        }
564    }
565
566    /// Get the currently highlighted item
567    pub fn highlighted_item(&self) -> TabContextMenuItem {
568        TabContextMenuItem::all()[self.highlighted]
569    }
570
571    /// Move highlight down
572    pub fn next_item(&mut self) {
573        let items = TabContextMenuItem::all();
574        self.highlighted = (self.highlighted + 1) % items.len();
575    }
576
577    /// Move highlight up
578    pub fn prev_item(&mut self) {
579        let items = TabContextMenuItem::all();
580        self.highlighted = if self.highlighted == 0 {
581            items.len() - 1
582        } else {
583            self.highlighted - 1
584        };
585    }
586}
587
588/// Information about which theme key(s) style a specific screen position.
589/// Used by the Ctrl+Right-Click theme inspector popup.
590#[derive(Debug, Clone)]
591pub struct ThemeKeyInfo {
592    /// The foreground theme key path (e.g., "syntax.keyword", "editor.fg")
593    pub fg_key: Option<String>,
594    /// The background theme key path (e.g., "editor.bg", "editor.selection_bg")
595    pub bg_key: Option<String>,
596    /// Human-readable description of the UI region
597    pub region: String,
598    /// The actual foreground color value currently applied
599    pub fg_color: Option<ratatui::style::Color>,
600    /// The actual background color value currently applied
601    pub bg_color: Option<ratatui::style::Color>,
602    /// For syntax highlights: the HighlightCategory display name
603    pub syntax_category: Option<String>,
604}
605
606/// State for the theme inspector popup (Ctrl+Right-Click)
607#[derive(Debug, Clone)]
608pub struct ThemeInfoPopup {
609    /// Screen position where popup appears (x, y)
610    pub position: (u16, u16),
611    /// Resolved theme key information
612    pub info: ThemeKeyInfo,
613    /// Whether the "Open in Theme Editor" button is highlighted (mouse hover)
614    pub button_highlighted: bool,
615}
616
617/// Drop zone for tab drag-and-drop
618/// Indicates where a dragged tab will be placed when released
619#[derive(Debug, Clone, Copy, PartialEq, Eq)]
620pub enum TabDropZone {
621    /// Drop into an existing split's tab bar (before tab at index, or at end if None)
622    /// (target_split_id, insert_index)
623    TabBar(LeafId, Option<usize>),
624    /// Create a new split on the left edge of the target split
625    SplitLeft(LeafId),
626    /// Create a new split on the right edge of the target split
627    SplitRight(LeafId),
628    /// Create a new split on the top edge of the target split
629    SplitTop(LeafId),
630    /// Create a new split on the bottom edge of the target split
631    SplitBottom(LeafId),
632    /// Drop into the center of a split (switch to that split's tab bar)
633    SplitCenter(LeafId),
634}
635
636impl TabDropZone {
637    /// Get the split ID this drop zone is associated with
638    pub fn split_id(&self) -> LeafId {
639        match self {
640            Self::TabBar(id, _)
641            | Self::SplitLeft(id)
642            | Self::SplitRight(id)
643            | Self::SplitTop(id)
644            | Self::SplitBottom(id)
645            | Self::SplitCenter(id) => *id,
646        }
647    }
648}
649
650/// State for a tab being dragged
651#[derive(Debug, Clone)]
652pub struct TabDragState {
653    /// The buffer being dragged
654    pub buffer_id: BufferId,
655    /// The split the tab was dragged from
656    pub source_split_id: LeafId,
657    /// Starting mouse position when drag began
658    pub start_position: (u16, u16),
659    /// Current mouse position
660    pub current_position: (u16, u16),
661    /// Currently detected drop zone (if any)
662    pub drop_zone: Option<TabDropZone>,
663}
664
665impl TabDragState {
666    /// Create a new tab drag state
667    pub fn new(buffer_id: BufferId, source_split_id: LeafId, start_position: (u16, u16)) -> Self {
668        Self {
669            buffer_id,
670            source_split_id,
671            start_position,
672            current_position: start_position,
673            drop_zone: None,
674        }
675    }
676
677    /// Check if the drag has moved enough to be considered a real drag (not just a click)
678    pub fn is_dragging(&self) -> bool {
679        let dx = (self.current_position.0 as i32 - self.start_position.0 as i32).abs();
680        let dy = (self.current_position.1 as i32 - self.start_position.1 as i32).abs();
681        dx > 3 || dy > 3 // Threshold of 3 pixels before drag activates
682    }
683}
684
685/// Mouse state tracking
686#[derive(Debug, Clone, Default)]
687pub(super) struct MouseState {
688    /// Whether we're currently dragging a vertical scrollbar
689    pub dragging_scrollbar: Option<LeafId>,
690    /// Whether we're currently dragging a horizontal scrollbar
691    pub dragging_horizontal_scrollbar: Option<LeafId>,
692    /// Initial mouse column when starting horizontal scrollbar drag
693    pub drag_start_hcol: Option<u16>,
694    /// Initial left_column when starting horizontal scrollbar drag
695    pub drag_start_left_column: Option<usize>,
696    /// Last mouse position
697    pub last_position: Option<(u16, u16)>,
698    /// Mouse hover for LSP: byte position being hovered, timer start, and screen position
699    /// Format: (byte_position, hover_start_instant, screen_x, screen_y)
700    pub lsp_hover_state: Option<(usize, std::time::Instant, u16, u16)>,
701    /// Whether we've already sent a hover request for the current position
702    pub lsp_hover_request_sent: bool,
703    /// Initial mouse row when starting to drag the scrollbar thumb
704    /// Used to calculate relative movement rather than jumping
705    pub drag_start_row: Option<u16>,
706    /// Initial viewport top_byte when starting to drag the scrollbar thumb
707    pub drag_start_top_byte: Option<usize>,
708    /// Initial viewport top_view_line_offset when starting to drag the scrollbar thumb
709    /// This is needed for proper visual row calculation when scrolled into a wrapped line
710    pub drag_start_view_line_offset: Option<usize>,
711    /// Whether we're currently dragging a split separator
712    /// Stores (split_id, direction) for the separator being dragged
713    pub dragging_separator: Option<(ContainerId, SplitDirection)>,
714    /// Initial mouse position when starting to drag a separator
715    pub drag_start_position: Option<(u16, u16)>,
716    /// Initial split ratio when starting to drag a separator
717    pub drag_start_ratio: Option<f32>,
718    /// Whether we're currently dragging the file explorer border
719    pub dragging_file_explorer: bool,
720    /// Initial file explorer width percentage when starting to drag
721    pub drag_start_explorer_width: Option<f32>,
722    /// Current hover target (if any)
723    pub hover_target: Option<HoverTarget>,
724    /// Whether we're currently doing a text selection drag
725    pub dragging_text_selection: bool,
726    /// The split where text selection started
727    pub drag_selection_split: Option<LeafId>,
728    /// The buffer byte position where the selection anchor is
729    pub drag_selection_anchor: Option<usize>,
730    /// Tab drag state (for drag-to-split functionality)
731    pub dragging_tab: Option<TabDragState>,
732    /// Whether we're currently dragging a popup scrollbar (popup index)
733    pub dragging_popup_scrollbar: Option<usize>,
734    /// Initial scroll offset when starting to drag popup scrollbar
735    pub drag_start_popup_scroll: Option<usize>,
736    /// Whether we're currently selecting text in a popup (popup index)
737    pub selecting_in_popup: Option<usize>,
738    /// Initial composite scroll_row when starting to drag the scrollbar thumb
739    /// Used for composite buffer scrollbar drag
740    pub drag_start_composite_scroll_row: Option<usize>,
741}
742
743/// Mapping from visual row to buffer positions for mouse click handling
744/// Each entry represents one visual row with byte position info for click handling
745#[derive(Debug, Clone, Default)]
746pub struct ViewLineMapping {
747    /// Source byte offset for each character (None for injected/virtual content)
748    pub char_source_bytes: Vec<Option<usize>>,
749    /// Character index at each visual column (for O(1) mouse clicks)
750    pub visual_to_char: Vec<usize>,
751    /// Last valid byte position in this visual row (newline for real lines, last char for wrapped)
752    /// Clicks past end of visible text position cursor here
753    pub line_end_byte: usize,
754}
755
756impl ViewLineMapping {
757    /// Get source byte at a given visual column (O(1) for mouse clicks)
758    #[inline]
759    pub fn source_byte_at_visual_col(&self, visual_col: usize) -> Option<usize> {
760        let char_idx = self.visual_to_char.get(visual_col).copied()?;
761        self.char_source_bytes.get(char_idx).copied().flatten()
762    }
763
764    /// Find the nearest source byte to a given visual column, searching outward.
765    /// Returns the source byte at the closest valid visual column.
766    pub fn nearest_source_byte(&self, goal_col: usize) -> Option<usize> {
767        let width = self.visual_to_char.len();
768        if width == 0 {
769            return None;
770        }
771        // Search outward from goal_col: try +1, -1, +2, -2, ...
772        for delta in 1..width {
773            if goal_col + delta < width {
774                if let Some(byte) = self.source_byte_at_visual_col(goal_col + delta) {
775                    return Some(byte);
776                }
777            }
778            if delta <= goal_col {
779                if let Some(byte) = self.source_byte_at_visual_col(goal_col - delta) {
780                    return Some(byte);
781                }
782            }
783        }
784        None
785    }
786
787    /// Check if this visual row contains the given byte position
788    #[inline]
789    pub fn contains_byte(&self, byte_pos: usize) -> bool {
790        // A row contains a byte if it's in the char_source_bytes range
791        // The first valid source byte marks the start, line_end_byte marks the end
792        if let Some(first_byte) = self.char_source_bytes.iter().find_map(|b| *b) {
793            byte_pos >= first_byte && byte_pos <= self.line_end_byte
794        } else {
795            // Empty/virtual row - only matches if byte_pos equals line_end_byte
796            byte_pos == self.line_end_byte
797        }
798    }
799
800    /// Get the first source byte position in this row (if any)
801    #[inline]
802    pub fn first_source_byte(&self) -> Option<usize> {
803        self.char_source_bytes.iter().find_map(|b| *b)
804    }
805}
806
807/// Type alias for popup area layout information used in mouse hit testing.
808/// Fields: (popup_index, rect, inner_rect, scroll_offset, num_items, scrollbar_rect, total_lines)
809pub(crate) type PopupAreaLayout = (usize, Rect, Rect, usize, usize, Option<Rect>, usize);
810
811/// Cached layout information for mouse hit testing
812#[derive(Debug, Clone, Default)]
813pub(crate) struct CachedLayout {
814    /// File explorer area (if visible)
815    pub file_explorer_area: Option<Rect>,
816    /// Editor content area (excluding file explorer)
817    pub editor_content_area: Option<Rect>,
818    /// Individual split areas with their scrollbar areas and thumb positions
819    /// (split_id, buffer_id, content_rect, scrollbar_rect, thumb_start, thumb_end)
820    pub split_areas: Vec<(LeafId, BufferId, Rect, Rect, usize, usize)>,
821    /// Horizontal scrollbar areas per split
822    /// (split_id, buffer_id, horizontal_scrollbar_rect, max_content_width, thumb_start_col, thumb_end_col)
823    pub horizontal_scrollbar_areas: Vec<(LeafId, BufferId, Rect, usize, usize, usize)>,
824    /// Split separator positions for drag resize
825    /// (container_id, direction, x, y, length)
826    pub separator_areas: Vec<(ContainerId, SplitDirection, u16, u16, u16)>,
827    /// Popup areas for mouse hit testing
828    /// scrollbar_rect is Some if popup has a scrollbar
829    pub popup_areas: Vec<PopupAreaLayout>,
830    /// Suggestions area for mouse hit testing
831    /// (inner_rect, scroll_start_idx, visible_count, total_count)
832    pub suggestions_area: Option<(Rect, usize, usize, usize)>,
833    /// Tab layouts per split for mouse interaction
834    pub tab_layouts: HashMap<LeafId, crate::view::ui::tabs::TabLayout>,
835    /// Close split button hit areas
836    /// (split_id, row, start_col, end_col)
837    pub close_split_areas: Vec<(LeafId, u16, u16, u16)>,
838    /// Maximize split button hit areas
839    /// (split_id, row, start_col, end_col)
840    pub maximize_split_areas: Vec<(LeafId, u16, u16, u16)>,
841    /// View line mappings for accurate mouse click positioning per split
842    /// Maps visual row index to character position mappings
843    /// Used to translate screen coordinates to buffer byte positions
844    pub view_line_mappings: HashMap<LeafId, Vec<ViewLineMapping>>,
845    /// Settings modal layout for hit testing
846    pub settings_layout: Option<crate::view::settings::SettingsLayout>,
847    /// Status bar area (row, x, width)
848    pub status_bar_area: Option<(u16, u16, u16)>,
849    /// Status bar LSP indicator area (row, start_col, end_col)
850    pub status_bar_lsp_area: Option<(u16, u16, u16)>,
851    /// Status bar warning badge area (row, start_col, end_col)
852    pub status_bar_warning_area: Option<(u16, u16, u16)>,
853    /// Status bar line ending indicator area (row, start_col, end_col)
854    pub status_bar_line_ending_area: Option<(u16, u16, u16)>,
855    /// Status bar encoding indicator area (row, start_col, end_col)
856    pub status_bar_encoding_area: Option<(u16, u16, u16)>,
857    /// Status bar language indicator area (row, start_col, end_col)
858    pub status_bar_language_area: Option<(u16, u16, u16)>,
859    /// Status bar message area (row, start_col, end_col) - clickable to show status log
860    pub status_bar_message_area: Option<(u16, u16, u16)>,
861    /// Search options layout for checkbox hit testing
862    pub search_options_layout: Option<crate::view::ui::status_bar::SearchOptionsLayout>,
863    /// Menu bar layout for hit testing
864    pub menu_layout: Option<crate::view::ui::menu::MenuLayout>,
865    /// Last frame dimensions — used by recompute_layout for macro replay
866    pub last_frame_width: u16,
867    pub last_frame_height: u16,
868}
869
870impl CachedLayout {
871    /// Find which visual row contains the given byte position for a split
872    pub fn find_visual_row(&self, split_id: LeafId, byte_pos: usize) -> Option<usize> {
873        let mappings = self.view_line_mappings.get(&split_id)?;
874        mappings.iter().position(|m| m.contains_byte(byte_pos))
875    }
876
877    /// Get the visual column of a byte position within its visual row
878    pub fn byte_to_visual_column(&self, split_id: LeafId, byte_pos: usize) -> Option<usize> {
879        let mappings = self.view_line_mappings.get(&split_id)?;
880        let row_idx = self.find_visual_row(split_id, byte_pos)?;
881        let row = mappings.get(row_idx)?;
882
883        // Find the visual column that maps to this byte position
884        for (visual_col, &char_idx) in row.visual_to_char.iter().enumerate() {
885            if let Some(source_byte) = row.char_source_bytes.get(char_idx).and_then(|b| *b) {
886                if source_byte == byte_pos {
887                    return Some(visual_col);
888                }
889                // If we've passed the byte position, return previous column
890                if source_byte > byte_pos {
891                    return Some(visual_col.saturating_sub(1));
892                }
893            }
894        }
895        // Byte is at or past end of row - return column after last character
896        // This handles cursor positions at end of line (e.g., after last char before newline)
897        Some(row.visual_to_char.len())
898    }
899
900    /// Move by visual line using the cached mappings
901    /// Returns (new_position, new_visual_column) or None if at boundary
902    pub fn move_visual_line(
903        &self,
904        split_id: LeafId,
905        current_pos: usize,
906        goal_visual_col: usize,
907        direction: i8, // -1 = up, 1 = down
908    ) -> Option<(usize, usize)> {
909        let mappings = self.view_line_mappings.get(&split_id)?;
910        let current_row = self.find_visual_row(split_id, current_pos)?;
911
912        let target_row = if direction < 0 {
913            current_row.checked_sub(1)?
914        } else {
915            let next = current_row + 1;
916            if next >= mappings.len() {
917                return None;
918            }
919            next
920        };
921
922        let target_mapping = mappings.get(target_row)?;
923
924        // Try to get byte at goal visual column.  If the goal column is past
925        // the end of visible content, land at line_end_byte (the newline or
926        // end of buffer).  If the column exists but has no source byte (e.g.
927        // padding on a wrapped continuation line), search outward for the
928        // nearest valid source byte at minimal visual distance.
929        let new_pos = if goal_visual_col >= target_mapping.visual_to_char.len() {
930            target_mapping.line_end_byte
931        } else {
932            target_mapping
933                .source_byte_at_visual_col(goal_visual_col)
934                .or_else(|| target_mapping.nearest_source_byte(goal_visual_col))
935                .unwrap_or(target_mapping.line_end_byte)
936        };
937
938        Some((new_pos, goal_visual_col))
939    }
940
941    /// Get the start byte position of the visual row containing the given byte position.
942    /// If the cursor is already at the visual row start and this is a wrapped continuation,
943    /// moves to the previous visual row's start (within the same logical line).
944    /// Get the start byte position of the visual row containing the given byte position.
945    /// When `allow_advance` is true and the cursor is already at the row start,
946    /// moves to the previous visual row's start.
947    pub fn visual_line_start(
948        &self,
949        split_id: LeafId,
950        byte_pos: usize,
951        allow_advance: bool,
952    ) -> Option<usize> {
953        let mappings = self.view_line_mappings.get(&split_id)?;
954        let row_idx = self.find_visual_row(split_id, byte_pos)?;
955        let row = mappings.get(row_idx)?;
956        let row_start = row.first_source_byte()?;
957
958        if allow_advance && byte_pos == row_start && row_idx > 0 {
959            let prev_row = mappings.get(row_idx - 1)?;
960            prev_row.first_source_byte()
961        } else {
962            Some(row_start)
963        }
964    }
965
966    /// Get the end byte position of the visual row containing the given byte position.
967    /// If the cursor is already at the visual row end and the next row is a wrapped continuation,
968    /// moves to the next visual row's end (within the same logical line).
969    /// Get the end byte position of the visual row containing the given byte position.
970    /// When `allow_advance` is true and the cursor is already at the row end,
971    /// advances to the next visual row's end.
972    pub fn visual_line_end(
973        &self,
974        split_id: LeafId,
975        byte_pos: usize,
976        allow_advance: bool,
977    ) -> Option<usize> {
978        let mappings = self.view_line_mappings.get(&split_id)?;
979        let row_idx = self.find_visual_row(split_id, byte_pos)?;
980        let row = mappings.get(row_idx)?;
981
982        if allow_advance && byte_pos == row.line_end_byte && row_idx + 1 < mappings.len() {
983            let next_row = mappings.get(row_idx + 1)?;
984            Some(next_row.line_end_byte)
985        } else {
986            Some(row.line_end_byte)
987        }
988    }
989}
990
991/// Convert a file path to an `lsp_types::Uri`.
992///
993/// Uses `url::Url::from_file_path` for initial encoding, then percent-encodes
994/// characters that the `url` crate (WHATWG) leaves raw but that `lsp_types::Uri`
995/// (strict RFC 3986) rejects — specifically `[`, `]`, `{`, `}`, `|`, `^`, and `` ` ``.
996pub fn file_path_to_lsp_uri(path: &Path) -> Option<lsp_types::Uri> {
997    let url = url::Url::from_file_path(path).ok()?;
998    let url_str = url.as_str();
999
1000    // Fast path: if no problematic characters, parse directly
1001    if !url_str
1002        .bytes()
1003        .any(|b| matches!(b, b'[' | b']' | b'{' | b'}' | b'|' | b'^' | b'`'))
1004    {
1005        return url_str.parse::<lsp_types::Uri>().ok();
1006    }
1007
1008    // Percent-encode characters that url (WHATWG) allows but lsp_types::Uri (RFC 3986) rejects
1009    let mut encoded = String::with_capacity(url_str.len() + 16);
1010    for byte in url_str.bytes() {
1011        match byte {
1012            b'[' => encoded.push_str("%5B"),
1013            b']' => encoded.push_str("%5D"),
1014            b'{' => encoded.push_str("%7B"),
1015            b'}' => encoded.push_str("%7D"),
1016            b'|' => encoded.push_str("%7C"),
1017            b'^' => encoded.push_str("%5E"),
1018            b'`' => encoded.push_str("%60"),
1019            _ => encoded.push(byte as char),
1020        }
1021    }
1022    encoded.parse::<lsp_types::Uri>().ok()
1023}
1024
1025#[cfg(test)]
1026mod uri_encoding_tests {
1027    use super::*;
1028
1029    /// Helper to get a platform-appropriate absolute path for testing.
1030    /// On Windows, url::Url::from_file_path rejects Unix-style paths.
1031    fn abs_path(suffix: &str) -> PathBuf {
1032        std::env::temp_dir().join(suffix)
1033    }
1034
1035    #[test]
1036    fn test_brackets_in_path() {
1037        let path = abs_path("MY_PROJECTS [temp]/gogame/main.go");
1038        let uri = file_path_to_lsp_uri(&path);
1039        assert!(
1040            uri.is_some(),
1041            "URI should be computed for path with brackets"
1042        );
1043        let uri = uri.unwrap();
1044        assert!(
1045            uri.as_str().contains("%5Btemp%5D"),
1046            "Brackets should be percent-encoded: {}",
1047            uri.as_str()
1048        );
1049    }
1050
1051    #[test]
1052    fn test_spaces_in_path() {
1053        let path = abs_path("My Projects/src/main.go");
1054        let uri = file_path_to_lsp_uri(&path);
1055        assert!(uri.is_some(), "URI should be computed for path with spaces");
1056    }
1057
1058    #[test]
1059    fn test_normal_path() {
1060        let path = abs_path("project/main.go");
1061        let uri = file_path_to_lsp_uri(&path);
1062        assert!(uri.is_some(), "URI should be computed for normal path");
1063        let s = uri.unwrap().as_str().to_string();
1064        assert!(s.starts_with("file:///"), "Should be a file URI: {}", s);
1065        assert!(
1066            s.ends_with("project/main.go"),
1067            "Should end with the path: {}",
1068            s
1069        );
1070    }
1071
1072    #[test]
1073    fn test_relative_path_returns_none() {
1074        let path = PathBuf::from("main.go");
1075        assert!(file_path_to_lsp_uri(&path).is_none());
1076    }
1077
1078    #[test]
1079    fn test_all_special_chars() {
1080        let path = abs_path("a[b]c{d}e^g`h/file.rs");
1081        let uri = file_path_to_lsp_uri(&path);
1082        assert!(uri.is_some(), "Should handle all special characters");
1083        let s = uri.unwrap().as_str().to_string();
1084        assert!(!s.contains('['), "[ should be encoded in {}", s);
1085        assert!(!s.contains(']'), "] should be encoded in {}", s);
1086        assert!(!s.contains('{'), "{{ should be encoded in {}", s);
1087        assert!(!s.contains('}'), "}} should be encoded in {}", s);
1088        assert!(!s.contains('^'), "^ should be encoded in {}", s);
1089        assert!(!s.contains('`'), "` should be encoded in {}", s);
1090    }
1091}