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