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