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}