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