Skip to main content

fresh/app/
types.rs

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