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