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        // Library files are read-only (to prevent accidental edits) but LSP stays
227        // enabled so that Goto Definition, Hover, Find References, etc. still work
228        // when the user navigates into library source code (issue #1344).
229        let is_library = Self::is_library_path(&path, working_dir);
230
231        Self {
232            kind: BufferKind::File {
233                path,
234                uri: file_uri,
235            },
236            display_name,
237            lsp_enabled: true,
238            lsp_disabled_reason: None,
239            read_only: is_library,
240            binary: false,
241            lsp_opened_with: HashSet::new(),
242            hidden_from_tabs: false,
243            recovery_id: None,
244        }
245    }
246
247    /// Check if a path is a library file (in vendor directories or standard libraries)
248    ///
249    /// Library files include:
250    /// - Files in common vendor/dependency directories (.cargo, node_modules, etc.)
251    /// - Standard library / toolchain files (rustup toolchains, system includes, etc.)
252    pub fn is_library_path(path: &Path, _working_dir: &Path) -> bool {
253        // Check for common library paths
254        let path_str = path.to_string_lossy();
255
256        // Rust: .cargo directory (can be within project for vendor'd crates)
257        if path_str.contains("/.cargo/") || path_str.contains("\\.cargo\\") {
258            return true;
259        }
260
261        // Rust: rustup toolchains (standard library source files)
262        if path_str.contains("/rustup/toolchains/") || path_str.contains("\\rustup\\toolchains\\") {
263            return true;
264        }
265
266        // Node.js: node_modules
267        if path_str.contains("/node_modules/") || path_str.contains("\\node_modules\\") {
268            return true;
269        }
270
271        // Python: site-packages, dist-packages
272        if path_str.contains("/site-packages/")
273            || path_str.contains("\\site-packages\\")
274            || path_str.contains("/dist-packages/")
275            || path_str.contains("\\dist-packages\\")
276        {
277            return true;
278        }
279
280        // Go: pkg/mod
281        if path_str.contains("/pkg/mod/") || path_str.contains("\\pkg\\mod\\") {
282            return true;
283        }
284
285        // Ruby: gems
286        if path_str.contains("/gems/") || path_str.contains("\\gems\\") {
287            return true;
288        }
289
290        // Java/Gradle: .gradle
291        if path_str.contains("/.gradle/") || path_str.contains("\\.gradle\\") {
292            return true;
293        }
294
295        // Maven: .m2
296        if path_str.contains("/.m2/") || path_str.contains("\\.m2\\") {
297            return true;
298        }
299
300        // C/C++: system include directories
301        if path_str.starts_with("/usr/include/") || path_str.starts_with("/usr/local/include/") {
302            return true;
303        }
304
305        // Nix store (system-managed packages)
306        if path_str.starts_with("/nix/store/") {
307            return true;
308        }
309
310        // Homebrew (macOS system-managed packages)
311        if path_str.starts_with("/opt/homebrew/Cellar/")
312            || path_str.starts_with("/usr/local/Cellar/")
313        {
314            return true;
315        }
316
317        // .NET / C#: NuGet packages
318        if path_str.contains("/.nuget/") || path_str.contains("\\.nuget\\") {
319            return true;
320        }
321
322        // Swift / Xcode toolchains
323        if path_str.contains("/Xcode.app/Contents/Developer/")
324            || path_str.contains("/CommandLineTools/SDKs/")
325        {
326            return true;
327        }
328
329        false
330    }
331
332    /// Compute display name relative to working_dir when possible, otherwise absolute
333    pub fn display_name_for_path(path: &Path, working_dir: &Path) -> String {
334        // Canonicalize working_dir to normalize platform-specific prefixes
335        let canonical_working_dir = working_dir
336            .canonicalize()
337            .unwrap_or_else(|_| working_dir.to_path_buf());
338
339        // Try to canonicalize the file path; if it fails (e.g., new file), fall back to absolute
340        let absolute_path = if path.is_absolute() {
341            path.to_path_buf()
342        } else {
343            // If we were given a relative path, anchor it to working_dir
344            canonical_working_dir.join(path)
345        };
346        let canonical_path = absolute_path
347            .canonicalize()
348            .unwrap_or_else(|_| absolute_path.clone());
349
350        // Prefer canonical comparison first, then raw prefix as a fallback
351        let relative = canonical_path
352            .strip_prefix(&canonical_working_dir)
353            .or_else(|_| path.strip_prefix(working_dir))
354            .ok()
355            .and_then(|rel| rel.to_str().map(|s| s.to_string()));
356
357        relative
358            .or_else(|| canonical_path.to_str().map(|s| s.to_string()))
359            .unwrap_or_else(|| t!("buffer.unknown").to_string())
360    }
361
362    /// Create metadata for a virtual buffer (not backed by a file)
363    ///
364    /// # Arguments
365    /// * `name` - Display name (e.g., "*Diagnostics*")
366    /// * `mode` - Buffer mode for keybindings (e.g., "diagnostics-list")
367    /// * `read_only` - Whether the buffer should be read-only
368    pub fn virtual_buffer(name: String, mode: String, read_only: bool) -> Self {
369        Self {
370            kind: BufferKind::Virtual { mode },
371            display_name: name,
372            lsp_enabled: false, // Virtual buffers don't use LSP
373            lsp_disabled_reason: Some(t!("lsp.disabled.virtual").to_string()),
374            read_only,
375            binary: false,
376            lsp_opened_with: HashSet::new(),
377            hidden_from_tabs: false,
378            recovery_id: None,
379        }
380    }
381
382    /// Create metadata for a hidden virtual buffer (for composite source buffers)
383    /// These buffers are not shown in tabs and are managed by their parent composite buffer.
384    /// Hidden buffers are always read-only to prevent accidental edits.
385    pub fn hidden_virtual_buffer(name: String, mode: String) -> Self {
386        Self {
387            kind: BufferKind::Virtual { mode },
388            display_name: name,
389            lsp_enabled: false,
390            lsp_disabled_reason: Some(t!("lsp.disabled.virtual").to_string()),
391            read_only: true, // Hidden buffers are always read-only
392            binary: false,
393            lsp_opened_with: HashSet::new(),
394            hidden_from_tabs: true,
395            recovery_id: None,
396        }
397    }
398
399    /// Disable LSP for this buffer with a reason
400    pub fn disable_lsp(&mut self, reason: String) {
401        self.lsp_enabled = false;
402        self.lsp_disabled_reason = Some(reason);
403    }
404}
405
406/// State for macro recording
407#[derive(Debug, Clone)]
408pub(super) struct MacroRecordingState {
409    /// The register key for this macro
410    pub key: char,
411    /// Actions recorded so far
412    pub actions: Vec<Action>,
413}
414
415/// LSP progress information
416#[derive(Debug, Clone)]
417pub(super) struct LspProgressInfo {
418    pub language: String,
419    pub title: String,
420    pub message: Option<String>,
421    pub percentage: Option<u32>,
422}
423
424/// LSP message entry (for window messages and logs)
425#[derive(Debug, Clone)]
426#[allow(dead_code)]
427pub(super) struct LspMessageEntry {
428    pub language: String,
429    pub message_type: LspMessageType,
430    pub message: String,
431    pub timestamp: std::time::Instant,
432}
433
434/// Types of UI elements that can be hovered over
435#[derive(Debug, Clone, PartialEq)]
436pub enum HoverTarget {
437    /// Hovering over a split separator (container_id, direction)
438    SplitSeparator(ContainerId, SplitDirection),
439    /// Hovering over a scrollbar thumb (split_id)
440    ScrollbarThumb(LeafId),
441    /// Hovering over a scrollbar track (split_id)
442    ScrollbarTrack(LeafId),
443    /// Hovering over a menu bar item (menu_index)
444    MenuBarItem(usize),
445    /// Hovering over a menu dropdown item (menu_index, item_index)
446    MenuDropdownItem(usize, usize),
447    /// Hovering over a submenu item (depth, item_index) - depth 1+ for nested submenus
448    SubmenuItem(usize, usize),
449    /// Hovering over a popup list item (popup_index in stack, item_index)
450    PopupListItem(usize, usize),
451    /// Hovering over a suggestion item (item_index)
452    SuggestionItem(usize),
453    /// Hovering over the file explorer border (for resize)
454    FileExplorerBorder,
455    /// Hovering over a file browser navigation shortcut
456    FileBrowserNavShortcut(usize),
457    /// Hovering over a file browser file/directory entry
458    FileBrowserEntry(usize),
459    /// Hovering over a file browser column header
460    FileBrowserHeader(SortMode),
461    /// Hovering over the file browser scrollbar
462    FileBrowserScrollbar,
463    /// Hovering over the file browser "Show Hidden" checkbox
464    FileBrowserShowHiddenCheckbox,
465    /// Hovering over the file browser "Detect Encoding" checkbox
466    FileBrowserDetectEncodingCheckbox,
467    /// Hovering over a tab name (buffer_id, split_id) - for non-active tabs
468    TabName(BufferId, LeafId),
469    /// Hovering over a tab close button (buffer_id, split_id)
470    TabCloseButton(BufferId, LeafId),
471    /// Hovering over a close split button (split_id)
472    CloseSplitButton(LeafId),
473    /// Hovering over a maximize/unmaximize split button (split_id)
474    MaximizeSplitButton(LeafId),
475    /// Hovering over the file explorer close button
476    FileExplorerCloseButton,
477    /// Hovering over a file explorer item's status indicator (path)
478    FileExplorerStatusIndicator(std::path::PathBuf),
479    /// Hovering over the status bar LSP indicator
480    StatusBarLspIndicator,
481    /// Hovering over the status bar warning badge
482    StatusBarWarningBadge,
483    /// Hovering over the status bar line ending indicator
484    StatusBarLineEndingIndicator,
485    /// Hovering over the status bar encoding indicator
486    StatusBarEncodingIndicator,
487    /// Hovering over the status bar language indicator
488    StatusBarLanguageIndicator,
489    /// Hovering over the search options "Case Sensitive" checkbox
490    SearchOptionCaseSensitive,
491    /// Hovering over the search options "Whole Word" checkbox
492    SearchOptionWholeWord,
493    /// Hovering over the search options "Regex" checkbox
494    SearchOptionRegex,
495    /// Hovering over the search options "Confirm Each" checkbox
496    SearchOptionConfirmEach,
497    /// Hovering over a tab context menu item (item_index)
498    TabContextMenuItem(usize),
499}
500
501/// Tab context menu items
502#[derive(Debug, Clone, Copy, PartialEq, Eq)]
503pub enum TabContextMenuItem {
504    /// Close this tab
505    Close,
506    /// Close all other tabs
507    CloseOthers,
508    /// Close tabs to the right
509    CloseToRight,
510    /// Close tabs to the left
511    CloseToLeft,
512    /// Close all tabs
513    CloseAll,
514}
515
516impl TabContextMenuItem {
517    /// Get all menu items in order
518    pub fn all() -> &'static [Self] {
519        &[
520            Self::Close,
521            Self::CloseOthers,
522            Self::CloseToRight,
523            Self::CloseToLeft,
524            Self::CloseAll,
525        ]
526    }
527
528    /// Get the display label for this menu item
529    pub fn label(&self) -> String {
530        match self {
531            Self::Close => t!("tab.close").to_string(),
532            Self::CloseOthers => t!("tab.close_others").to_string(),
533            Self::CloseToRight => t!("tab.close_to_right").to_string(),
534            Self::CloseToLeft => t!("tab.close_to_left").to_string(),
535            Self::CloseAll => t!("tab.close_all").to_string(),
536        }
537    }
538}
539
540/// State for tab context menu (right-click popup on tabs)
541#[derive(Debug, Clone)]
542pub struct TabContextMenu {
543    /// The buffer ID this context menu is for
544    pub buffer_id: BufferId,
545    /// The split ID where the tab is located
546    pub split_id: LeafId,
547    /// Screen position where the menu should appear (x, y)
548    pub position: (u16, u16),
549    /// Currently highlighted menu item index
550    pub highlighted: usize,
551}
552
553impl TabContextMenu {
554    /// Create a new tab context menu
555    pub fn new(buffer_id: BufferId, split_id: LeafId, x: u16, y: u16) -> Self {
556        Self {
557            buffer_id,
558            split_id,
559            position: (x, y),
560            highlighted: 0,
561        }
562    }
563
564    /// Get the currently highlighted item
565    pub fn highlighted_item(&self) -> TabContextMenuItem {
566        TabContextMenuItem::all()[self.highlighted]
567    }
568
569    /// Move highlight down
570    pub fn next_item(&mut self) {
571        let items = TabContextMenuItem::all();
572        self.highlighted = (self.highlighted + 1) % items.len();
573    }
574
575    /// Move highlight up
576    pub fn prev_item(&mut self) {
577        let items = TabContextMenuItem::all();
578        self.highlighted = if self.highlighted == 0 {
579            items.len() - 1
580        } else {
581            self.highlighted - 1
582        };
583    }
584}
585
586/// Lightweight per-cell theme key provenance recorded during rendering.
587/// Stored in `CachedLayout::cell_theme_map` so the theme inspector popup
588/// can look up the exact keys used for any screen position.
589#[derive(Debug, Clone, Default)]
590pub struct CellThemeInfo {
591    /// Foreground theme key (e.g. "syntax.keyword", "editor.fg")
592    pub fg_key: Option<&'static str>,
593    /// Background theme key (e.g. "editor.bg", "diagnostic.warning_bg")
594    pub bg_key: Option<&'static str>,
595    /// Short region label (e.g. "Line Numbers", "Editor Content")
596    pub region: &'static str,
597    /// Dynamic region suffix (e.g. syntax category display name appended to "Syntax: ")
598    pub syntax_category: Option<&'static str>,
599}
600
601/// Information about which theme key(s) style a specific screen position.
602/// Used by the Ctrl+Right-Click theme inspector popup.
603#[derive(Debug, Clone)]
604pub struct ThemeKeyInfo {
605    /// The foreground theme key path (e.g., "syntax.keyword", "editor.fg")
606    pub fg_key: Option<String>,
607    /// The background theme key path (e.g., "editor.bg", "editor.selection_bg")
608    pub bg_key: Option<String>,
609    /// Human-readable description of the UI region
610    pub region: String,
611    /// The actual foreground color value currently applied
612    pub fg_color: Option<ratatui::style::Color>,
613    /// The actual background color value currently applied
614    pub bg_color: Option<ratatui::style::Color>,
615    /// For syntax highlights: the HighlightCategory display name
616    pub syntax_category: Option<String>,
617}
618
619/// State for the theme inspector popup (Ctrl+Right-Click)
620#[derive(Debug, Clone)]
621pub struct ThemeInfoPopup {
622    /// Screen position where popup appears (x, y)
623    pub position: (u16, u16),
624    /// Resolved theme key information
625    pub info: ThemeKeyInfo,
626    /// Whether the "Open in Theme Editor" button is highlighted (mouse hover)
627    pub button_highlighted: bool,
628}
629
630/// Drop zone for tab drag-and-drop
631/// Indicates where a dragged tab will be placed when released
632#[derive(Debug, Clone, Copy, PartialEq, Eq)]
633pub enum TabDropZone {
634    /// Drop into an existing split's tab bar (before tab at index, or at end if None)
635    /// (target_split_id, insert_index)
636    TabBar(LeafId, Option<usize>),
637    /// Create a new split on the left edge of the target split
638    SplitLeft(LeafId),
639    /// Create a new split on the right edge of the target split
640    SplitRight(LeafId),
641    /// Create a new split on the top edge of the target split
642    SplitTop(LeafId),
643    /// Create a new split on the bottom edge of the target split
644    SplitBottom(LeafId),
645    /// Drop into the center of a split (switch to that split's tab bar)
646    SplitCenter(LeafId),
647}
648
649impl TabDropZone {
650    /// Get the split ID this drop zone is associated with
651    pub fn split_id(&self) -> LeafId {
652        match self {
653            Self::TabBar(id, _)
654            | Self::SplitLeft(id)
655            | Self::SplitRight(id)
656            | Self::SplitTop(id)
657            | Self::SplitBottom(id)
658            | Self::SplitCenter(id) => *id,
659        }
660    }
661}
662
663/// State for a tab being dragged
664#[derive(Debug, Clone)]
665pub struct TabDragState {
666    /// The buffer being dragged
667    pub buffer_id: BufferId,
668    /// The split the tab was dragged from
669    pub source_split_id: LeafId,
670    /// Starting mouse position when drag began
671    pub start_position: (u16, u16),
672    /// Current mouse position
673    pub current_position: (u16, u16),
674    /// Currently detected drop zone (if any)
675    pub drop_zone: Option<TabDropZone>,
676}
677
678impl TabDragState {
679    /// Create a new tab drag state
680    pub fn new(buffer_id: BufferId, source_split_id: LeafId, start_position: (u16, u16)) -> Self {
681        Self {
682            buffer_id,
683            source_split_id,
684            start_position,
685            current_position: start_position,
686            drop_zone: None,
687        }
688    }
689
690    /// Check if the drag has moved enough to be considered a real drag (not just a click)
691    pub fn is_dragging(&self) -> bool {
692        let dx = (self.current_position.0 as i32 - self.start_position.0 as i32).abs();
693        let dy = (self.current_position.1 as i32 - self.start_position.1 as i32).abs();
694        dx > 3 || dy > 3 // Threshold of 3 pixels before drag activates
695    }
696}
697
698/// Mouse state tracking
699#[derive(Debug, Clone, Default)]
700pub(super) struct MouseState {
701    /// Whether we're currently dragging a vertical scrollbar
702    pub dragging_scrollbar: Option<LeafId>,
703    /// Whether we're currently dragging a horizontal scrollbar
704    pub dragging_horizontal_scrollbar: Option<LeafId>,
705    /// Initial mouse column when starting horizontal scrollbar drag
706    pub drag_start_hcol: Option<u16>,
707    /// Initial left_column when starting horizontal scrollbar drag
708    pub drag_start_left_column: Option<usize>,
709    /// Last mouse position
710    pub last_position: Option<(u16, u16)>,
711    /// Mouse hover for LSP: byte position being hovered, timer start, and screen position
712    /// Format: (byte_position, hover_start_instant, screen_x, screen_y)
713    pub lsp_hover_state: Option<(usize, std::time::Instant, u16, u16)>,
714    /// Whether we've already sent a hover request for the current position
715    pub lsp_hover_request_sent: bool,
716    /// Initial mouse row when starting to drag the scrollbar thumb
717    /// Used to calculate relative movement rather than jumping
718    pub drag_start_row: Option<u16>,
719    /// Initial viewport top_byte when starting to drag the scrollbar thumb
720    pub drag_start_top_byte: Option<usize>,
721    /// Initial viewport top_view_line_offset when starting to drag the scrollbar thumb
722    /// This is needed for proper visual row calculation when scrolled into a wrapped line
723    pub drag_start_view_line_offset: Option<usize>,
724    /// Whether we're currently dragging a split separator
725    /// Stores (split_id, direction) for the separator being dragged
726    pub dragging_separator: Option<(ContainerId, SplitDirection)>,
727    /// Initial mouse position when starting to drag a separator
728    pub drag_start_position: Option<(u16, u16)>,
729    /// Initial split ratio when starting to drag a separator
730    pub drag_start_ratio: Option<f32>,
731    /// Whether we're currently dragging the file explorer border
732    pub dragging_file_explorer: bool,
733    /// Initial file explorer width percentage when starting to drag
734    pub drag_start_explorer_width: Option<f32>,
735    /// Current hover target (if any)
736    pub hover_target: Option<HoverTarget>,
737    /// Whether we're currently doing a text selection drag
738    pub dragging_text_selection: bool,
739    /// The split where text selection started
740    pub drag_selection_split: Option<LeafId>,
741    /// The buffer byte position where the selection anchor is
742    pub drag_selection_anchor: Option<usize>,
743    /// When true, dragging extends selection by whole words (set by double-click)
744    pub drag_selection_by_words: bool,
745    /// The end of the initially double-clicked word (used as anchor when dragging backward)
746    pub drag_selection_word_end: Option<usize>,
747    /// Tab drag state (for drag-to-split functionality)
748    pub dragging_tab: Option<TabDragState>,
749    /// Whether we're currently dragging a popup scrollbar (popup index)
750    pub dragging_popup_scrollbar: Option<usize>,
751    /// Initial scroll offset when starting to drag popup scrollbar
752    pub drag_start_popup_scroll: Option<usize>,
753    /// Whether we're currently selecting text in a popup (popup index)
754    pub selecting_in_popup: Option<usize>,
755    /// Initial composite scroll_row when starting to drag the scrollbar thumb
756    /// Used for composite buffer scrollbar drag
757    pub drag_start_composite_scroll_row: Option<usize>,
758}
759
760/// Mapping from visual row to buffer positions for mouse click handling
761/// Each entry represents one visual row with byte position info for click handling
762#[derive(Debug, Clone, Default)]
763pub struct ViewLineMapping {
764    /// Source byte offset for each character (None for injected/virtual content)
765    pub char_source_bytes: Vec<Option<usize>>,
766    /// Character index at each visual column (for O(1) mouse clicks)
767    pub visual_to_char: Vec<usize>,
768    /// Last valid byte position in this visual row (newline for real lines, last char for wrapped)
769    /// Clicks past end of visible text position cursor here
770    pub line_end_byte: usize,
771}
772
773impl ViewLineMapping {
774    /// Get source byte at a given visual column (O(1) for mouse clicks)
775    #[inline]
776    pub fn source_byte_at_visual_col(&self, visual_col: usize) -> Option<usize> {
777        let char_idx = self.visual_to_char.get(visual_col).copied()?;
778        self.char_source_bytes.get(char_idx).copied().flatten()
779    }
780
781    /// Find the nearest source byte to a given visual column, searching outward.
782    /// Returns the source byte at the closest valid visual column.
783    pub fn nearest_source_byte(&self, goal_col: usize) -> Option<usize> {
784        let width = self.visual_to_char.len();
785        if width == 0 {
786            return None;
787        }
788        // Search outward from goal_col: try +1, -1, +2, -2, ...
789        for delta in 1..width {
790            if goal_col + delta < width {
791                if let Some(byte) = self.source_byte_at_visual_col(goal_col + delta) {
792                    return Some(byte);
793                }
794            }
795            if delta <= goal_col {
796                if let Some(byte) = self.source_byte_at_visual_col(goal_col - delta) {
797                    return Some(byte);
798                }
799            }
800        }
801        None
802    }
803
804    /// Check if this visual row contains the given byte position
805    #[inline]
806    pub fn contains_byte(&self, byte_pos: usize) -> bool {
807        // A row contains a byte if it's in the char_source_bytes range
808        // The first valid source byte marks the start, line_end_byte marks the end
809        if let Some(first_byte) = self.char_source_bytes.iter().find_map(|b| *b) {
810            byte_pos >= first_byte && byte_pos <= self.line_end_byte
811        } else {
812            // Empty/virtual row - only matches if byte_pos equals line_end_byte
813            byte_pos == self.line_end_byte
814        }
815    }
816
817    /// Get the first source byte position in this row (if any)
818    #[inline]
819    pub fn first_source_byte(&self) -> Option<usize> {
820        self.char_source_bytes.iter().find_map(|b| *b)
821    }
822}
823
824/// Type alias for popup area layout information used in mouse hit testing.
825/// Fields: (popup_index, rect, inner_rect, scroll_offset, num_items, scrollbar_rect, total_lines)
826pub(crate) type PopupAreaLayout = (usize, Rect, Rect, usize, usize, Option<Rect>, usize);
827
828/// Cached layout information for mouse hit testing
829#[derive(Debug, Clone, Default)]
830pub(crate) struct CachedLayout {
831    /// File explorer area (if visible)
832    pub file_explorer_area: Option<Rect>,
833    /// Editor content area (excluding file explorer)
834    pub editor_content_area: Option<Rect>,
835    /// Individual split areas with their scrollbar areas and thumb positions
836    /// (split_id, buffer_id, content_rect, scrollbar_rect, thumb_start, thumb_end)
837    pub split_areas: Vec<(LeafId, BufferId, Rect, Rect, usize, usize)>,
838    /// Horizontal scrollbar areas per split
839    /// (split_id, buffer_id, horizontal_scrollbar_rect, max_content_width, thumb_start_col, thumb_end_col)
840    pub horizontal_scrollbar_areas: Vec<(LeafId, BufferId, Rect, usize, usize, usize)>,
841    /// Split separator positions for drag resize
842    /// (container_id, direction, x, y, length)
843    pub separator_areas: Vec<(ContainerId, SplitDirection, u16, u16, u16)>,
844    /// Popup areas for mouse hit testing
845    /// scrollbar_rect is Some if popup has a scrollbar
846    pub popup_areas: Vec<PopupAreaLayout>,
847    /// Suggestions area for mouse hit testing
848    /// (inner_rect, scroll_start_idx, visible_count, total_count)
849    pub suggestions_area: Option<(Rect, usize, usize, usize)>,
850    /// Tab layouts per split for mouse interaction
851    pub tab_layouts: HashMap<LeafId, crate::view::ui::tabs::TabLayout>,
852    /// Close split button hit areas
853    /// (split_id, row, start_col, end_col)
854    pub close_split_areas: Vec<(LeafId, u16, u16, u16)>,
855    /// Maximize split button hit areas
856    /// (split_id, row, start_col, end_col)
857    pub maximize_split_areas: Vec<(LeafId, u16, u16, u16)>,
858    /// View line mappings for accurate mouse click positioning per split
859    /// Maps visual row index to character position mappings
860    /// Used to translate screen coordinates to buffer byte positions
861    pub view_line_mappings: HashMap<LeafId, Vec<ViewLineMapping>>,
862    /// Settings modal layout for hit testing
863    pub settings_layout: Option<crate::view::settings::SettingsLayout>,
864    /// Status bar area (row, x, width)
865    pub status_bar_area: Option<(u16, u16, u16)>,
866    /// Status bar LSP indicator area (row, start_col, end_col)
867    pub status_bar_lsp_area: Option<(u16, u16, u16)>,
868    /// Status bar warning badge area (row, start_col, end_col)
869    pub status_bar_warning_area: Option<(u16, u16, u16)>,
870    /// Status bar line ending indicator area (row, start_col, end_col)
871    pub status_bar_line_ending_area: Option<(u16, u16, u16)>,
872    /// Status bar encoding indicator area (row, start_col, end_col)
873    pub status_bar_encoding_area: Option<(u16, u16, u16)>,
874    /// Status bar language indicator area (row, start_col, end_col)
875    pub status_bar_language_area: Option<(u16, u16, u16)>,
876    /// Status bar message area (row, start_col, end_col) - clickable to show status log
877    pub status_bar_message_area: Option<(u16, u16, u16)>,
878    /// Search options layout for checkbox hit testing
879    pub search_options_layout: Option<crate::view::ui::status_bar::SearchOptionsLayout>,
880    /// Menu bar layout for hit testing
881    pub menu_layout: Option<crate::view::ui::menu::MenuLayout>,
882    /// Last frame dimensions — used by recompute_layout for macro replay
883    pub last_frame_width: u16,
884    pub last_frame_height: u16,
885    /// Per-cell theme key provenance recorded during rendering.
886    /// Flat vec indexed as `row * width + col` where `width = last_frame_width`.
887    pub cell_theme_map: Vec<CellThemeInfo>,
888}
889
890impl CachedLayout {
891    /// Reset the cell theme map for a new frame
892    pub fn reset_cell_theme_map(&mut self) {
893        let total = self.last_frame_width as usize * self.last_frame_height as usize;
894        self.cell_theme_map.clear();
895        self.cell_theme_map.resize(total, CellThemeInfo::default());
896    }
897
898    /// Look up the theme info for a screen position
899    pub fn cell_theme_at(&self, col: u16, row: u16) -> Option<&CellThemeInfo> {
900        let idx = row as usize * self.last_frame_width as usize + col as usize;
901        self.cell_theme_map.get(idx)
902    }
903
904    /// Find which visual row contains the given byte position for a split
905    pub fn find_visual_row(&self, split_id: LeafId, byte_pos: usize) -> Option<usize> {
906        let mappings = self.view_line_mappings.get(&split_id)?;
907        mappings.iter().position(|m| m.contains_byte(byte_pos))
908    }
909
910    /// Get the visual column of a byte position within its visual row
911    pub fn byte_to_visual_column(&self, split_id: LeafId, byte_pos: usize) -> Option<usize> {
912        let mappings = self.view_line_mappings.get(&split_id)?;
913        let row_idx = self.find_visual_row(split_id, byte_pos)?;
914        let row = mappings.get(row_idx)?;
915
916        // Find the visual column that maps to this byte position
917        for (visual_col, &char_idx) in row.visual_to_char.iter().enumerate() {
918            if let Some(source_byte) = row.char_source_bytes.get(char_idx).and_then(|b| *b) {
919                if source_byte == byte_pos {
920                    return Some(visual_col);
921                }
922                // If we've passed the byte position, return previous column
923                if source_byte > byte_pos {
924                    return Some(visual_col.saturating_sub(1));
925                }
926            }
927        }
928        // Byte is at or past end of row - return column after last character
929        // This handles cursor positions at end of line (e.g., after last char before newline)
930        Some(row.visual_to_char.len())
931    }
932
933    /// Move by visual line using the cached mappings
934    /// Returns (new_position, new_visual_column) or None if at boundary
935    pub fn move_visual_line(
936        &self,
937        split_id: LeafId,
938        current_pos: usize,
939        goal_visual_col: usize,
940        direction: i8, // -1 = up, 1 = down
941    ) -> Option<(usize, usize)> {
942        let mappings = self.view_line_mappings.get(&split_id)?;
943        let current_row = self.find_visual_row(split_id, current_pos)?;
944
945        let target_row = if direction < 0 {
946            current_row.checked_sub(1)?
947        } else {
948            let next = current_row + 1;
949            if next >= mappings.len() {
950                return None;
951            }
952            next
953        };
954
955        let target_mapping = mappings.get(target_row)?;
956
957        // Try to get byte at goal visual column.  If the goal column is past
958        // the end of visible content, land at line_end_byte (the newline or
959        // end of buffer).  If the column exists but has no source byte (e.g.
960        // padding on a wrapped continuation line), search outward for the
961        // nearest valid source byte at minimal visual distance.
962        let new_pos = if goal_visual_col >= target_mapping.visual_to_char.len() {
963            target_mapping.line_end_byte
964        } else {
965            target_mapping
966                .source_byte_at_visual_col(goal_visual_col)
967                .or_else(|| target_mapping.nearest_source_byte(goal_visual_col))
968                .unwrap_or(target_mapping.line_end_byte)
969        };
970
971        Some((new_pos, goal_visual_col))
972    }
973
974    /// Get the start byte position of the visual row containing the given byte position.
975    /// If the cursor is already at the visual row start and this is a wrapped continuation,
976    /// moves to the previous visual row's start (within the same logical line).
977    /// Get the start byte position of the visual row containing the given byte position.
978    /// When `allow_advance` is true and the cursor is already at the row start,
979    /// moves to the previous visual row's start.
980    pub fn visual_line_start(
981        &self,
982        split_id: LeafId,
983        byte_pos: usize,
984        allow_advance: bool,
985    ) -> Option<usize> {
986        let mappings = self.view_line_mappings.get(&split_id)?;
987        let row_idx = self.find_visual_row(split_id, byte_pos)?;
988        let row = mappings.get(row_idx)?;
989        let row_start = row.first_source_byte()?;
990
991        if allow_advance && byte_pos == row_start && row_idx > 0 {
992            let prev_row = mappings.get(row_idx - 1)?;
993            prev_row.first_source_byte()
994        } else {
995            Some(row_start)
996        }
997    }
998
999    /// Get the end byte position of the visual row containing the given byte position.
1000    /// If the cursor is already at the visual row end and the next row is a wrapped continuation,
1001    /// moves to the next visual row's end (within the same logical line).
1002    /// Get the end byte position of the visual row containing the given byte position.
1003    /// When `allow_advance` is true and the cursor is already at the row end,
1004    /// advances to the next visual row's end.
1005    pub fn visual_line_end(
1006        &self,
1007        split_id: LeafId,
1008        byte_pos: usize,
1009        allow_advance: bool,
1010    ) -> Option<usize> {
1011        let mappings = self.view_line_mappings.get(&split_id)?;
1012        let row_idx = self.find_visual_row(split_id, byte_pos)?;
1013        let row = mappings.get(row_idx)?;
1014
1015        if allow_advance && byte_pos == row.line_end_byte && row_idx + 1 < mappings.len() {
1016            let next_row = mappings.get(row_idx + 1)?;
1017            Some(next_row.line_end_byte)
1018        } else {
1019            Some(row.line_end_byte)
1020        }
1021    }
1022}
1023
1024/// Convert a file path to an `lsp_types::Uri`.
1025pub fn file_path_to_lsp_uri(path: &Path) -> Option<lsp_types::Uri> {
1026    fresh_core::file_uri::path_to_lsp_uri(path)
1027}
1028
1029#[cfg(test)]
1030mod uri_encoding_tests {
1031    use super::*;
1032
1033    /// Helper to get a platform-appropriate absolute path for testing.
1034    fn abs_path(suffix: &str) -> PathBuf {
1035        std::env::temp_dir().join(suffix)
1036    }
1037
1038    #[test]
1039    fn test_brackets_in_path() {
1040        let path = abs_path("MY_PROJECTS [temp]/gogame/main.go");
1041        let uri = file_path_to_lsp_uri(&path);
1042        assert!(
1043            uri.is_some(),
1044            "URI should be computed for path with brackets"
1045        );
1046        let uri = uri.unwrap();
1047        assert!(
1048            uri.as_str().contains("%5Btemp%5D"),
1049            "Brackets should be percent-encoded: {}",
1050            uri.as_str()
1051        );
1052    }
1053
1054    #[test]
1055    fn test_spaces_in_path() {
1056        let path = abs_path("My Projects/src/main.go");
1057        let uri = file_path_to_lsp_uri(&path);
1058        assert!(uri.is_some(), "URI should be computed for path with spaces");
1059    }
1060
1061    #[test]
1062    fn test_normal_path() {
1063        let path = abs_path("project/main.go");
1064        let uri = file_path_to_lsp_uri(&path);
1065        assert!(uri.is_some(), "URI should be computed for normal path");
1066        let s = uri.unwrap().as_str().to_string();
1067        assert!(s.starts_with("file:///"), "Should be a file URI: {}", s);
1068        assert!(
1069            s.ends_with("project/main.go"),
1070            "Should end with the path: {}",
1071            s
1072        );
1073    }
1074
1075    #[test]
1076    fn test_relative_path_returns_none() {
1077        let path = PathBuf::from("main.go");
1078        assert!(file_path_to_lsp_uri(&path).is_none());
1079    }
1080
1081    #[test]
1082    fn test_all_special_chars() {
1083        let path = abs_path("a[b]c{d}e^g`h/file.rs");
1084        let uri = file_path_to_lsp_uri(&path);
1085        assert!(uri.is_some(), "Should handle all special characters");
1086        let s = uri.unwrap().as_str().to_string();
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        assert!(!s.contains('}'), "}} should be encoded in {}", s);
1091        assert!(!s.contains('^'), "^ should be encoded in {}", s);
1092        assert!(!s.contains('`'), "` should be encoded in {}", s);
1093    }
1094}