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 area (row, x, width)
128 pub status_bar_area: Option<(u16, u16, u16)>,
129 /// Status bar LSP indicator area (row, start_col, end_col)
130 pub status_bar_lsp_area: Option<(u16, u16, u16)>,
131 /// Status bar warning badge area (row, start_col, end_col)
132 pub status_bar_warning_area: Option<(u16, u16, u16)>,
133 /// Status bar line ending indicator area (row, start_col, end_col)
134 pub status_bar_line_ending_area: Option<(u16, u16, u16)>,
135 /// Status bar encoding indicator area (row, start_col, end_col)
136 pub status_bar_encoding_area: Option<(u16, u16, u16)>,
137 /// Status bar language indicator area (row, start_col, end_col)
138 pub status_bar_language_area: Option<(u16, u16, u16)>,
139 /// Status bar message area (row, start_col, end_col) - clickable to show status log
140 pub status_bar_message_area: Option<(u16, u16, u16)>,
141 /// Status bar remote-authority indicator area (row, start_col, end_col)
142 /// — clickable to open the remote-authority context menu.
143 pub status_bar_remote_area: Option<(u16, u16, u16)>,
144 /// Status bar workspace-trust indicator area (row, start_col, end_col)
145 /// — clickable to open the workspace-trust prompt.
146 pub status_bar_trust_area: Option<(u16, u16, u16)>,
147 /// Plugin-registered status-bar token areas, keyed by
148 /// `"<plugin>:<token>"`. Populated by `render_status_bar`; consumed
149 /// by `handle_click_status_bar` which fires the
150 /// `status_bar_token_clicked` hook on a hit so the registering
151 /// plugin can react (typically by re-opening a deferred prompt).
152 /// See `docs/internal/trust-env-devcontainer-ux-plan.md` for the
153 /// design context.
154 pub status_bar_plugin_token_areas: std::collections::HashMap<String, (u16, u16, u16)>,
155 /// Semantic status-bar model (rendered elements + text + positions), captured
156 /// by the renderer so `status_view` derives the web status bar directly
157 /// instead of scraping the drawn cells.
158 pub status_bar_segments: Vec<crate::view::ui::status_bar::StatusSegmentInfo>,
159 /// Search options layout for checkbox hit testing
160 pub search_options_layout: Option<crate::view::ui::status_bar::SearchOptionsLayout>,
161 /// Menu bar layout for hit testing
162 pub menu_layout: Option<crate::view::ui::menu::MenuLayout>,
163 /// Last frame dimensions — used by recompute_layout for macro replay
164 pub last_frame_width: u16,
165 pub last_frame_height: u16,
166 /// Per-cell theme key provenance recorded during rendering.
167 /// Flat vec indexed as `row * width + col` where `width = last_frame_width`.
168 pub cell_theme_map: Vec<CellThemeInfo>,
169}
170
171impl ChromeLayout {
172 /// Reset the cell theme map for a new frame
173 pub fn reset_cell_theme_map(&mut self) {
174 let total = self.last_frame_width as usize * self.last_frame_height as usize;
175 self.cell_theme_map.clear();
176 self.cell_theme_map.resize(total, CellThemeInfo::default());
177 }
178
179 /// Look up the theme info for a screen position
180 pub fn cell_theme_at(&self, col: u16, row: u16) -> Option<&CellThemeInfo> {
181 let idx = row as usize * self.last_frame_width as usize + col as usize;
182 self.cell_theme_map.get(idx)
183 }
184
185 /// Write theme-key runs a chrome renderer captured during paint into the
186 /// per-cell map. The runs carry screen coordinates; cells outside the
187 /// frame are skipped.
188 pub fn apply_theme_runs(&mut self, runs: &[super::theme::ThemeRun]) {
189 let width = self.last_frame_width;
190 super::theme::apply_theme_runs(&mut self.cell_theme_map, width, runs);
191 }
192}
193
194/// Per-window layout cache: hit-test rects for content scoped to a
195/// single window (split panes, tabs, the file explorer, separators,
196/// scrollbars) plus the per-leaf visual-row→source-byte mappings used
197/// by mouse positioning and visual-line motion. Lives on `Window`;
198/// editor-chrome rects live on [`ChromeLayout`].
199#[derive(Debug, Clone, Default)]
200pub(crate) struct WindowLayoutCache {
201 /// File explorer area (if visible)
202 pub file_explorer_area: Option<Rect>,
203 /// Editor content area (excluding file explorer)
204 pub editor_content_area: Option<Rect>,
205 /// Individual split areas with their scrollbar areas and thumb positions
206 /// (split_id, buffer_id, content_rect, scrollbar_rect, thumb_start, thumb_end)
207 pub split_areas: Vec<(LeafId, BufferId, Rect, Rect, usize, usize)>,
208 /// Horizontal scrollbar areas per split
209 /// (split_id, buffer_id, horizontal_scrollbar_rect, max_content_width, thumb_start_col, thumb_end_col)
210 pub horizontal_scrollbar_areas: Vec<(LeafId, BufferId, Rect, usize, usize, usize)>,
211 /// Split separator positions for drag resize
212 /// (container_id, direction, x, y, length)
213 pub separator_areas: Vec<(ContainerId, SplitDirection, u16, u16, u16)>,
214 /// Tab layouts per split for mouse interaction
215 pub tab_layouts: HashMap<LeafId, crate::view::ui::tabs::TabLayout>,
216 /// Close split button hit areas
217 /// (split_id, row, start_col, end_col)
218 pub close_split_areas: Vec<(LeafId, u16, u16, u16)>,
219 /// Maximize split button hit areas
220 /// (split_id, row, start_col, end_col)
221 pub maximize_split_areas: Vec<(LeafId, u16, u16, u16)>,
222 /// View line mappings for accurate mouse click positioning per split
223 /// Maps visual row index to character position mappings
224 /// Used to translate screen coordinates to buffer byte positions
225 pub view_line_mappings: HashMap<LeafId, Vec<ViewLineMapping>>,
226}
227
228impl WindowLayoutCache {
229 /// Find which visual row contains the given byte position for a split
230 pub fn find_visual_row(&self, split_id: LeafId, byte_pos: usize) -> Option<usize> {
231 let mappings = self.view_line_mappings.get(&split_id)?;
232 mappings.iter().position(|m| m.contains_byte(byte_pos))
233 }
234
235 /// Get the visual column of a byte position within its visual row
236 pub fn byte_to_visual_column(&self, split_id: LeafId, byte_pos: usize) -> Option<usize> {
237 let mappings = self.view_line_mappings.get(&split_id)?;
238 let row_idx = self.find_visual_row(split_id, byte_pos)?;
239 let row = mappings.get(row_idx)?;
240
241 // Find the visual column that maps to this byte position
242 for (visual_col, &char_idx) in row.visual_to_char.iter().enumerate() {
243 if let Some(source_byte) = row.char_source_bytes.get(char_idx).and_then(|b| *b) {
244 if source_byte == byte_pos {
245 return Some(visual_col);
246 }
247 // If we've passed the byte position, return previous column
248 if source_byte > byte_pos {
249 return Some(visual_col.saturating_sub(1));
250 }
251 }
252 }
253 // Byte is at or past end of row - return column after last character
254 // This handles cursor positions at end of line (e.g., after last char before newline)
255 Some(row.visual_to_char.len())
256 }
257
258 /// Move by visual line using the cached mappings
259 /// Returns (new_position, new_visual_column) or None if at boundary
260 pub fn move_visual_line(
261 &self,
262 split_id: LeafId,
263 current_pos: usize,
264 goal_visual_col: usize,
265 direction: i8, // -1 = up, 1 = down
266 ) -> Option<(usize, usize)> {
267 let mappings = self.view_line_mappings.get(&split_id)?;
268 let current_row = self.find_visual_row(split_id, current_pos)?;
269
270 // Walk past purely-virtual rows (e.g. markdown_compose table top/
271 // bottom borders and inter-row separators, live-diff deletion
272 // virtual lines). Those rows are plugin-injected and their
273 // `line_end_byte` is inherited from the adjacent content row.
274 // If MoveDown/MoveUp stopped on them the cursor would land on a
275 // byte that's already at the row above's end, which in turn
276 // causes Down-after-table to teleport back to an earlier
277 // position (regression exposed by markdown_compose's table
278 // border feature) or strands the cursor at the previous line's
279 // EOL when a live-diff deletion hunk starts with a blank line
280 // (regression exposed by the live-diff plugin).
281 //
282 // A row is "navigable" iff at least one of its visual columns
283 // maps to a real source byte. Skip entirely-virtual rows in
284 // the move direction until we hit a navigable one or run off
285 // the edge.
286 let mut target_row = current_row;
287 let navigable = |idx: usize| -> bool {
288 mappings
289 .get(idx)
290 .map(|m| m.char_source_bytes.iter().any(|b| b.is_some()))
291 .unwrap_or(false)
292 };
293 loop {
294 target_row = if direction < 0 {
295 target_row.checked_sub(1)?
296 } else {
297 let next = target_row + 1;
298 if next >= mappings.len() {
299 return None;
300 }
301 next
302 };
303 // Either the next row has real source content, or we've reached
304 // a legitimate non-source row that the rest of the editor
305 // already treats as a cursor stop (trailing empty line at EOF,
306 // implicit blank final line, empty source line between
307 // paragraphs). In either case stop walking.
308 if navigable(target_row) {
309 break;
310 }
311 let mapping = mappings.get(target_row)?;
312 if mapping.is_plugin_virtual {
313 // Plugin-injected virtual row (live-diff deletion lines,
314 // markdown_compose table borders, …). Its
315 // `line_end_byte` is inherited from the previous row, so
316 // stopping here would strand the cursor at the previous
317 // source line's EOL. Keep walking.
318 continue;
319 }
320 // Empty mapping that isn't plugin-virtual: a real empty
321 // source line (paragraph separator), the trailing empty
322 // EOF row, or the implicit blank final line. These are
323 // legitimate cursor stops.
324 break;
325 }
326
327 let target_mapping = mappings.get(target_row)?;
328
329 // Try to get byte at goal visual column. If the goal column is past
330 // the end of visible content, land at line_end_byte (the newline or
331 // end of buffer). If the column exists but has no source byte (e.g.
332 // padding on a wrapped continuation line), search outward for the
333 // nearest valid source byte at minimal visual distance.
334 let new_pos = if goal_visual_col >= target_mapping.visual_to_char.len() {
335 target_mapping.line_end_byte
336 } else {
337 target_mapping
338 .source_byte_at_visual_col(goal_visual_col)
339 .or_else(|| target_mapping.nearest_source_byte(goal_visual_col))
340 .unwrap_or(target_mapping.line_end_byte)
341 };
342
343 Some((new_pos, goal_visual_col))
344 }
345
346 /// Get the start byte position of the visual row containing the given byte position.
347 /// If the cursor is already at the visual row start and this is a wrapped continuation,
348 /// moves to the previous visual row's start (within the same logical line).
349 /// Get the start byte position of the visual row containing the given byte position.
350 /// When `allow_advance` is true and the cursor is already at the row start,
351 /// moves to the previous visual row's start.
352 pub fn visual_line_start(
353 &self,
354 split_id: LeafId,
355 byte_pos: usize,
356 allow_advance: bool,
357 ) -> Option<usize> {
358 let mappings = self.view_line_mappings.get(&split_id)?;
359 let row_idx = self.find_visual_row(split_id, byte_pos)?;
360 let row = mappings.get(row_idx)?;
361 let row_start = row.first_source_byte()?;
362
363 if allow_advance && byte_pos == row_start && row_idx > 0 {
364 let prev_row = mappings.get(row_idx - 1)?;
365 prev_row.first_source_byte()
366 } else {
367 Some(row_start)
368 }
369 }
370
371 /// Get the end byte position of the visual row containing the given byte position.
372 /// If the cursor is already at the visual row end and the next row is a wrapped continuation,
373 /// moves to the next visual row's end (within the same logical line).
374 /// Get the end byte position of the visual row containing the given byte position.
375 /// When `allow_advance` is true and the cursor is already at the row end,
376 /// advances to the next visual row's end.
377 pub fn visual_line_end(
378 &self,
379 split_id: LeafId,
380 byte_pos: usize,
381 allow_advance: bool,
382 ) -> Option<usize> {
383 let mappings = self.view_line_mappings.get(&split_id)?;
384 let row_idx = self.find_visual_row(split_id, byte_pos)?;
385 let row = mappings.get(row_idx)?;
386
387 if allow_advance && byte_pos == row.line_end_byte && row_idx + 1 < mappings.len() {
388 let next_row = mappings.get(row_idx + 1)?;
389 Some(next_row.line_end_byte)
390 } else {
391 Some(row.line_end_byte)
392 }
393 }
394}
395
396/// Self-contained state for the Live Grep floating overlay's preview
397/// pane (issue #1796).
398///
399/// Owned directly by `Editor::overlay_preview_state` rather than
400/// living in `Editor::split_view_states` keyed by a synthetic
401/// `LeafId`. This isolation matters because ~20 sites across the
402/// editor iterate `split_view_states` for cross-cutting work
403/// (workspace save, viewport hooks, settings broadcasts, buffer
404/// close cascades). The preview is a *transient render artefact*,
405/// not a real split — none of those code paths should see it.
406///
407/// The phantom buffer is not in `SplitManager`'s tree either, so
408/// it's invisible to focus rotation (`Alt+]`/`Alt+[`), tab drag
409/// drop zones, hit testing, and `find_leaf_by_role` queries.
410#[derive(Debug)]
411pub struct OverlayPreviewState {
412 /// Buffer currently displayed in the preview pane.
413 pub buffer_id: BufferId,
414 /// View state (cursor, viewport, folds, view mode, …) used by
415 /// the renderer's per-leaf pipeline.
416 pub view_state: crate::view::split::SplitViewState,
417 /// Buffers we loaded only to feed the preview pane. On overlay
418 /// close we close these via the standard `close_buffer` path.
419 /// Buffers the user already had open are *not* in this set —
420 /// dismissing the overlay never disturbs them.
421 pub loaded_buffers: HashSet<BufferId>,
422 /// When true, the preview pane renders empty (just its frame). Set
423 /// when the current query has no selectable result so a stale match
424 /// doesn't keep showing after the result list clears. Kept as a flag
425 /// (rather than dropping the whole state) so `loaded_buffers` stays
426 /// tracked for cleanup and the buffer can be re-shown on the next
427 /// match without reloading.
428 pub blanked: bool,
429 /// The match byte-offset the preview viewport was last centred on
430 /// (issue #2119). The renderer recentres only when this changes (a new
431 /// selected result), so a mouse-wheel scroll of the preview isn't undone
432 /// by the next frame's recenter.
433 pub centered_byte: Option<usize>,
434}