Skip to main content

fresh/app/types/
layout.rs

1use super::theme::CellThemeInfo;
2use crate::model::event::{BufferId, ContainerId, LeafId, SplitDirection};
3use ratatui::layout::Rect;
4use std::collections::{HashMap, HashSet};
5
6/// Mapping from visual row to buffer positions for mouse click handling
7/// Each entry represents one visual row with byte position info for click handling
8#[derive(Debug, Clone, Default)]
9pub struct ViewLineMapping {
10    /// Source byte offset for each character (None for injected/virtual content)
11    pub char_source_bytes: Vec<Option<usize>>,
12    /// Character index at each visual column (for O(1) mouse clicks)
13    pub visual_to_char: Vec<usize>,
14    /// Last valid byte position in this visual row (newline for real lines, last char for wrapped)
15    /// Clicks past end of visible text position cursor here
16    pub line_end_byte: usize,
17    /// True iff this visual row was rendered for a plugin-injected
18    /// virtual line (live-diff deletion overlays, markdown_compose
19    /// borders, …) rather than for actual buffer content. Used by
20    /// `move_visual_line` to skip past these rows without stranding
21    /// the cursor on a position whose `line_end_byte` was inherited
22    /// from the previous source row.
23    pub is_plugin_virtual: bool,
24}
25
26impl ViewLineMapping {
27    /// Get source byte at a given visual column (O(1) for mouse clicks)
28    #[inline]
29    pub fn source_byte_at_visual_col(&self, visual_col: usize) -> Option<usize> {
30        let char_idx = self.visual_to_char.get(visual_col).copied()?;
31        self.char_source_bytes.get(char_idx).copied().flatten()
32    }
33
34    /// Find the nearest source byte to a given visual column, searching outward.
35    /// Returns the source byte at the closest valid visual column.
36    pub fn nearest_source_byte(&self, goal_col: usize) -> Option<usize> {
37        let width = self.visual_to_char.len();
38        if width == 0 {
39            return None;
40        }
41        // Search outward from goal_col: try +1, -1, +2, -2, ...
42        for delta in 1..width {
43            if goal_col + delta < width {
44                if let Some(byte) = self.source_byte_at_visual_col(goal_col + delta) {
45                    return Some(byte);
46                }
47            }
48            if delta <= goal_col {
49                if let Some(byte) = self.source_byte_at_visual_col(goal_col - delta) {
50                    return Some(byte);
51                }
52            }
53        }
54        None
55    }
56
57    /// Check if this visual row contains the given byte position
58    #[inline]
59    pub fn contains_byte(&self, byte_pos: usize) -> bool {
60        // A row contains a byte if it's in the char_source_bytes range
61        // The first valid source byte marks the start, line_end_byte marks the end
62        if let Some(first_byte) = self.char_source_bytes.iter().find_map(|b| *b) {
63            byte_pos >= first_byte && byte_pos <= self.line_end_byte
64        } else {
65            // Empty/virtual row - only matches if byte_pos equals line_end_byte
66            byte_pos == self.line_end_byte
67        }
68    }
69
70    /// Get the first source byte position in this row (if any)
71    #[inline]
72    pub fn first_source_byte(&self) -> Option<usize> {
73        self.char_source_bytes.iter().find_map(|b| *b)
74    }
75}
76
77/// Type alias for popup area layout information used in mouse hit testing.
78/// Fields: (popup_index, rect, inner_rect, scroll_offset, num_items, scrollbar_rect, total_lines)
79pub(crate) type PopupAreaLayout = (usize, Rect, Rect, usize, usize, Option<Rect>, usize);
80
81/// Editor-chrome layout cache: full-frame and chrome-region rects
82/// (status bar, menu bar, prompt overlay, popups) plus the screen-
83/// indexed cell-theme map. Per-window layout (split-leaf rects, tab
84/// rects, file-explorer rects, view-line mappings) lives on
85/// [`WindowLayoutCache`] instead.
86#[derive(Debug, Clone, Default)]
87pub(crate) struct ChromeLayout {
88    /// Popup areas for mouse hit testing
89    /// scrollbar_rect is Some if popup has a scrollbar
90    pub popup_areas: Vec<PopupAreaLayout>,
91    /// Editor-level popup areas (e.g. plugin action popups) for mouse hit
92    /// testing. Stored separately from buffer popups because they're owned by
93    /// `Editor.global_popups` rather than the active buffer's state.
94    /// Fields: (popup_index, rect, inner_rect, scroll_offset, num_items)
95    pub global_popup_areas: Vec<(usize, Rect, Rect, usize, usize)>,
96    /// Suggestions area for mouse hit testing
97    /// (inner_rect, scroll_start_idx, visible_count, total_count)
98    pub suggestions_area: Option<(Rect, usize, usize, usize)>,
99    /// Full outer rect of the suggestions popup (including borders).
100    /// Used to absorb clicks on the popup chrome so they don't reach the
101    /// buffer below while the prompt is open.
102    pub suggestions_outer_area: Option<Rect>,
103    /// Hit-test rect for the floating-overlay prompt's scrollbar
104    /// (issue #1796). `None` when no overlay is open or the result
105    /// list fits in the visible window. Click/drag handlers in
106    /// `mouse_input.rs` read this to update `prompt.scroll_offset`.
107    pub suggestions_scrollbar_rect: Option<Rect>,
108    /// Hit rects for the floating-overlay prompt's widget toolbar, as
109    /// (widget_key, screen_rect) pairs. Populated when the prompt carries a
110    /// `toolbar_widget`; a click inside one fires the matching
111    /// `live_grep_toggle_<key>` action. Empty otherwise.
112    pub prompt_toolbar_hits: Vec<(String, Rect)>,
113    /// Screen rect of the floating-overlay prompt's results list (issue
114    /// #2119). `None` when no overlay is open. The mouse-wheel handler reads
115    /// this to scroll the result list (without moving the selection) when the
116    /// pointer is over it.
117    pub prompt_results_area: Option<Rect>,
118    /// Screen rect of the floating-overlay prompt's preview pane (issue
119    /// #2119). `None` when no overlay is open or the overlay is too narrow to
120    /// show a preview. The mouse-wheel handler reads this to scroll the
121    /// preview (rather than the result list) when the pointer is over it.
122    pub prompt_preview_area: Option<Rect>,
123    /// Settings modal layout for hit testing
124    pub settings_layout: Option<crate::view::settings::SettingsLayout>,
125    /// Workspace-trust dialog click layout (radios + OK/Quit) for hit testing.
126    pub workspace_trust_dialog: Option<crate::view::workspace_trust_dialog::TrustDialogLayout>,
127    /// Status-bar hit-test layout (area, clickable segments, plugin token
128    /// areas, semantic segment model). See [`StatusBarChrome`].
129    pub status_bar: StatusBarChrome,
130    /// Search options layout for checkbox hit testing
131    pub search_options_layout: Option<crate::view::ui::status_bar::SearchOptionsLayout>,
132    /// Menu bar layout for hit testing
133    pub menu_layout: Option<crate::view::ui::menu::MenuLayout>,
134    /// Dimensions of the last rendered frame. See [`FrameDimensions`].
135    pub last_frame: FrameDimensions,
136    /// Per-cell theme key provenance recorded during rendering.
137    /// Flat vec indexed as `row * width + col` where `width = last_frame.width`.
138    pub cell_theme_map: Vec<CellThemeInfo>,
139}
140
141/// Width and height of the most recently rendered frame. Used to size the
142/// cell-theme map and to clamp / replay layout against the latest frame
143/// extent (macro replay, dock/overlay sizing). Grouped so the pair travels
144/// together rather than as loose `last_frame_*` members of [`ChromeLayout`].
145#[derive(Debug, Clone, Copy, Default)]
146pub(crate) struct FrameDimensions {
147    pub width: u16,
148    pub height: u16,
149}
150
151/// Status-bar hit-test layout captured each frame by `render_status_bar`.
152/// Grouped so the four related fields travel together rather than as loose
153/// `status_bar_*` members of [`ChromeLayout`].
154#[derive(Debug, Clone, Default)]
155pub(crate) struct StatusBarChrome {
156    /// Status bar area (row, x, width)
157    pub area: Option<(u16, u16, u16)>,
158    /// Every clickable built-in status-bar segment drawn last frame, as
159    /// `(id, row, start_col, end_col)`. One generic list (mirroring
160    /// `StatusBarLayout::clickable`) walked by both the hover hit-test and
161    /// `handle_click_status_bar` — no per-indicator field. See
162    /// `StatusBarClickable`.
163    pub clickable: Vec<(
164        crate::view::ui::status_bar::StatusBarClickable,
165        u16,
166        u16,
167        u16,
168    )>,
169    /// Plugin-registered status-bar token areas, keyed by
170    /// `"<plugin>:<token>"`. Populated by `render_status_bar`; consumed
171    /// by `handle_click_status_bar` which fires the
172    /// `status_bar_token_clicked` hook on a hit so the registering
173    /// plugin can react (typically by re-opening a deferred prompt).
174    /// See `docs/internal/trust-env-devcontainer-ux-plan.md` for the
175    /// design context.
176    pub plugin_token_areas: std::collections::HashMap<String, (u16, u16, u16)>,
177    /// Semantic status-bar model (rendered elements + text + positions), captured
178    /// by the renderer so `status_view` derives the web status bar directly
179    /// instead of scraping the drawn cells.
180    pub segments: Vec<crate::view::ui::status_bar::StatusSegmentInfo>,
181}
182
183impl StatusBarChrome {
184    /// Screen area `(row, start_col, end_col)` of a given clickable status-bar
185    /// segment from the last frame, if it was drawn. Used to anchor popups to
186    /// their indicator (e.g. the LSP / remote / read-only menus).
187    pub fn clickable_area(
188        &self,
189        id: crate::view::ui::status_bar::StatusBarClickable,
190    ) -> Option<(u16, u16, u16)> {
191        self.clickable
192            .iter()
193            .find(|(cid, _, _, _)| *cid == id)
194            .map(|(_, row, start, end)| (*row, *start, *end))
195    }
196}
197
198impl ChromeLayout {
199    /// Reset the cell theme map for a new frame
200    pub fn reset_cell_theme_map(&mut self) {
201        let total = self.last_frame.width as usize * self.last_frame.height as usize;
202        self.cell_theme_map.clear();
203        self.cell_theme_map.resize(total, CellThemeInfo::default());
204    }
205
206    /// Look up the theme info for a screen position
207    pub fn cell_theme_at(&self, col: u16, row: u16) -> Option<&CellThemeInfo> {
208        let idx = row as usize * self.last_frame.width as usize + col as usize;
209        self.cell_theme_map.get(idx)
210    }
211
212    /// Write theme-key runs a chrome renderer captured during paint into the
213    /// per-cell map. The runs carry screen coordinates; cells outside the
214    /// frame are skipped.
215    pub fn apply_theme_runs(&mut self, runs: &[super::theme::ThemeRun]) {
216        let width = self.last_frame.width;
217        super::theme::apply_theme_runs(&mut self.cell_theme_map, width, runs);
218    }
219}
220
221/// Per-window layout cache: hit-test rects for content scoped to a
222/// single window (split panes, tabs, the file explorer, separators,
223/// scrollbars) plus the per-leaf visual-row→source-byte mappings used
224/// by mouse positioning and visual-line motion. Lives on `Window`;
225/// editor-chrome rects live on [`ChromeLayout`].
226#[derive(Debug, Clone, Default)]
227pub(crate) struct WindowLayoutCache {
228    /// File explorer area (if visible)
229    pub file_explorer_area: Option<Rect>,
230    /// Editor content area (excluding file explorer)
231    pub editor_content_area: Option<Rect>,
232    /// Individual split areas with their scrollbar areas and thumb positions
233    /// (split_id, buffer_id, content_rect, scrollbar_rect, thumb_start, thumb_end)
234    pub split_areas: Vec<(LeafId, BufferId, Rect, Rect, usize, usize)>,
235    /// Horizontal scrollbar areas per split
236    /// (split_id, buffer_id, horizontal_scrollbar_rect, max_content_width, thumb_start_col, thumb_end_col)
237    pub horizontal_scrollbar_areas: Vec<(LeafId, BufferId, Rect, usize, usize, usize)>,
238    /// Split separator positions for drag resize
239    /// (container_id, direction, x, y, length)
240    pub separator_areas: Vec<(ContainerId, SplitDirection, u16, u16, u16)>,
241    /// Tab layouts per split for mouse interaction
242    pub tab_layouts: HashMap<LeafId, crate::view::ui::tabs::TabLayout>,
243    /// Close split button hit areas
244    /// (split_id, row, start_col, end_col)
245    pub close_split_areas: Vec<(LeafId, u16, u16, u16)>,
246    /// Maximize split button hit areas
247    /// (split_id, row, start_col, end_col)
248    pub maximize_split_areas: Vec<(LeafId, u16, u16, u16)>,
249    /// View line mappings for accurate mouse click positioning per split
250    /// Maps visual row index to character position mappings
251    /// Used to translate screen coordinates to buffer byte positions
252    pub view_line_mappings: HashMap<LeafId, Vec<ViewLineMapping>>,
253}
254
255impl WindowLayoutCache {
256    /// Find which visual row contains the given byte position for a split
257    pub fn find_visual_row(&self, split_id: LeafId, byte_pos: usize) -> Option<usize> {
258        let mappings = self.view_line_mappings.get(&split_id)?;
259        mappings.iter().position(|m| m.contains_byte(byte_pos))
260    }
261
262    /// Get the visual column of a byte position within its visual row
263    pub fn byte_to_visual_column(&self, split_id: LeafId, byte_pos: usize) -> Option<usize> {
264        let mappings = self.view_line_mappings.get(&split_id)?;
265        let row_idx = self.find_visual_row(split_id, byte_pos)?;
266        let row = mappings.get(row_idx)?;
267
268        // Find the visual column that maps to this byte position
269        for (visual_col, &char_idx) in row.visual_to_char.iter().enumerate() {
270            if let Some(source_byte) = row.char_source_bytes.get(char_idx).and_then(|b| *b) {
271                if source_byte == byte_pos {
272                    return Some(visual_col);
273                }
274                // If we've passed the byte position, return previous column
275                if source_byte > byte_pos {
276                    return Some(visual_col.saturating_sub(1));
277                }
278            }
279        }
280        // Byte is at or past end of row - return column after last character
281        // This handles cursor positions at end of line (e.g., after last char before newline)
282        Some(row.visual_to_char.len())
283    }
284
285    /// Move by visual line using the cached mappings
286    /// Returns (new_position, new_visual_column) or None if at boundary
287    pub fn move_visual_line(
288        &self,
289        split_id: LeafId,
290        current_pos: usize,
291        goal_visual_col: usize,
292        direction: i8, // -1 = up, 1 = down
293    ) -> Option<(usize, usize)> {
294        let mappings = self.view_line_mappings.get(&split_id)?;
295        let current_row = self.find_visual_row(split_id, current_pos)?;
296
297        // Walk past purely-virtual rows (e.g. markdown_compose table top/
298        // bottom borders and inter-row separators, live-diff deletion
299        // virtual lines).  Those rows are plugin-injected and their
300        // `line_end_byte` is inherited from the adjacent content row.
301        // If MoveDown/MoveUp stopped on them the cursor would land on a
302        // byte that's already at the row above's end, which in turn
303        // causes Down-after-table to teleport back to an earlier
304        // position (regression exposed by markdown_compose's table
305        // border feature) or strands the cursor at the previous line's
306        // EOL when a live-diff deletion hunk starts with a blank line
307        // (regression exposed by the live-diff plugin).
308        //
309        // A row is "navigable" iff at least one of its visual columns
310        // maps to a real source byte.  Skip entirely-virtual rows in
311        // the move direction until we hit a navigable one or run off
312        // the edge.
313        let mut target_row = current_row;
314        let navigable = |idx: usize| -> bool {
315            mappings
316                .get(idx)
317                .map(|m| m.char_source_bytes.iter().any(|b| b.is_some()))
318                .unwrap_or(false)
319        };
320        loop {
321            target_row = if direction < 0 {
322                target_row.checked_sub(1)?
323            } else {
324                let next = target_row + 1;
325                if next >= mappings.len() {
326                    return None;
327                }
328                next
329            };
330            // Either the next row has real source content, or we've reached
331            // a legitimate non-source row that the rest of the editor
332            // already treats as a cursor stop (trailing empty line at EOF,
333            // implicit blank final line, empty source line between
334            // paragraphs).  In either case stop walking.
335            if navigable(target_row) {
336                break;
337            }
338            let mapping = mappings.get(target_row)?;
339            if mapping.is_plugin_virtual {
340                // Plugin-injected virtual row (live-diff deletion lines,
341                // markdown_compose table borders, …).  Its
342                // `line_end_byte` is inherited from the previous row, so
343                // stopping here would strand the cursor at the previous
344                // source line's EOL.  Keep walking.
345                continue;
346            }
347            // Empty mapping that isn't plugin-virtual: a real empty
348            // source line (paragraph separator), the trailing empty
349            // EOF row, or the implicit blank final line.  These are
350            // legitimate cursor stops.
351            break;
352        }
353
354        let target_mapping = mappings.get(target_row)?;
355
356        // Try to get byte at goal visual column.  If the goal column is past
357        // the end of visible content, land at line_end_byte (the newline or
358        // end of buffer).  If the column exists but has no source byte (e.g.
359        // padding on a wrapped continuation line), search outward for the
360        // nearest valid source byte at minimal visual distance.
361        let new_pos = if goal_visual_col >= target_mapping.visual_to_char.len() {
362            target_mapping.line_end_byte
363        } else {
364            target_mapping
365                .source_byte_at_visual_col(goal_visual_col)
366                .or_else(|| target_mapping.nearest_source_byte(goal_visual_col))
367                .unwrap_or(target_mapping.line_end_byte)
368        };
369
370        Some((new_pos, goal_visual_col))
371    }
372
373    /// Get the start byte position of the visual row containing the given byte position.
374    /// If the cursor is already at the visual row start and this is a wrapped continuation,
375    /// moves to the previous visual row's start (within the same logical line).
376    /// Get the start byte position of the visual row containing the given byte position.
377    /// When `allow_advance` is true and the cursor is already at the row start,
378    /// moves to the previous visual row's start.
379    pub fn visual_line_start(
380        &self,
381        split_id: LeafId,
382        byte_pos: usize,
383        allow_advance: bool,
384    ) -> Option<usize> {
385        let mappings = self.view_line_mappings.get(&split_id)?;
386        let row_idx = self.find_visual_row(split_id, byte_pos)?;
387        let row = mappings.get(row_idx)?;
388        let row_start = row.first_source_byte()?;
389
390        if allow_advance && byte_pos == row_start && row_idx > 0 {
391            let prev_row = mappings.get(row_idx - 1)?;
392            prev_row.first_source_byte()
393        } else {
394            Some(row_start)
395        }
396    }
397
398    /// Get the end byte position of the visual row containing the given byte position.
399    /// If the cursor is already at the visual row end and the next row is a wrapped continuation,
400    /// moves to the next visual row's end (within the same logical line).
401    /// Get the end byte position of the visual row containing the given byte position.
402    /// When `allow_advance` is true and the cursor is already at the row end,
403    /// advances to the next visual row's end.
404    pub fn visual_line_end(
405        &self,
406        split_id: LeafId,
407        byte_pos: usize,
408        allow_advance: bool,
409    ) -> Option<usize> {
410        let mappings = self.view_line_mappings.get(&split_id)?;
411        let row_idx = self.find_visual_row(split_id, byte_pos)?;
412        let row = mappings.get(row_idx)?;
413
414        if allow_advance && byte_pos == row.line_end_byte && row_idx + 1 < mappings.len() {
415            let next_row = mappings.get(row_idx + 1)?;
416            Some(next_row.line_end_byte)
417        } else {
418            Some(row.line_end_byte)
419        }
420    }
421}
422
423/// Self-contained state for the Live Grep floating overlay's preview
424/// pane (issue #1796).
425///
426/// Owned directly by `Editor::overlay_preview_state` rather than
427/// living in `Editor::split_view_states` keyed by a synthetic
428/// `LeafId`. This isolation matters because ~20 sites across the
429/// editor iterate `split_view_states` for cross-cutting work
430/// (workspace save, viewport hooks, settings broadcasts, buffer
431/// close cascades). The preview is a *transient render artefact*,
432/// not a real split — none of those code paths should see it.
433///
434/// The phantom buffer is not in `SplitManager`'s tree either, so
435/// it's invisible to focus rotation (`Alt+]`/`Alt+[`), tab drag
436/// drop zones, hit testing, and `find_leaf_by_role` queries.
437#[derive(Debug)]
438pub struct OverlayPreviewState {
439    /// Buffer currently displayed in the preview pane.
440    pub buffer_id: BufferId,
441    /// View state (cursor, viewport, folds, view mode, …) used by
442    /// the renderer's per-leaf pipeline.
443    pub view_state: crate::view::split::SplitViewState,
444    /// Buffers we loaded only to feed the preview pane. On overlay
445    /// close we close these via the standard `close_buffer` path.
446    /// Buffers the user already had open are *not* in this set —
447    /// dismissing the overlay never disturbs them.
448    pub loaded_buffers: HashSet<BufferId>,
449    /// When true, the preview pane renders empty (just its frame). Set
450    /// when the current query has no selectable result so a stale match
451    /// doesn't keep showing after the result list clears. Kept as a flag
452    /// (rather than dropping the whole state) so `loaded_buffers` stays
453    /// tracked for cleanup and the buffer can be re-shown on the next
454    /// match without reloading.
455    pub blanked: bool,
456    /// The match byte-offset the preview viewport was last centred on
457    /// (issue #2119). The renderer recentres only when this changes (a new
458    /// selected result), so a mouse-wheel scroll of the preview isn't undone
459    /// by the next frame's recenter.
460    pub centered_byte: Option<usize>,
461}