fresh/app/render.rs
1use super::lsp_status::compose_lsp_status;
2use super::*;
3use crate::config::FileExplorerSide;
4
5impl Editor {
6 /// Render the topmost global popup at its computed area and register its
7 /// click region in `global_popup_areas`. Shared by the generic
8 /// global-popup slot and the workspace-trust modal band so the area math
9 /// lives in exactly one place.
10 fn render_top_global_popup(
11 &mut self,
12 frame: &mut Frame,
13 size: ratatui::layout::Rect,
14 theme: &crate::view::theme::Theme,
15 hover_target: Option<&crate::app::HoverTarget>,
16 ) {
17 let Some(popup) = self.global_popups.top() else {
18 return;
19 };
20 let top_idx = self.global_popups.all().len() - 1;
21 let popup_area = popup.calculate_area(size, None);
22 let desc_height = popup.description_height();
23 let inner_area = if popup.bordered {
24 ratatui::layout::Rect {
25 x: popup_area.x + 1,
26 y: popup_area.y + 1 + desc_height,
27 width: popup_area.width.saturating_sub(2),
28 height: popup_area.height.saturating_sub(2 + desc_height),
29 }
30 } else {
31 ratatui::layout::Rect {
32 x: popup_area.x,
33 y: popup_area.y + desc_height,
34 width: popup_area.width,
35 height: popup_area.height.saturating_sub(desc_height),
36 }
37 };
38 let num_items = match &popup.content {
39 crate::view::popup::PopupContent::List { items, .. } => items.len(),
40 _ => 0,
41 };
42 let scroll_offset = popup.scroll_offset;
43 popup.render_with_hover(frame, popup_area, theme, hover_target);
44 self.active_chrome_mut().global_popup_areas.push((
45 top_idx,
46 popup_area,
47 inner_area,
48 scroll_offset,
49 num_items,
50 ));
51 }
52
53 /// Render the editor to the terminal
54 pub fn render(&mut self, frame: &mut Frame) {
55 let _span = tracing::info_span!("render").entered();
56 let size = frame.area();
57
58 self.drain_pre_layout_plugin_commands();
59
60 for window in self.windows.values_mut() {
61 window.sync_terminal_titles();
62 }
63
64 // Carve a full-height left column for a docked floating panel
65 // (e.g. the orchestrator dock) out of the screen *before* the
66 // chrome lays itself out, so the menu bar, splits, and status
67 // bar all sit to the dock's right. `chrome_area` is the region
68 // the rest of `render` lays into; `dock_area` (if any) is
69 // painted last alongside the centered-overlay path.
70 let (dock_area, chrome_area) = self.compute_dock_split(size);
71
72 // Let active animations snapshot the previous frame's buffer
73 // from the runner's own cache. We can't read the live
74 // `frame.buffer_mut()` — ratatui resets it before each draw —
75 // so the runner keeps a post-apply clone from the last frame.
76 self.active_window_mut().animations.capture_before_all();
77
78 // Save frame dimensions for recompute_layout (used by macro replay)
79 self.active_chrome_mut().last_frame_width = size.width;
80 self.active_chrome_mut().last_frame_height = size.height;
81
82 // Reset per-cell theme key map for this frame
83 self.active_chrome_mut().reset_cell_theme_map();
84
85 self.pre_sync_and_scroll_sync();
86
87 // NOTE: Viewport sync with cursor is handled by split_rendering.rs which knows the
88 // correct content area dimensions. Don't sync here with incorrect EditorState viewport size.
89
90 self.request_semantic_ranges_for_visible_splits();
91
92 self.prepare_visible_buffers_for_render();
93
94 // Refresh search highlights only during incremental search (when prompt is active)
95 // After search is confirmed, overlays exist for ALL matches and shouldn't be overwritten
96 let is_search_prompt_active = self.active_window().prompt.as_ref().is_some_and(|p| {
97 matches!(
98 p.prompt_type,
99 PromptType::Search | PromptType::ReplaceSearch | PromptType::QueryReplaceSearch
100 )
101 });
102 if is_search_prompt_active {
103 if let Some(ref search_state) = self.active_window().search_state {
104 let query = search_state.query.clone();
105 self.update_search_highlights(&query);
106 }
107 }
108
109 // Determine if we need to show search options bar.
110 // (Held in mutable bindings because the in-render
111 // `process_commands` block below can dispatch commands —
112 // e.g. `StartPromptAsync`, `SetPromptSuggestions` — that
113 // mutate `self.active_window_mut().prompt`. When that happens we recompute these
114 // flags and re-split `main_chunks` so the bottom-row
115 // rendering uses an up-to-date layout. See the
116 // "Recompute layout if mid-render commands changed state"
117 // block below.)
118 let mut show_search_options = self.active_window().prompt.as_ref().is_some_and(|p| {
119 matches!(
120 p.prompt_type,
121 PromptType::Search
122 | PromptType::ReplaceSearch
123 | PromptType::Replace { .. }
124 | PromptType::QueryReplaceSearch
125 | PromptType::QueryReplace { .. }
126 )
127 });
128
129 // Hide status bar when suggestions popup or file browser
130 // popup is shown — those popups float just above the prompt
131 // line, and a visible status bar wedged between them looks
132 // wrong. Floating-overlay prompts (Live Grep, issue #1796)
133 // are exempt because their suggestions live inside the
134 // centred frame, not above the bottom row.
135 let mut prompt_is_overlay = self
136 .active_window()
137 .prompt
138 .as_ref()
139 .is_some_and(|p| p.overlay);
140 let mut has_suggestions = self
141 .active_window()
142 .prompt
143 .as_ref()
144 .is_some_and(|p| !p.suggestions.is_empty())
145 && !prompt_is_overlay;
146 let mut has_file_browser = self.active_window().prompt.as_ref().is_some_and(|p| {
147 matches!(
148 p.prompt_type,
149 PromptType::OpenFile | PromptType::SwitchProject | PromptType::SaveFileAs
150 )
151 }) && self.active_window_mut().file_open_state.is_some();
152
153 // Build main vertical layout: [menu_bar, main_content, status_bar, search_options, prompt_line]
154 // Status bar is hidden when suggestions popup is shown
155 // Search options bar is shown when in search prompt
156 let mut main_chunks = Layout::default()
157 .direction(Direction::Vertical)
158 .constraints(vec![
159 Constraint::Length(if self.active_window_mut().menu_bar_visible {
160 1
161 } else {
162 0
163 }), // Menu bar
164 Constraint::Min(0), // Main content area
165 Constraint::Length(
166 if !self.active_window_mut().status_bar_visible
167 || has_suggestions
168 || has_file_browser
169 {
170 0
171 } else {
172 1
173 },
174 ), // Status bar (hidden when toggled off or with popups)
175 Constraint::Length(if show_search_options { 1 } else { 0 }), // Search options bar
176 Constraint::Length(
177 // Prompt line is auto-hidden when no prompt active.
178 // Overlay prompts (Live Grep, issue #1796) host the
179 // input row inside the centred frame, so the
180 // bottom row stays available for editor content
181 // rather than being reserved as dead space.
182 if (self.active_window_mut().prompt_line_visible
183 || self.active_window().prompt.is_some())
184 && !prompt_is_overlay
185 {
186 1
187 } else {
188 0
189 },
190 ), // Prompt line
191 ])
192 .split(chrome_area);
193
194 let menu_bar_area = main_chunks[0];
195 let main_content_area = main_chunks[1];
196 let status_bar_idx = 2;
197 let search_options_idx = 3;
198 let prompt_line_idx = 4;
199
200 // Split main content area based on file explorer visibility
201 // Also keep the layout split if a sync is in progress (to avoid flicker)
202 let editor_content_area;
203 let file_explorer_should_show = self.file_explorer_visible()
204 && (self.file_explorer().is_some()
205 || self.active_window().file_explorer_sync_in_progress);
206
207 if file_explorer_should_show {
208 // Split horizontally based on side placement
209 tracing::trace!(
210 "render: file explorer layout active (present={}, sync_in_progress={}, side={:?})",
211 self.file_explorer().is_some(),
212 self.active_window().file_explorer_sync_in_progress,
213 self.active_window().file_explorer_side
214 );
215 let explorer_cols = self
216 .active_window()
217 .file_explorer_width
218 .to_cols(main_content_area.width);
219
220 let (explorer_area, editor_area) = match self.active_window().file_explorer_side {
221 FileExplorerSide::Left => {
222 let chunks = Layout::default()
223 .direction(Direction::Horizontal)
224 .constraints([Constraint::Length(explorer_cols), Constraint::Min(0)])
225 .split(main_content_area);
226 (chunks[0], chunks[1])
227 }
228 FileExplorerSide::Right => {
229 let chunks = Layout::default()
230 .direction(Direction::Horizontal)
231 .constraints([Constraint::Min(0), Constraint::Length(explorer_cols)])
232 .split(main_content_area);
233 (chunks[1], chunks[0])
234 }
235 };
236
237 self.active_layout_mut().file_explorer_area = Some(explorer_area);
238 editor_content_area = editor_area;
239
240 // Get connection string before mutable borrow of file_explorer.
241 let remote_connection = self.connection_display_string();
242
243 // Render file explorer (only if we have it - during sync we just keep the area reserved).
244 // Uses direct `self.windows.get_mut(...)` (not `file_explorer_mut()`) so the body
245 // can keep reading other Editor fields (buffers, theme, keybindings, …) — Rust
246 // splits the borrow on `self.windows` from the borrows on those other fields.
247 let active_id = self.active_window;
248 // Read window-state inputs before taking the &mut borrow on the
249 // window for the explorer/buffer access below.
250 // The explorer reads as focused only when it actually owns the
251 // keyboard — not when a focused orchestrator dock has stolen it
252 // out from under the (still-FileExplorer) window context. Without
253 // this guard the explorer keeps its accent border while the dock
254 // is driving, making it ambiguous which panel is focused.
255 let is_focused = self.active_window().key_context == KeyContext::FileExplorer
256 && !self.dock.as_ref().is_some_and(|d| d.focused);
257 let key_context_clone = self.active_window().key_context.clone();
258 let close_button_hovered = matches!(
259 &self.active_window().mouse_state.hover_target,
260 Some(HoverTarget::FileExplorerCloseButton)
261 );
262 let slot_resolver = self.file_explorer_slot_resolver();
263 // Take one &mut on the active window; the explorer + buffers
264 // come from disjoint sub-fields so they can coexist.
265 let __win = self
266 .windows
267 .get_mut(&active_id)
268 .expect("active window must exist");
269 let __buffers_ref: &crate::app::window::WindowBuffers = &__win.buffers;
270 if let Some(explorer) = __win.file_explorer.as_mut() {
271 // Build set of files with unsaved changes
272 let mut files_with_unsaved_changes = std::collections::HashSet::new();
273 for (buffer_id, state) in __buffers_ref {
274 if state.buffer.is_modified() {
275 if let Some(metadata) = __win.buffer_metadata.get(buffer_id) {
276 if let Some(file_path) = metadata.file_path() {
277 files_with_unsaved_changes.insert(file_path.clone());
278 }
279 }
280 }
281 }
282
283 let keybindings = self.keybindings.read().unwrap();
284 let empty: Vec<std::path::PathBuf> = Vec::new();
285 let cut_paths = __win
286 .file_explorer_clipboard
287 .as_ref()
288 .filter(|cb| cb.is_cut)
289 .map(|cb| cb.paths.as_slice())
290 .unwrap_or(empty.as_slice());
291 FileExplorerRenderer::render(
292 explorer,
293 frame,
294 explorer_area,
295 slot_resolver,
296 is_focused,
297 &files_with_unsaved_changes,
298 &__win.file_explorer_decoration_cache,
299 &__win.file_explorer_slot_override_cache,
300 &keybindings,
301 key_context_clone,
302 &*self.theme.read().unwrap(),
303 close_button_hovered,
304 remote_connection.as_deref(),
305 cut_paths,
306 &self.config.file_explorer.tree_indicator_collapsed,
307 &self.config.file_explorer.tree_indicator_expanded,
308 );
309 }
310 // Note: if file_explorer is None but sync_in_progress is true,
311 // we just leave the area blank (or could render a placeholder)
312 } else {
313 // No file explorer: use entire main content area for editor
314 self.active_layout_mut().file_explorer_area = None;
315 editor_content_area = main_content_area;
316 }
317
318 // Note: Tabs are now rendered within each split by SplitRenderer
319
320 // Trigger lines_changed hooks for newly visible lines in all visible buffers
321 // This allows plugins to add overlays before rendering
322 // Only lines that haven't been seen before are sent (batched for efficiency)
323 // Use non-blocking hooks to avoid deadlock when actions are awaiting
324 if self.plugin_manager.read().unwrap().is_active() {
325 let hooks_start = std::time::Instant::now();
326 // Get visible buffers and their areas
327 let visible_buffers = self
328 .windows
329 .get(&self.active_window)
330 .and_then(|w| w.buffers.splits())
331 .map(|(mgr, _)| mgr)
332 .expect("active window must have a populated split layout")
333 .get_visible_buffers(editor_content_area);
334
335 let mut total_new_lines = 0usize;
336 for (split_id, buffer_id, split_area) in visible_buffers {
337 // Get viewport from SplitViewState (the authoritative source)
338 let viewport_top_byte = self
339 .windows
340 .get(&self.active_window)
341 .and_then(|w| w.buffers.splits())
342 .map(|(_, vs)| vs)
343 .expect("active window must have a populated split layout")
344 .get(&split_id)
345 .map(|vs| vs.viewport.top_byte)
346 .unwrap_or(0);
347
348 let __active_id = self.active_window;
349 let __win = self
350 .windows
351 .get_mut(&__active_id)
352 .expect("active window must exist");
353 // Take a disjoint mut borrow on `seen_byte_ranges` (a sibling
354 // field on Window, not part of WindowBuffers) so the closure
355 // below can update it alongside the buffer + view-state
356 // mutations.
357 let seen_ranges_for_win = &mut __win.seen_byte_ranges;
358 let plugin_manager = &self.plugin_manager;
359 let estimated_line_length = self.config.editor.estimated_line_length;
360 let added = __win
361 .buffers
362 .with_buffer_and_view_states(buffer_id, |state, vs_map| {
363 // `render_start` has a tiny payload (just the
364 // buffer id) — fire unconditionally so third-party
365 // plugins listening for it still work.
366 let pm_guard = plugin_manager.read().unwrap();
367 pm_guard.run_hook(
368 "render_start",
369 crate::services::plugins::hooks::HookArgs::RenderStart { buffer_id },
370 );
371
372 let visible_count = split_area.height as usize;
373
374 // `view_transform_request` carries the full
375 // tokenized viewport in its args. Building those
376 // tokens (`build_base_tokens_for_hook`) is the
377 // expensive part — see #2009. Skip the whole
378 // pipeline when no plugin subscribes.
379 if pm_guard.has_subscribers("view_transform_request") {
380 let is_binary = state.buffer.is_binary();
381 let line_ending = state.buffer.line_ending();
382 let base_tokens =
383 crate::view::ui::split_rendering::SplitRenderer::build_base_tokens_for_hook(
384 &mut state.buffer,
385 viewport_top_byte,
386 estimated_line_length,
387 visible_count,
388 is_binary,
389 line_ending,
390 );
391 let viewport_start = viewport_top_byte;
392 let viewport_end = base_tokens
393 .last()
394 .and_then(|t| t.source_offset)
395 .unwrap_or(viewport_start);
396 let cursor_positions: Vec<usize> = vs_map
397 .get(&split_id)
398 .map(|vs| vs.cursors.iter().map(|(_, c)| c.position).collect())
399 .unwrap_or_default();
400 pm_guard.run_hook(
401 "view_transform_request",
402 crate::services::plugins::hooks::HookArgs::ViewTransformRequest {
403 buffer_id,
404 split_id: split_id.into(),
405 viewport_start,
406 viewport_end,
407 tokens: base_tokens,
408 cursor_positions,
409 },
410 );
411
412 // Plugin saw fresh base tokens; future
413 // SubmitViewTransform from this request is valid.
414 if let Some(vs) = vs_map.get_mut(&split_id) {
415 vs.view_transform_stale = false;
416 }
417 }
418 drop(pm_guard);
419
420 let top_byte = viewport_top_byte;
421 let seen_byte_ranges =
422 seen_ranges_for_win.entry(buffer_id).or_default();
423
424 let mut new_lines: Vec<
425 crate::services::plugins::hooks::LineInfo,
426 > = Vec::new();
427 let mut line_number = state.buffer.get_line_number(top_byte);
428 let mut iter = state
429 .buffer
430 .line_iterator(top_byte, estimated_line_length);
431
432 for _ in 0..visible_count {
433 if let Some((line_start, line_content)) = iter.next_line() {
434 let byte_end = line_start + line_content.len();
435 let byte_range = (line_start, byte_end);
436
437 if !seen_byte_ranges.contains(&byte_range) {
438 new_lines.push(
439 crate::services::plugins::hooks::LineInfo {
440 line_number,
441 byte_start: line_start,
442 byte_end,
443 content: line_content,
444 },
445 );
446 seen_byte_ranges.insert(byte_range);
447 }
448 line_number += 1;
449 } else {
450 break;
451 }
452 }
453
454 let count = new_lines.len();
455 if !new_lines.is_empty() {
456 plugin_manager.read().unwrap().run_hook(
457 "lines_changed",
458 crate::services::plugins::hooks::HookArgs::LinesChanged {
459 buffer_id,
460 lines: new_lines,
461 },
462 );
463 }
464 count
465 })
466 .unwrap_or(0);
467 total_new_lines += added;
468 }
469 let hooks_elapsed = hooks_start.elapsed();
470 tracing::trace!(
471 new_lines = total_new_lines,
472 elapsed_ms = hooks_elapsed.as_millis(),
473 elapsed_us = hooks_elapsed.as_micros(),
474 "lines_changed hooks total"
475 );
476
477 // Process any plugin commands (like AddOverlay) that resulted from the hooks.
478 //
479 // This is non-blocking: we collect whatever the plugin has sent so far.
480 // The plugin thread runs in parallel, and because we proactively call
481 // handle_refresh_lines after cursor_moved (in fire_cursor_hooks), the
482 // lines_changed hook fires early in the render cycle. By the time we
483 // reach this point, the plugin has typically already processed all hooks
484 // and sent back conceal/overlay commands. On rare occasions (high CPU
485 // load), the response arrives one frame late, which is imperceptible
486 // at 60fps. The plugin's own refreshLines() call from cursor_moved
487 // ensures a follow-up render cycle picks up any missed commands.
488 #[cfg(not(feature = "plugins"))]
489 let dispatched_any = false;
490 #[cfg(feature = "plugins")]
491 let dispatched_any = {
492 let commands = self.plugin_manager.write().unwrap().process_commands();
493 let dispatched_any = !commands.is_empty();
494 if dispatched_any {
495 let cmd_names: Vec<String> =
496 commands.iter().map(|c| c.debug_variant_name()).collect();
497 tracing::trace!(count = commands.len(), cmds = ?cmd_names, "process_commands during render");
498 }
499 for command in commands {
500 if let Err(e) = self.handle_plugin_command(command) {
501 tracing::error!("Error handling plugin command: {}", e);
502 }
503 }
504 dispatched_any
505 };
506
507 // Flush any deferred grammar rebuilds as a single batch
508 self.flush_pending_grammars();
509
510 // Recompute the bottom-row layout if the in-render command
511 // dispatch above mutated state that affects it. Without
512 // this, a `StartPromptAsync` (or similar) processed
513 // mid-render leaves `main_chunks` reflecting the prior
514 // `self.active_window_mut().prompt = None` shape — the prompt slot ends up at
515 // (y = size.height, h = 0) and the status bar paints the
516 // bottom row in place of the prompt input. Conservative:
517 // we recompute on *any* dispatched commands rather than
518 // enumerating layout-affecting variants — Layout::split is
519 // cheap, and this avoids a maintenance-burden whitelist
520 // that would silently regress as new `PluginCommand`
521 // variants are added.
522 //
523 // Bounded — single drain + single recompute. We do not
524 // call `process_commands` again, so commands queued by
525 // hooks fired inside the dispatch above wait for the next
526 // render or `editor_tick` (the existing one-frame-late
527 // behaviour the comment above already accepts).
528 //
529 // `main_content_area` (and the file-explorer / split
530 // rendering derived from it earlier in this render) is
531 // intentionally NOT re-derived: those areas were already
532 // painted, and the bottom-row recompute may overwrite a
533 // single row of main content where the new status bar /
534 // prompt now sits. That brief overlap self-corrects on
535 // the next frame, where the layout is built consistently
536 // from the start.
537 if dispatched_any {
538 show_search_options = self.active_window().prompt.as_ref().is_some_and(|p| {
539 matches!(
540 p.prompt_type,
541 PromptType::Search
542 | PromptType::ReplaceSearch
543 | PromptType::Replace { .. }
544 | PromptType::QueryReplaceSearch
545 | PromptType::QueryReplace { .. }
546 )
547 });
548 prompt_is_overlay = self
549 .active_window()
550 .prompt
551 .as_ref()
552 .is_some_and(|p| p.overlay);
553 has_suggestions = self
554 .active_window()
555 .prompt
556 .as_ref()
557 .is_some_and(|p| !p.suggestions.is_empty())
558 && !prompt_is_overlay;
559 has_file_browser = self.active_window().prompt.as_ref().is_some_and(|p| {
560 matches!(
561 p.prompt_type,
562 PromptType::OpenFile | PromptType::SwitchProject | PromptType::SaveFileAs
563 )
564 }) && self.active_window_mut().file_open_state.is_some();
565 main_chunks = Layout::default()
566 .direction(Direction::Vertical)
567 .constraints(vec![
568 Constraint::Length(if self.active_window_mut().menu_bar_visible {
569 1
570 } else {
571 0
572 }),
573 Constraint::Min(0),
574 Constraint::Length(
575 if !self.active_window_mut().status_bar_visible
576 || has_suggestions
577 || has_file_browser
578 {
579 0
580 } else {
581 1
582 },
583 ),
584 Constraint::Length(if show_search_options { 1 } else { 0 }),
585 Constraint::Length(
586 if (self.active_window_mut().prompt_line_visible
587 || self.active_window().prompt.is_some())
588 && !prompt_is_overlay
589 {
590 1
591 } else {
592 0
593 },
594 ),
595 ])
596 .split(chrome_area);
597 }
598 }
599
600 // Render editor content (same for both layouts)
601 let lsp_waiting = !self.active_window().pending_completion_requests.is_empty()
602 || self
603 .active_window()
604 .pending_goto_definition_request
605 .is_some();
606
607 // Hide the hardware cursor when menu is open, file explorer is focused, terminal mode,
608 // or settings UI is open
609 // (the file explorer will set its own cursor position when focused)
610 // (terminal mode renders its own cursor via the terminal emulator)
611 // (settings UI is a modal that doesn't need the editor cursor)
612 // This also causes visual cursor indicators in the editor to be dimmed
613 let settings_visible = self.settings_state.as_ref().is_some_and(|s| s.visible);
614 let hide_cursor = self.menu_state.active_menu.is_some()
615 || self.active_window_mut().key_context == KeyContext::FileExplorer
616 || self.active_window().terminal_mode
617 || self.dock.as_ref().is_some_and(|d| d.focused)
618 || settings_visible
619 || self.keybinding_editor.is_some();
620
621 // Convert HoverTarget to tab hover info for rendering
622 let hovered_tab = match &self.active_window_mut().mouse_state.hover_target {
623 Some(HoverTarget::TabName(target, split_id)) => Some((*target, *split_id, false)),
624 Some(HoverTarget::TabCloseButton(target, split_id)) => Some((*target, *split_id, true)),
625 _ => None,
626 };
627
628 // Get hovered close split button
629 let hovered_close_split = match &self.active_window_mut().mouse_state.hover_target {
630 Some(HoverTarget::CloseSplitButton(split_id)) => Some(*split_id),
631 _ => None,
632 };
633
634 // Get hovered maximize split button
635 let hovered_maximize_split = match &self.active_window_mut().mouse_state.hover_target {
636 Some(HoverTarget::MaximizeSplitButton(split_id)) => Some(*split_id),
637 _ => None,
638 };
639
640 let is_maximized = self
641 .windows
642 .get(&self.active_window)
643 .and_then(|w| w.buffers.splits())
644 .map(|(mgr, _)| mgr)
645 .expect("active window must have a populated split layout")
646 .is_maximized();
647
648 // The active split's buffer renderer records where the hardware
649 // cursor *wants* to appear here; we only commit it to the frame at
650 // the very end of this draw pass, after popups have been rendered,
651 // so a popup covering the cursor cell causes the cursor to be
652 // hidden (otherwise the hardware caret would bleed through the
653 // popup).
654 let mut pending_hardware_cursor: Option<(u16, u16)> = None;
655
656 let _content_span = tracing::info_span!("render_content").entered();
657 // Take a single mutable borrow on the active window's splits and
658 // split it into (&SplitManager, &mut HashMap<...>) — Rust can
659 // destructure the tuple, but we can't make two separate
660 // `windows.get`/`windows.get_mut` calls in the same expression.
661 let active_window_id = self.active_window;
662 // Take one &mut on the active window. Split-borrow into
663 // buffers (mut), split_mgr (immutable view of mgr), and
664 // split_view_states (mut) — all disjoint sub-fields.
665 let __win = self
666 .windows
667 .get_mut(&active_window_id)
668 .expect("active window must exist");
669 let __metadata_ref = &__win.buffer_metadata;
670 let __event_logs_mut = &mut __win.event_logs;
671 let __grouped_ref = &__win.grouped_subtrees;
672 let __composite_buffers_mut = &mut __win.composite_buffers;
673 let __composite_view_states_mut = &mut __win.composite_view_states;
674 let __cell_theme_map_mut = &mut __win.chrome_layout.cell_theme_map;
675 let __tab_bar_visible = __win.tab_bar_visible;
676 let (
677 split_areas,
678 tab_layouts,
679 close_split_areas,
680 maximize_split_areas,
681 view_line_mappings,
682 horizontal_scrollbar_areas,
683 grouped_separator_areas,
684 ) = __win
685 .buffers
686 .with_all_mut(|__buffers_mut, __mgr, __vs_map| {
687 SplitRenderer::render_content(
688 frame,
689 editor_content_area,
690 &*__mgr,
691 __buffers_mut,
692 __metadata_ref,
693 __event_logs_mut,
694 __composite_buffers_mut,
695 __composite_view_states_mut,
696 &*self.theme.read().unwrap(),
697 self.ansi_background.as_ref(),
698 self.background_fade,
699 lsp_waiting,
700 self.config.editor.large_file_threshold_bytes,
701 self.config.editor.line_wrap,
702 self.config.editor.estimated_line_length,
703 self.config.editor.highlight_context_bytes,
704 Some(__vs_map),
705 __grouped_ref,
706 hide_cursor,
707 hovered_tab,
708 hovered_close_split,
709 hovered_maximize_split,
710 is_maximized,
711 self.config.editor.relative_line_numbers,
712 __tab_bar_visible,
713 self.config.editor.use_terminal_bg,
714 self.session_mode || !self.software_cursor_only,
715 self.software_cursor_only,
716 self.config.editor.show_vertical_scrollbar,
717 self.config.editor.show_horizontal_scrollbar,
718 self.config.editor.diagnostics_inline_text,
719 self.config.editor.show_tilde,
720 self.config.editor.highlight_current_column,
721 self.config.editor.hide_current_line_on_selection,
722 __cell_theme_map_mut,
723 size.width,
724 &mut pending_hardware_cursor,
725 )
726 })
727 .expect("active window must have a populated split layout");
728
729 drop(_content_span);
730
731 // Cursor-jump animation: compare the cursor's screen position to
732 // the prior frame and animate either when the cursor crossed split
733 // panes or moved more than two rows within the same pane. The
734 // trail crosses pane separators when the jump is across splits —
735 // that's the intended "follow the focus" cue.
736 let active_split = self
737 .windows
738 .get(&self.active_window)
739 .and_then(|w| w.buffers.splits())
740 .map(|(mgr, _)| mgr)
741 .expect("active window must have a populated split layout")
742 .active_split();
743 self.maybe_start_cursor_jump_animation(pending_hardware_cursor, active_split);
744
745 // Detect viewport changes and fire hooks
746 // Compare against previous frame's viewport state (stored in self.active_window().previous_viewports)
747 // This correctly detects changes from scroll events that happen before render()
748 if self.plugin_manager.read().unwrap().is_active() {
749 for (split_id, view_state) in self
750 .windows
751 .get(&self.active_window)
752 .and_then(|w| w.buffers.splits())
753 .map(|(_, vs)| vs)
754 .expect("active window must have a populated split layout")
755 {
756 let current = (
757 view_state.viewport.top_byte,
758 view_state.viewport.width,
759 view_state.viewport.height,
760 );
761 // Compare against previous frame's state
762 // Skip new splits (None case) - only fire hooks for established splits
763 // This matches the original behavior where hooks only fire for splits
764 // that existed at the start of render
765 let (changed, previous) =
766 match self.active_window().previous_viewports.get(split_id) {
767 Some(previous) => (*previous != current, Some(*previous)),
768 None => (false, None), // Skip new splits until they're established
769 };
770 tracing::trace!(
771 "viewport_changed check: split={:?} current={:?} previous={:?} changed={}",
772 split_id,
773 current,
774 previous,
775 changed
776 );
777 if changed {
778 if let Some(buffer_id) = self
779 .windows
780 .get(&self.active_window)
781 .and_then(|w| w.buffers.splits())
782 .map(|(mgr, _)| mgr)
783 .expect("active window must have a populated split layout")
784 .get_buffer_id((*split_id).into())
785 {
786 // Compute top_line if line info is available
787 let top_line = self
788 .windows
789 .get(&self.active_window)
790 .map(|w| &w.buffers)
791 .expect("active window present")
792 .get(&buffer_id)
793 .and_then(|state| {
794 if state.buffer.line_count().is_some() {
795 Some(state.buffer.get_line_number(view_state.viewport.top_byte))
796 } else {
797 None
798 }
799 });
800 tracing::debug!(
801 "Firing viewport_changed hook: split={:?} buffer={:?} top_byte={} top_line={:?}",
802 split_id,
803 buffer_id,
804 view_state.viewport.top_byte,
805 top_line
806 );
807 self.plugin_manager.read().unwrap().run_hook(
808 "viewport_changed",
809 crate::services::plugins::hooks::HookArgs::ViewportChanged {
810 split_id: (*split_id).into(),
811 buffer_id,
812 top_byte: view_state.viewport.top_byte,
813 top_line,
814 width: view_state.viewport.width,
815 height: view_state.viewport.height,
816 },
817 );
818 }
819 }
820 }
821 }
822
823 // Update previous_viewports for next frame's comparison.
824 // Take both `previous_viewports` and the split view-states from
825 // the same `__win` borrow so the iterator and the inserts share
826 // a single mutable borrow on `self.windows`.
827 let __vp_win = self
828 .windows
829 .get_mut(&self.active_window)
830 .expect("active window present");
831 __vp_win.previous_viewports.clear();
832 let (_, __vp_vs_map) = __vp_win
833 .buffers
834 .splits()
835 .expect("active window must have a populated split layout");
836 let snapshot: Vec<(LeafId, (usize, u16, u16))> = __vp_vs_map
837 .iter()
838 .map(|(split_id, view_state)| {
839 (
840 *split_id,
841 (
842 view_state.viewport.top_byte,
843 view_state.viewport.width,
844 view_state.viewport.height,
845 ),
846 )
847 })
848 .collect();
849 for (split_id, vp) in snapshot {
850 __vp_win.previous_viewports.insert(split_id, vp);
851 }
852
853 // Render terminal content on top of split content for terminal buffers.
854 // Active-window path: cursor blinks normally when terminal_mode is on.
855 self.active_window()
856 .render_terminal_splits(frame, &split_areas, true);
857
858 self.active_layout_mut().split_areas = split_areas;
859 self.active_layout_mut().horizontal_scrollbar_areas = horizontal_scrollbar_areas;
860 self.active_layout_mut().tab_layouts = tab_layouts;
861 self.active_layout_mut().close_split_areas = close_split_areas;
862 self.active_layout_mut().maximize_split_areas = maximize_split_areas;
863 self.active_layout_mut().view_line_mappings = view_line_mappings;
864
865 // Promote any deferred virtual-buffer animations whose Rect is now
866 // known. Done here (after split_areas is recomputed, before
867 // apply_all runs at the end of render) so the first frame of the
868 // effect lands on the same paint that made the buffer visible.
869 self.drain_pending_vb_animations();
870 let mut separator_areas = self
871 .split_manager_mut()
872 .get_separators_with_ids(editor_content_area);
873 // Grouped subtrees live in a side-map outside the main split tree, so
874 // their inner separators are not visited by `get_separators_with_ids`
875 // above. The renderer collected them (using the same content rect it
876 // drew them at) — merge so clicks on those rendered columns register.
877 separator_areas.extend(grouped_separator_areas);
878 self.active_layout_mut().separator_areas = separator_areas;
879 self.active_layout_mut().editor_content_area = Some(editor_content_area);
880
881 // Render hover highlights for separators and scrollbars
882 self.render_hover_highlights(frame);
883
884 // Initialize popup/suggestion layout state (rendered after status bar below)
885 self.active_chrome_mut().suggestions_area = None;
886 self.active_chrome_mut().suggestions_outer_area = None;
887 self.active_chrome_mut().prompt_results_area = None;
888 self.active_chrome_mut().prompt_preview_area = None;
889 self.active_window_mut().file_browser_layout = None;
890
891 // Clone all immutable values before the mutable borrow
892 let display_name = self
893 .active_window()
894 .buffer_metadata
895 .get(&self.active_buffer())
896 .map(|m| m.display_name.clone())
897 .unwrap_or_else(|| "[No Name]".to_string());
898
899 // Reflect the active buffer in the terminal window/tab title. Only
900 // writes when the title actually changes so we don't flood stdout
901 // with OSC sequences every frame.
902 self.update_terminal_title(&display_name);
903
904 let status_message = self.active_window().status_message.clone();
905 let plugin_status_message = self.active_window().plugin_status_message.clone();
906 let prompt = self.active_window().prompt.clone();
907 // Compute a simple buffer-aware LSP indicator.
908 // Compose the LSP status-bar segment for the active buffer. This
909 // runs every render — the editor has no precomputed LSP-status
910 // string cached anywhere else, so there is a single source of
911 // truth for what the user sees.
912 //
913 // Priority order (first non-empty wins):
914 //
915 // 1. Active `$/progress` work for this language — e.g.
916 // "LSP (cpp): indexing (42%)". Conveys the transient
917 // startup/indexing phase.
918 // 2. A running server — "LSP". Short because detail belongs
919 // in LSP-specific UI, not the compact status bar pill.
920 // 3. Configured `auto_start=true` servers that haven't started
921 // (error / crashed / pending) — "LSP off".
922 // 4. Configured `enabled && !auto_start` servers that the user
923 // has to opt into — "LSP: off (N)".
924 // 5. Nothing.
925 //
926 // Rules 3 and 4 address heuristic eval H-1: without them, a
927 // configured-but-dormant server is indistinguishable from "no
928 // LSP at all."
929 let current_language = self
930 .buffers()
931 .get(&self.active_buffer())
932 .map(|s| s.language.clone())
933 .unwrap_or_default();
934 let buffer_lsp_disabled_reason = self
935 .active_window()
936 .buffer_metadata
937 .get(&self.active_buffer())
938 .filter(|m| !m.lsp_enabled)
939 .and_then(|m| m.lsp_disabled_reason.as_deref());
940 let (lsp_status, lsp_indicator_state) = compose_lsp_status(
941 ¤t_language,
942 buffer_lsp_disabled_reason,
943 &self.active_window().lsp_progress,
944 &self.active_window().lsp_server_statuses,
945 &self.config.lsp,
946 &self.active_window().user_dismissed_lsp_languages,
947 self.config.lsp_enabled,
948 );
949 let theme = self.theme.read().unwrap().clone();
950 let keybindings_cloned = self.keybindings.read().unwrap().clone(); // Clone the keybindings
951 let chord_state_cloned = self.active_window_mut().chord_state.clone(); // Clone the chord state
952
953 // Get update availability info
954 let update_available = self.latest_version().map(|v| v.to_string());
955
956 // Render status bar (hidden when toggled off, or when suggestions/file browser popup is shown)
957 if self.active_window_mut().status_bar_visible && !has_suggestions && !has_file_browser {
958 // Get warning level for colored indicator (respects config setting)
959 // LSP warning level is scoped to the current buffer's language
960 let (warning_level, general_warning_count) =
961 if self.config.warnings.show_status_indicator {
962 let lsp_level = {
963 use crate::services::async_bridge::LspServerStatus;
964 let mut level = WarningLevel::None;
965 for ((lang, _), status) in &self.active_window().lsp_server_statuses {
966 if lang == ¤t_language {
967 match status {
968 LspServerStatus::Error => {
969 level = WarningLevel::Error;
970 break;
971 }
972 LspServerStatus::Starting | LspServerStatus::Initializing => {
973 if level != WarningLevel::Error {
974 level = WarningLevel::Warning;
975 }
976 }
977 _ => {}
978 }
979 }
980 }
981 level
982 };
983 (
984 lsp_level,
985 self.active_window().warning_domains.general.count,
986 )
987 } else {
988 (WarningLevel::None, 0)
989 };
990
991 // Compute status bar hover state for styling
992 use crate::view::ui::status_bar::StatusBarHover;
993 let status_bar_hover = match &self.active_window_mut().mouse_state.hover_target {
994 Some(HoverTarget::StatusBarLspIndicator) => StatusBarHover::LspIndicator,
995 Some(HoverTarget::StatusBarWarningBadge) => StatusBarHover::WarningBadge,
996 Some(HoverTarget::StatusBarLineEndingIndicator) => {
997 StatusBarHover::LineEndingIndicator
998 }
999 Some(HoverTarget::StatusBarEncodingIndicator) => StatusBarHover::EncodingIndicator,
1000 Some(HoverTarget::StatusBarLanguageIndicator) => StatusBarHover::LanguageIndicator,
1001 Some(HoverTarget::StatusBarRemoteIndicator) => StatusBarHover::RemoteIndicator,
1002 Some(HoverTarget::StatusBarTrustIndicator) => StatusBarHover::WorkspaceTrust,
1003 _ => StatusBarHover::None,
1004 };
1005
1006 let remote_connection = self.connection_display_string();
1007
1008 // Get session name for display (only in session mode)
1009 let session_name = self.session_name().map(|s| s.to_string());
1010
1011 let active_split = self.effective_active_split();
1012 let active_buf = self.active_buffer();
1013 let default_cursors = crate::model::cursor::Cursors::new();
1014 let is_read_only = self
1015 .active_window()
1016 .buffer_metadata
1017 .get(&active_buf)
1018 .map(|m| m.read_only)
1019 .unwrap_or(false);
1020 let is_synthetic_placeholder = self
1021 .active_window()
1022 .buffer_metadata
1023 .get(&active_buf)
1024 .map(|m| m.synthetic_placeholder)
1025 .unwrap_or(false);
1026 // Compute plugin-provided status-bar values before taking the
1027 // mutable window borrow below.
1028 let dynamic_status_bar_elements = self.get_status_bar_element_values(active_buf);
1029 // Active session's trust level for the always-present `{trust}`
1030 // indicator — read here (Copy) before the mutable window borrow.
1031 let workspace_trust_level = self.authority().workspace_trust.level();
1032 // Single window borrow, split into buffers + cursors so the
1033 // status-bar context can hold both.
1034 let __active_id = self.active_window;
1035 let __win = self
1036 .windows
1037 .get_mut(&__active_id)
1038 .expect("active window must exist");
1039 let status_bar_layout = __win
1040 .buffers
1041 .with_buffer_and_view_states(active_buf, |state, vs_map| {
1042 let cursors = vs_map
1043 .get(&active_split)
1044 .map(|v| &v.cursors)
1045 .unwrap_or(&default_cursors);
1046 let mut status_ctx = crate::view::ui::status_bar::StatusBarContext {
1047 state,
1048 cursors,
1049 status_message: &status_message,
1050 plugin_status_message: &plugin_status_message,
1051 lsp_status: &lsp_status,
1052 lsp_indicator_state,
1053 theme: &theme,
1054 display_name: &display_name,
1055 keybindings: &keybindings_cloned,
1056 chord_state: &chord_state_cloned,
1057 update_available: update_available.as_deref(),
1058 warning_level,
1059 general_warning_count,
1060 hover: status_bar_hover,
1061 remote_connection: remote_connection.as_deref(),
1062 session_name: session_name.as_deref(),
1063 read_only: is_read_only,
1064 remote_state_override: self.remote_indicator_override.as_ref(),
1065 is_synthetic_placeholder,
1066 // Filled in by `render_status` from the user's
1067 // status_bar config; the value here is just a
1068 // safe default for the rare path that builds the
1069 // ctx but doesn't run `render_status`.
1070 remote_indicator_on_bar: false,
1071 dynamic_status_bar_elements: dynamic_status_bar_elements.clone(),
1072 workspace_trust_level,
1073 };
1074 StatusBarRenderer::render_status_bar(
1075 frame,
1076 main_chunks[status_bar_idx],
1077 &mut status_ctx,
1078 &self.config.editor.status_bar,
1079 )
1080 })
1081 .expect("active buffer must be present");
1082
1083 // Store status bar layout for click detection
1084 let status_bar_area = main_chunks[status_bar_idx];
1085 self.active_chrome_mut().status_bar_area =
1086 Some((status_bar_area.y, status_bar_area.x, status_bar_area.width));
1087 self.active_chrome_mut().status_bar_lsp_area = status_bar_layout.lsp_indicator;
1088 self.active_chrome_mut().status_bar_warning_area = status_bar_layout.warning_badge;
1089 self.active_chrome_mut().status_bar_line_ending_area =
1090 status_bar_layout.line_ending_indicator;
1091 self.active_chrome_mut().status_bar_encoding_area =
1092 status_bar_layout.encoding_indicator;
1093 self.active_chrome_mut().status_bar_language_area =
1094 status_bar_layout.language_indicator;
1095 self.active_chrome_mut().status_bar_message_area = status_bar_layout.message_area;
1096 self.active_chrome_mut().status_bar_remote_area = status_bar_layout.remote_indicator;
1097 self.active_chrome_mut().status_bar_trust_area = status_bar_layout.trust_indicator;
1098 self.active_chrome_mut().status_bar_plugin_token_areas =
1099 status_bar_layout.plugin_token_areas;
1100 }
1101
1102 // Render search options bar when in search prompt
1103 if show_search_options {
1104 // Show "Confirm" option only in replace modes
1105 let confirm_each = self.active_window().prompt.as_ref().and_then(|p| {
1106 if matches!(
1107 p.prompt_type,
1108 PromptType::ReplaceSearch
1109 | PromptType::Replace { .. }
1110 | PromptType::QueryReplaceSearch
1111 | PromptType::QueryReplace { .. }
1112 ) {
1113 Some(self.active_window().search_confirm_each)
1114 } else {
1115 None
1116 }
1117 });
1118
1119 // Determine hover state for search options
1120 use crate::view::ui::status_bar::SearchOptionsHover;
1121 let search_options_hover = match &self.active_window_mut().mouse_state.hover_target {
1122 Some(HoverTarget::SearchOptionCaseSensitive) => SearchOptionsHover::CaseSensitive,
1123 Some(HoverTarget::SearchOptionWholeWord) => SearchOptionsHover::WholeWord,
1124 Some(HoverTarget::SearchOptionRegex) => SearchOptionsHover::Regex,
1125 Some(HoverTarget::SearchOptionConfirmEach) => SearchOptionsHover::ConfirmEach,
1126 _ => SearchOptionsHover::None,
1127 };
1128
1129 let search_options_layout = StatusBarRenderer::render_search_options(
1130 frame,
1131 main_chunks[search_options_idx],
1132 self.active_window().search_case_sensitive,
1133 self.active_window().search_whole_word,
1134 self.active_window().search_use_regex,
1135 confirm_each,
1136 &theme,
1137 &keybindings_cloned,
1138 search_options_hover,
1139 );
1140 self.active_chrome_mut().search_options_layout = Some(search_options_layout);
1141 } else {
1142 self.active_chrome_mut().search_options_layout = None;
1143 }
1144
1145 // Render prompt line if active. Overlay prompts (Live Grep)
1146 // skip the bottom-row render entirely — they paint their own
1147 // input row inside the centred overlay frame, so the user's
1148 // editor view stays unobstructed at the bottom.
1149 if let Some(prompt) = &prompt {
1150 if !prompt.overlay {
1151 // Use specialized renderer for file/folder open prompt to show colorized path
1152 if matches!(
1153 prompt.prompt_type,
1154 crate::view::prompt::PromptType::OpenFile
1155 | crate::view::prompt::PromptType::SwitchProject
1156 ) {
1157 if let Some(file_open_state) = &self.active_window_mut().file_open_state {
1158 StatusBarRenderer::render_file_open_prompt(
1159 frame,
1160 main_chunks[prompt_line_idx],
1161 prompt,
1162 file_open_state,
1163 &theme,
1164 );
1165 } else {
1166 StatusBarRenderer::render_prompt(
1167 frame,
1168 main_chunks[prompt_line_idx],
1169 prompt,
1170 &theme,
1171 );
1172 }
1173 } else {
1174 StatusBarRenderer::render_prompt(
1175 frame,
1176 main_chunks[prompt_line_idx],
1177 prompt,
1178 &theme,
1179 );
1180 }
1181 }
1182 }
1183
1184 // Float-overlay preview: load the selected match's file (if
1185 // the file changed) and seed the phantom leaf's cursor before
1186 // the renderer reaches it. Done before render_prompt_popups
1187 // because that path immediately needs the leaf's view state.
1188 if self
1189 .active_window()
1190 .prompt
1191 .as_ref()
1192 .is_some_and(|p| p.overlay)
1193 {
1194 self.prepare_overlay_preview();
1195 }
1196
1197 // Render file browser popup or suggestions popup AFTER status bar + prompt,
1198 // so they overlay on top of both (fixes bottom border being overwritten by status bar)
1199 self.render_prompt_popups(frame, main_chunks[prompt_line_idx], chrome_area);
1200
1201 // Render popups from the active buffer state
1202 // Clone theme to avoid borrow checker issues with active_state_mut()
1203 let theme_clone = self.theme.read().unwrap().clone();
1204 let hover_target = self.active_window_mut().mouse_state.hover_target.clone();
1205
1206 // Clear popup areas and recalculate
1207 self.active_chrome_mut().popup_areas.clear();
1208
1209 // Collect popup information without holding a mutable borrow
1210 let popup_info: Vec<_> = {
1211 // Get viewport from active split's SplitViewState
1212 let active_split = self
1213 .windows
1214 .get(&self.active_window)
1215 .and_then(|w| w.buffers.splits())
1216 .map(|(mgr, _)| mgr)
1217 .expect("active window must have a populated split layout")
1218 .active_split();
1219 let viewport = self
1220 .windows
1221 .get(&self.active_window)
1222 .and_then(|w| w.buffers.splits())
1223 .map(|(_, vs)| vs)
1224 .expect("active window must have a populated split layout")
1225 .get(&active_split)
1226 .map(|vs| vs.viewport.clone());
1227
1228 // Get the content_rect for the active split from the cached layout.
1229 // This is the absolute screen rect (already accounts for file explorer,
1230 // tab bar, scrollbars, etc.). The gutter is rendered inside this rect,
1231 // so we add gutter_width to get the text content origin.
1232 let content_rect = self
1233 .active_layout()
1234 .split_areas
1235 .iter()
1236 .find(|(split_id, _, _, _, _, _)| *split_id == active_split)
1237 .map(|(_, _, rect, _, _, _)| *rect);
1238
1239 let primary_cursor = self
1240 .windows
1241 .get(&self.active_window)
1242 .and_then(|w| w.buffers.splits())
1243 .map(|(_, vs)| vs)
1244 .expect("active window must have a populated split layout")
1245 .get(&active_split)
1246 .map(|vs| *vs.cursors.primary());
1247 let state = self.active_state_mut();
1248 if state.popups.is_visible() {
1249 // Get the primary cursor position for popup positioning
1250 let primary_cursor =
1251 primary_cursor.unwrap_or_else(|| crate::model::cursor::Cursor::new(0));
1252
1253 // Compute gutter width so we know where text content starts
1254 let gutter_width = viewport
1255 .as_ref()
1256 .map(|vp| vp.gutter_width(&state.buffer) as u16)
1257 .unwrap_or(0);
1258
1259 let cursor_screen_pos = viewport
1260 .as_ref()
1261 .map(|vp| vp.cursor_screen_position(&mut state.buffer, &primary_cursor))
1262 .unwrap_or((0, 0));
1263
1264 // For completion popups, compute the word-start screen position so
1265 // the popup aligns with the beginning of the word being completed,
1266 // not the current cursor position.
1267 let word_start_screen_pos = {
1268 use crate::primitives::word_navigation::find_completion_word_start;
1269 let word_start =
1270 find_completion_word_start(&state.buffer, primary_cursor.position);
1271 let word_start_cursor = crate::model::cursor::Cursor::new(word_start);
1272 viewport
1273 .as_ref()
1274 .map(|vp| vp.cursor_screen_position(&mut state.buffer, &word_start_cursor))
1275 .unwrap_or((0, 0))
1276 };
1277
1278 // Use content_rect as the single source of truth for the text
1279 // content area origin. content_rect.x is the split's left edge
1280 // (already past the file explorer), content_rect.y is below the
1281 // tab bar. Adding gutter_width gives us the text content start.
1282 let (base_x, base_y) = content_rect
1283 .map(|r| (r.x + gutter_width, r.y))
1284 .unwrap_or((gutter_width, 1));
1285
1286 let cursor_screen_pos =
1287 (cursor_screen_pos.0 + base_x, cursor_screen_pos.1 + base_y);
1288 let word_start_screen_pos = (
1289 word_start_screen_pos.0 + base_x,
1290 word_start_screen_pos.1 + base_y,
1291 );
1292
1293 // Collect popup data
1294 state
1295 .popups
1296 .all()
1297 .iter()
1298 .enumerate()
1299 .map(|(popup_idx, popup)| {
1300 // Use word-start x for completion popups, cursor x for others
1301 let popup_pos = if popup.kind == crate::view::popup::PopupKind::Completion {
1302 (word_start_screen_pos.0, cursor_screen_pos.1)
1303 } else {
1304 cursor_screen_pos
1305 };
1306 // Clamp within the chrome area (right of a left
1307 // dock) so a cursor-anchored popup near the left
1308 // edge can't extend into the dock column.
1309 let popup_area = popup.calculate_area(chrome_area, Some(popup_pos));
1310
1311 // Track popup area for mouse hit testing
1312 // Account for description height when calculating the list item area
1313 let desc_height = popup.description_height();
1314 let inner_area = if popup.bordered {
1315 ratatui::layout::Rect {
1316 x: popup_area.x + 1,
1317 y: popup_area.y + 1 + desc_height,
1318 width: popup_area.width.saturating_sub(2),
1319 height: popup_area.height.saturating_sub(2 + desc_height),
1320 }
1321 } else {
1322 ratatui::layout::Rect {
1323 x: popup_area.x,
1324 y: popup_area.y + desc_height,
1325 width: popup_area.width,
1326 height: popup_area.height.saturating_sub(desc_height),
1327 }
1328 };
1329
1330 let num_items = match &popup.content {
1331 crate::view::popup::PopupContent::List { items, .. } => items.len(),
1332 _ => 0,
1333 };
1334
1335 // Calculate total content lines and scrollbar rect
1336 let total_lines = popup.item_count();
1337 let visible_lines = inner_area.height as usize;
1338 let scrollbar_rect = if total_lines > visible_lines && inner_area.width > 2
1339 {
1340 Some(ratatui::layout::Rect {
1341 x: inner_area.x + inner_area.width - 1,
1342 y: inner_area.y,
1343 width: 1,
1344 height: inner_area.height,
1345 })
1346 } else {
1347 None
1348 };
1349
1350 (
1351 popup_idx,
1352 popup_area,
1353 inner_area,
1354 popup.scroll_offset,
1355 num_items,
1356 scrollbar_rect,
1357 total_lines,
1358 )
1359 })
1360 .collect()
1361 } else {
1362 Vec::new()
1363 }
1364 };
1365
1366 // Store popup areas for mouse hit testing
1367 self.active_chrome_mut().popup_areas = popup_info.clone();
1368
1369 // Now render popups
1370 let state = self.active_state_mut();
1371 if state.popups.is_visible() {
1372 for (popup_idx, popup) in state.popups.all().iter().enumerate() {
1373 if let Some((_, popup_area, _, _, _, _, _)) = popup_info.get(popup_idx) {
1374 popup.render_with_hover(
1375 frame,
1376 *popup_area,
1377 &theme_clone,
1378 hover_target.as_ref(),
1379 );
1380 }
1381 }
1382 }
1383
1384 // Render editor-level popups (e.g. plugin action popups) on top of any
1385 // buffer content so they stay visible across buffer switches and over
1386 // virtual buffers (Dashboard, diagnostics) that own the whole split.
1387 // These don't need cursor-relative positioning — they all use absolute
1388 // positions like BottomRight or Centered.
1389 //
1390 // Queue semantics: concurrent action popups stack in `global_popups`,
1391 // but only the top one renders & receives input. Deeper popups
1392 // surface as the top is resolved — the alternative (drawing all at
1393 // the same BottomRight slot) makes them illegible.
1394 self.active_chrome_mut().global_popup_areas.clear();
1395 // The workspace-trust prompt is a blocking modal: it renders later in
1396 // the dedicated modal z-band (alongside settings / wizard) on a dimmed
1397 // backdrop, so it can't be lost amongst dashboard/explorer chrome.
1398 // Everything else on the global stack renders here, above buffer content.
1399 let top_is_trust_modal = self.global_popups.top().is_some_and(|p| {
1400 matches!(
1401 p.resolver,
1402 crate::view::popup::PopupResolver::WorkspaceTrust
1403 )
1404 });
1405 if !top_is_trust_modal {
1406 // Global popups render within the chrome area (right of a
1407 // left dock) so corner/centred popups don't overrun it.
1408 self.render_top_global_popup(frame, chrome_area, &theme_clone, hover_target.as_ref());
1409 }
1410
1411 // Render menu bar last so dropdown appears on top of all other content
1412 // Update menu context with current editor state
1413 self.update_menu_context();
1414
1415 // Render settings modal (before menu bar so menus can overlay)
1416 // Check visibility first to avoid borrow conflict with dimming
1417 let settings_visible = self
1418 .settings_state
1419 .as_ref()
1420 .map(|s| s.visible)
1421 .unwrap_or(false);
1422 if settings_visible {
1423 // Dim the editor content behind the settings modal. Use the
1424 // chrome area (right of a left dock) so the modal sits beside
1425 // the persistent dock instead of being overlapped by it.
1426 crate::view::dimming::apply_dimming(frame, chrome_area);
1427 }
1428 if let Some(ref mut settings_state) = self.settings_state {
1429 if settings_state.visible {
1430 settings_state.update_focus_states();
1431 let settings_layout = crate::view::settings::render_settings(
1432 frame,
1433 chrome_area,
1434 settings_state,
1435 &*self.theme.read().unwrap(),
1436 );
1437 self.active_chrome_mut().settings_layout = Some(settings_layout);
1438 }
1439 }
1440
1441 // Render calibration wizard if active
1442 if let Some(ref wizard) = self.calibration_wizard {
1443 // Dim the editor content behind the wizard modal
1444 crate::view::dimming::apply_dimming(frame, chrome_area);
1445 crate::view::calibration_wizard::render_calibration_wizard(
1446 frame,
1447 chrome_area,
1448 wizard,
1449 &*self.theme.read().unwrap(),
1450 );
1451 }
1452
1453 // Render keybinding editor if active
1454 if let Some(ref mut kb_editor) = self.keybinding_editor {
1455 crate::view::dimming::apply_dimming(frame, chrome_area);
1456 crate::view::keybinding_editor::render_keybinding_editor(
1457 frame,
1458 chrome_area,
1459 kb_editor,
1460 &*self.theme.read().unwrap(),
1461 );
1462 }
1463
1464 // Render event debug dialog if active
1465 if let Some(ref debug) = self.active_window().event_debug {
1466 // Dim the editor content behind the dialog modal
1467 crate::view::dimming::apply_dimming(frame, chrome_area);
1468 crate::view::event_debug::render_event_debug(
1469 frame,
1470 chrome_area,
1471 debug,
1472 &*self.theme.read().unwrap(),
1473 );
1474 }
1475
1476 // The workspace-trust prompt is a blocking, top-most security modal.
1477 // It dims the *entire* frame (the dock included) and centres in the
1478 // full window, so it is rendered at the very end of this method —
1479 // after the dock and floating panels — rather than here, where the
1480 // dock's later pass would overpaint its left edge. See the bottom of
1481 // `render`.
1482
1483 if self.active_window_mut().menu_bar_visible {
1484 // Pre-expand DynamicSubmenu items once per registry; without this
1485 // MenuRenderer::render rescans + reparses every theme JSON file
1486 // on every frame.
1487 self.expanded_menus_cache.update(
1488 &self.theme_registry,
1489 &self.menus,
1490 &self.menu_state.themes_dir,
1491 );
1492 let hover_target = self.active_window().mouse_state.hover_target.clone();
1493 let menu_bar_mnemonics = self.config.editor.menu_bar_mnemonics;
1494 let expanded = self.expanded_menus_cache.get().expect("just updated");
1495 let keybindings = self.keybindings.read().unwrap();
1496 let new_menu_layout = crate::view::ui::MenuRenderer::render(
1497 frame,
1498 menu_bar_area,
1499 expanded,
1500 &self.menu_state,
1501 &keybindings,
1502 &*self.theme.read().unwrap(),
1503 hover_target.as_ref(),
1504 menu_bar_mnemonics,
1505 );
1506 drop(keybindings);
1507 self.active_chrome_mut().menu_layout = Some(new_menu_layout);
1508 } else {
1509 self.active_chrome_mut().menu_layout = None;
1510 }
1511
1512 // Render tab context menu if open
1513 let tab_ctx_menu = self.active_window().tab_context_menu.clone();
1514 if let Some(menu) = tab_ctx_menu {
1515 self.render_tab_context_menu(frame, &menu);
1516 }
1517
1518 let fe_ctx_menu = self.active_window().file_explorer_context_menu.clone();
1519 if let Some(menu) = fe_ctx_menu {
1520 self.render_file_explorer_context_menu(frame, &menu);
1521 }
1522
1523 // Render the "+" new-tab popup menu if open
1524 let new_tab_menu = self.active_window().new_tab_menu.clone();
1525 if let Some(menu) = new_tab_menu {
1526 self.render_new_tab_menu(frame, &menu);
1527 }
1528
1529 // Record non-editor region theme keys for the theme inspector
1530 self.record_non_editor_theme_regions();
1531
1532 // Render theme info popup (Ctrl+Right-Click)
1533 self.render_theme_info_popup(frame);
1534
1535 // Render tab drag drop zone overlay if dragging a tab
1536 let drag_state_clone = self.active_window().mouse_state.dragging_tab.clone();
1537 if let Some(ref drag_state) = drag_state_clone {
1538 if drag_state.is_dragging() {
1539 self.render_tab_drop_zone(frame, drag_state);
1540 }
1541 }
1542
1543 // Render software mouse cursor when GPM is active
1544 // GPM can't draw its cursor on the alternate screen buffer used by TUI apps,
1545 // so we draw our own cursor at the tracked mouse position.
1546 // This must happen LAST in the render flow so we can read the already-rendered
1547 // cell content and invert it.
1548 if self.active_window_mut().gpm_active {
1549 if let Some((col, row)) = self.active_window_mut().mouse_cursor_position {
1550 use ratatui::style::Modifier;
1551
1552 // Only render if within screen bounds
1553 if col < size.width && row < size.height {
1554 // Get the cell at this position and add REVERSED modifier to invert colors
1555 let buf = frame.buffer_mut();
1556 if let Some(cell) = buf.cell_mut((col, row)) {
1557 cell.set_style(cell.style().add_modifier(Modifier::REVERSED));
1558 }
1559 }
1560 }
1561 }
1562
1563 // When keyboard capture mode is active, dim all UI elements outside the terminal
1564 // to visually indicate that focus is exclusively on the terminal
1565 if self.active_window_mut().keyboard_capture && self.active_window().terminal_mode {
1566 // Find the active split's content area
1567 let active_split = self
1568 .windows
1569 .get(&self.active_window)
1570 .and_then(|w| w.buffers.splits())
1571 .map(|(mgr, _)| mgr)
1572 .expect("active window must have a populated split layout")
1573 .active_split();
1574 let active_split_area = self
1575 .active_layout()
1576 .split_areas
1577 .iter()
1578 .find(|(split_id, _, _, _, _, _)| *split_id == active_split)
1579 .map(|(_, _, content_rect, _, _, _)| *content_rect);
1580
1581 if let Some(terminal_area) = active_split_area {
1582 self.apply_keyboard_capture_dimming(frame, terminal_area);
1583 }
1584 }
1585
1586 // Commit the active-split hardware cursor (deferred since
1587 // `render_content`) unless a popup has been drawn over that cell.
1588 // Ratatui draws the hardware caret on top of every cell, so a
1589 // popup cannot hide the cursor by painting cells — the only way
1590 // to hide it is to leave `Frame::cursor_position` as `None`, which
1591 // triggers `Terminal::hide_cursor` at the end of the draw.
1592 //
1593 // When a prompt is active the prompt renderer already placed the
1594 // caret on the prompt line via `frame.set_cursor_position`; don't
1595 // override it with the (now-irrelevant) buffer cursor.
1596 if let Some((cx, cy)) = pending_hardware_cursor {
1597 if self.active_window().prompt.is_none() && !self.cursor_obscured_by_overlay(cx, cy) {
1598 frame.set_cursor_position((cx, cy));
1599 }
1600 }
1601
1602 // Convert all colors for terminal capability (256/16 color fallback)
1603 crate::view::color_support::convert_buffer_colors(
1604 frame.buffer_mut(),
1605 self.color_capability,
1606 );
1607
1608 // Frame-buffer animations run last so they mutate the final paint.
1609 self.active_window_mut()
1610 .animations
1611 .apply_all(frame.buffer_mut());
1612
1613 // Panels are drawn last so they sit above every other layer
1614 // (prompts, popups, animations). The two slots are independent:
1615 // the dock paints into its carved column (`dock_area`); a
1616 // centered modal paints over the whole frame (dimmed). Draw the
1617 // dock first so a centered modal sits visually above it.
1618 if let Some(dock) = dock_area {
1619 if self.dock.is_some() {
1620 self.render_floating_widget_panel(frame, dock, super::PanelSlot::Dock);
1621 }
1622 }
1623 if self.floating_widget_panel.is_some() {
1624 // A `fullscreen` modal paints over the whole frame, covering the
1625 // dock; otherwise it lays into `chrome_area` beside the dock.
1626 // The orchestrator's global modals (control room, New-Session
1627 // form) opt into fullscreen so they're not cramped into the
1628 // narrow region right of their own dock.
1629 let fullscreen = self
1630 .floating_widget_panel
1631 .as_ref()
1632 .map(|f| f.fullscreen)
1633 .unwrap_or(false);
1634 // An anchored context-menu popup is unobtrusive: it neither
1635 // dims the dock nor confines itself to `chrome_area` (its
1636 // anchor is an absolute screen cell that may sit over the
1637 // dock). Treat it like a non-dimming, full-frame placement.
1638 let is_anchored = matches!(
1639 self.floating_widget_panel.as_ref().map(|f| f.placement),
1640 Some(super::PanelPlacement::Anchored { .. })
1641 );
1642 // A centered modal makes the *whole* UI a passive, dimmed
1643 // background — the dock included. The dock was drawn above at
1644 // full brightness. A beside-dock modal only dims `chrome_area`,
1645 // so dim the dock column explicitly here; a fullscreen modal
1646 // dims the whole frame itself (its own `apply_dimming_excluding`
1647 // runs over the full area below), so skip the redundant pass.
1648 // Either way the dock is blurred + input-inaccessible while a
1649 // modal is up (the host blurs it on mount and the modal swallows
1650 // keys/clicks/wheel), so dimming it makes that passivity visible
1651 // rather than leaving it looking live beside the dialog.
1652 if !fullscreen && !is_anchored {
1653 if let Some(dock) = dock_area {
1654 if self.dock.is_some() {
1655 crate::view::dimming::apply_dimming(frame, dock);
1656 }
1657 }
1658 }
1659 // Render the centered modal within `chrome_area` (the region to
1660 // the right of a left dock) rather than the whole frame, so it
1661 // sits beside the dock and dims only the chrome instead of
1662 // painting over the dock column. When no dock is up
1663 // `chrome_area` is the whole frame, so this is unchanged for the
1664 // common case. This is what lets a plugin's Open picker coexist
1665 // with the dock — mirroring the settings / keybinding-editor
1666 // modals, which already lay into `chrome_area`. A `fullscreen`
1667 // panel instead gets the whole frame (`size`).
1668 let modal_area = if fullscreen || is_anchored {
1669 size
1670 } else {
1671 chrome_area
1672 };
1673 self.render_floating_widget_panel(frame, modal_area, super::PanelSlot::Floating);
1674 }
1675
1676 // Workspace-trust prompt — a blocking, top-most security modal. Drawn
1677 // dead last (after the dock and any floating panel) so it dims the
1678 // *entire* frame, centres in the full window (dock area included), and
1679 // renders on top of the dock rather than being overpainted by it.
1680 let trust_layout = if top_is_trust_modal {
1681 crate::view::dimming::apply_dimming(frame, size);
1682 let selected = self
1683 .global_popups
1684 .top()
1685 .and_then(|p| match &p.content {
1686 crate::view::popup::PopupContent::List { selected, .. } => Some(*selected),
1687 _ => None,
1688 })
1689 .unwrap_or(1);
1690 let path = self.working_dir().display().to_string();
1691 let triggers = self.workspace_trust_markers.join(", ");
1692 let secondary_label = if self.workspace_trust_prompt_cancellable {
1693 rust_i18n::t!("trust.dialog.btn_cancel").into_owned()
1694 } else {
1695 let quit_hint = self.keybindings.read().ok().and_then(|kb| {
1696 kb.get_keybinding_for_action(
1697 &crate::input::keybindings::Action::Quit,
1698 crate::input::keybindings::KeyContext::Normal,
1699 )
1700 });
1701 match quit_hint {
1702 Some(k) => rust_i18n::t!("trust.dialog.btn_quit_key", key = k).into_owned(),
1703 None => rust_i18n::t!("trust.dialog.btn_quit").into_owned(),
1704 }
1705 };
1706 Some(
1707 crate::view::workspace_trust_dialog::render_workspace_trust_dialog(
1708 frame,
1709 size,
1710 selected,
1711 &path,
1712 &triggers,
1713 &secondary_label,
1714 self.workspace_trust_scroll,
1715 &theme_clone,
1716 ),
1717 )
1718 } else {
1719 None
1720 };
1721 self.active_chrome_mut().workspace_trust_dialog = trust_layout;
1722 }
1723
1724 /// Drain plugin commands enqueued before this frame's layout pass.
1725 ///
1726 /// Must run before `compute_dock_split` because commands such as
1727 /// `UnmountFloatingWidget` affect the dock state that layout reads.
1728 /// The mid-render drain (after `compute_dock_split`) runs too late for
1729 /// those: the dock area would be computed from stale state and the freed
1730 /// columns would render blank until the next input event.
1731 fn drain_pre_layout_plugin_commands(&mut self) {
1732 #[cfg(feature = "plugins")]
1733 {
1734 let early_commands = self.plugin_manager.write().unwrap().process_commands();
1735 if !early_commands.is_empty() {
1736 tracing::trace!(
1737 count = early_commands.len(),
1738 "process_commands at top of render (pre-layout drain)"
1739 );
1740 for command in early_commands {
1741 if let Err(e) = self.handle_plugin_command(command) {
1742 tracing::error!("Error handling plugin command (pre-layout drain): {}", e);
1743 }
1744 }
1745 }
1746 }
1747 }
1748
1749 /// Ensure the active split's cursor is in view, then synchronise scroll-sync groups.
1750 ///
1751 /// Order matters: `sync_scroll_groups` reads the `viewport.top_byte` that
1752 /// `pre_sync_ensure_visible` just updated. Doing it after the render would
1753 /// produce a one-frame lag on cursor moves that trigger a scroll-sync anchor
1754 /// change (e.g. `G` in a side-by-side diff).
1755 fn pre_sync_and_scroll_sync(&mut self) {
1756 let active_split = self
1757 .windows
1758 .get(&self.active_window)
1759 .and_then(|w| w.buffers.splits())
1760 .map(|(mgr, _)| mgr)
1761 .expect("active window must have a populated split layout")
1762 .active_split();
1763 {
1764 let _span = tracing::info_span!("pre_sync_ensure_visible").entered();
1765 self.active_window_mut()
1766 .pre_sync_ensure_visible(active_split);
1767 }
1768 {
1769 let _span = tracing::info_span!("sync_scroll_groups").entered();
1770 self.active_window_mut().sync_scroll_groups();
1771 }
1772 }
1773
1774 /// Compute the visible byte range for each split and issue debounced LSP
1775 /// requests for semantic tokens and folding ranges.
1776 fn request_semantic_ranges_for_visible_splits(&mut self) {
1777 let mut semantic_ranges: std::collections::HashMap<BufferId, (usize, usize)> =
1778 std::collections::HashMap::new();
1779 {
1780 let _span = tracing::info_span!("compute_semantic_ranges").entered();
1781 for (split_id, view_state) in self
1782 .windows
1783 .get(&self.active_window)
1784 .and_then(|w| w.buffers.splits())
1785 .map(|(_, vs)| vs)
1786 .expect("active window must have a populated split layout")
1787 {
1788 if let Some(buffer_id) = self
1789 .windows
1790 .get(&self.active_window)
1791 .and_then(|w| w.buffers.splits())
1792 .map(|(mgr, _)| mgr)
1793 .expect("active window must have a populated split layout")
1794 .get_buffer_id((*split_id).into())
1795 {
1796 if let Some(state) = self
1797 .windows
1798 .get(&self.active_window)
1799 .map(|w| &w.buffers)
1800 .expect("active window present")
1801 .get(&buffer_id)
1802 {
1803 let start_line = state.buffer.get_line_number(view_state.viewport.top_byte);
1804 let visible_lines =
1805 view_state.viewport.visible_line_count().saturating_sub(1);
1806 let end_line = start_line.saturating_add(visible_lines);
1807 semantic_ranges
1808 .entry(buffer_id)
1809 .and_modify(|(min_start, max_end)| {
1810 *min_start = (*min_start).min(start_line);
1811 *max_end = (*max_end).max(end_line);
1812 })
1813 .or_insert((start_line, end_line));
1814 }
1815 }
1816 }
1817 }
1818 for (buffer_id, (start_line, end_line)) in semantic_ranges {
1819 self.maybe_request_semantic_tokens_range(buffer_id, start_line, end_line);
1820 self.maybe_request_semantic_tokens_full_debounced(buffer_id);
1821 self.maybe_request_folding_ranges_debounced(buffer_id);
1822 }
1823 }
1824
1825 /// Pre-load viewport data for each visible buffer.
1826 ///
1827 /// Large files use lazy loading: data outside the viewport isn't in memory.
1828 /// This pass materialises the bytes each split needs before the renderer
1829 /// touches them, so the render sees a fully-populated buffer.
1830 fn prepare_visible_buffers_for_render(&mut self) {
1831 let _span = tracing::info_span!("prepare_for_render").entered();
1832 // Pre-collect targets so we can take a mut borrow on buffers below
1833 // without holding the immutable read borrow on self.windows.
1834 let active_id = self.active_window;
1835 let prep_targets: Vec<(BufferId, usize, u16)> = {
1836 let win = self
1837 .windows
1838 .get(&active_id)
1839 .expect("active window must exist");
1840 let (mgr, vs_map) = win
1841 .buffers
1842 .splits()
1843 .expect("active window must have a populated split layout");
1844 vs_map
1845 .iter()
1846 .filter_map(|(split_id, vs)| {
1847 mgr.get_buffer_id((*split_id).into())
1848 .map(|bid| (bid, vs.viewport.top_byte, vs.viewport.height))
1849 })
1850 .collect()
1851 };
1852 let win_buffers = &mut self
1853 .windows
1854 .get_mut(&active_id)
1855 .expect("active window must exist")
1856 .buffers;
1857 for (buffer_id, top_byte, height) in prep_targets {
1858 if let Some(state) = win_buffers.get_mut(&buffer_id) {
1859 if let Err(e) = state.prepare_for_render(top_byte, height) {
1860 tracing::error!("Failed to prepare buffer for render: {}", e);
1861 }
1862 }
1863 }
1864 }
1865
1866 /// Compare the hardware cursor's screen position to the previous frame's
1867 /// and, if it moved by more than the "jump" threshold, start a
1868 /// `CursorJump` animation from the old to the new on-screen position.
1869 /// Successive jumps cancel the prior animation so trail effects don't
1870 /// pile up.
1871 ///
1872 /// Cross-split and cross-buffer transitions (focus change, tab switch)
1873 /// are also animated — the trail crosses pane separators on its way
1874 /// from one buffer's cursor cell to another's.
1875 ///
1876 /// The threshold is intentionally generous: arrow-key/typing moves
1877 /// (small `dx`/`dy`) must NOT trigger the animation, but search jumps,
1878 /// goto-line/definition, and pane switches (which always cross several
1879 /// rows or many columns) must.
1880 fn maybe_start_cursor_jump_animation(
1881 &mut self,
1882 current_pos: Option<(u16, u16)>,
1883 active_split: crate::model::event::LeafId,
1884 ) {
1885 // Honour the global animations toggle. Tests default to
1886 // `animations = false` so single-tick `render()` calls observe the
1887 // settled buffer instead of a mid-flight trail; users can also
1888 // disable animations entirely from config. The dedicated
1889 // `cursor_jump_animation` toggle suppresses just the cursor-jump
1890 // trail while leaving ambient animations (tab slides, dashboard,
1891 // plugin effects) running.
1892 if !self.config.editor.animations || !self.config.editor.cursor_jump_animation {
1893 self.previous_cursor_screen_pos = current_pos.map(|p| (p, active_split));
1894 return;
1895 }
1896
1897 let Some(current) = current_pos else {
1898 // Cursor is hidden this frame (e.g. prompt has focus). Reset the
1899 // tracker so the re-emerging cursor doesn't animate from a stale
1900 // spot when focus returns to a buffer.
1901 self.previous_cursor_screen_pos = None;
1902 return;
1903 };
1904
1905 let prev_entry = self.previous_cursor_screen_pos;
1906 // Update tracking unconditionally for the next frame.
1907 self.previous_cursor_screen_pos = Some((current, active_split));
1908
1909 let Some((prev, prev_split)) = prev_entry else {
1910 return;
1911 };
1912 if prev == current && prev_split == active_split {
1913 return;
1914 }
1915
1916 let dx = (current.0 as i32 - prev.0 as i32).abs();
1917 let dy = (current.1 as i32 - prev.1 as i32).abs();
1918 // Animate when the cursor crossed split panes, or when it made a
1919 // non-incremental move within the same pane: more than two rows
1920 // vertically, or — for moves that stay within ±2 rows — at
1921 // least 80 columns horizontally. The horizontal threshold is
1922 // generous because typing, arrow keys, word-jump, and Home/End
1923 // on long source lines can all exceed a smaller bound without
1924 // being a genuine "jump".
1925 let crossed_panes = prev_split != active_split;
1926 let row_jump = dy > 2;
1927 let col_jump = dx >= 80;
1928 if !crossed_panes && !row_jump && !col_jump {
1929 return;
1930 }
1931
1932 // Cancel any prior cursor-jump animation so trails don't stack.
1933 if let Some(prev_anim) = self.cursor_jump_animation.take() {
1934 self.active_window_mut().animations.cancel(prev_anim);
1935 }
1936
1937 let cursor_color = self.theme.read().unwrap().cursor;
1938 let bg_color = self.theme.read().unwrap().editor_bg;
1939 let id = self.active_window_mut().animations.start(
1940 // The bounding box is for runner bookkeeping only — CursorJump
1941 // paints at absolute screen coords and ignores `area`.
1942 ratatui::layout::Rect {
1943 x: prev.0.min(current.0),
1944 y: prev.1.min(current.1),
1945 width: dx as u16 + 1,
1946 height: dy as u16 + 1,
1947 },
1948 crate::view::animation::AnimationKind::CursorJump {
1949 from: prev,
1950 to: current,
1951 duration: std::time::Duration::from_millis(140),
1952 cursor_color,
1953 bg_color,
1954 },
1955 );
1956 self.cursor_jump_animation = Some(id);
1957 }
1958
1959 /// Returns true if `(x, y)` falls inside any popup-style overlay that
1960 /// was rendered this frame. Used to decide whether the hardware cursor
1961 /// should be shown or hidden so it does not bleed through a popup.
1962 fn cursor_obscured_by_overlay(&self, x: u16, y: u16) -> bool {
1963 let inside = |rect: ratatui::layout::Rect| -> bool {
1964 x >= rect.x
1965 && x < rect.x.saturating_add(rect.width)
1966 && y >= rect.y
1967 && y < rect.y.saturating_add(rect.height)
1968 };
1969
1970 if self
1971 .active_chrome()
1972 .popup_areas
1973 .iter()
1974 .any(|entry| inside(entry.1))
1975 {
1976 return true;
1977 }
1978 if self
1979 .active_chrome()
1980 .global_popup_areas
1981 .iter()
1982 .any(|entry| inside(entry.1))
1983 {
1984 return true;
1985 }
1986 if let Some((rect, _, _, _)) = self.active_chrome().suggestions_area {
1987 if inside(rect) {
1988 return true;
1989 }
1990 }
1991 if let Some(ref fb) = self.active_window().file_browser_layout {
1992 if inside(fb.popup_area) {
1993 return true;
1994 }
1995 }
1996 false
1997 }
1998
1999 /// Render the Quick Open hints line showing available mode prefixes
2000 fn render_quick_open_hints(
2001 frame: &mut Frame,
2002 area: ratatui::layout::Rect,
2003 theme: &crate::view::theme::Theme,
2004 ) {
2005 use ratatui::style::{Modifier, Style};
2006 use ratatui::text::{Line, Span};
2007 use ratatui::widgets::Paragraph;
2008 use rust_i18n::t;
2009
2010 let hints_style = Style::default()
2011 .fg(theme.line_number_fg)
2012 .bg(theme.suggestion_selected_bg)
2013 .add_modifier(Modifier::DIM);
2014 let hints_text = t!("quick_open.mode_hints");
2015 // Left-align with small margin
2016 let left_margin = 2;
2017 let hints_width = crate::primitives::display_width::str_width(&hints_text);
2018 let mut spans = Vec::new();
2019 spans.push(Span::styled(" ".repeat(left_margin), hints_style));
2020 spans.push(Span::styled(hints_text.to_string(), hints_style));
2021 let remaining = (area.width as usize).saturating_sub(left_margin + hints_width);
2022 spans.push(Span::styled(" ".repeat(remaining), hints_style));
2023
2024 let paragraph = Paragraph::new(Line::from(spans));
2025 frame.render_widget(paragraph, area);
2026 }
2027
2028 /// Apply dimming effect to UI elements outside the focused terminal area
2029 /// This visually indicates that keyboard capture mode is active
2030 fn apply_keyboard_capture_dimming(
2031 &self,
2032 frame: &mut Frame,
2033 terminal_area: ratatui::layout::Rect,
2034 ) {
2035 let size = frame.area();
2036 crate::view::dimming::apply_dimming_excluding(frame, size, Some(terminal_area));
2037 }
2038
2039 /// Render file browser or suggestions popup as overlay above the prompt line.
2040 /// Called after status bar + prompt so the popup draws on top of both.
2041 fn render_prompt_popups(
2042 &mut self,
2043 frame: &mut Frame,
2044 prompt_area: ratatui::layout::Rect,
2045 chrome: ratatui::layout::Rect,
2046 ) {
2047 let width = chrome.width;
2048 let Some(prompt) = &self.active_window_mut().prompt else {
2049 return;
2050 };
2051
2052 // Overlay prompts (Live Grep, issue #1796) get a dedicated
2053 // centred floating frame instead of the bottom-anchored popup.
2054 // Centre it in the chrome area (right of a left dock) so it never
2055 // overlaps the dock column.
2056 if prompt.overlay {
2057 self.render_overlay_prompt(frame, chrome);
2058 return;
2059 }
2060
2061 if matches!(
2062 prompt.prompt_type,
2063 PromptType::OpenFile | PromptType::SwitchProject | PromptType::SaveFileAs
2064 ) {
2065 let hover_target = self.active_window().mouse_state.hover_target.clone();
2066 let theme = self.theme.read().unwrap().clone();
2067 let keybindings = self.keybindings.read().unwrap();
2068 let kb_clone = keybindings.clone();
2069 drop(keybindings);
2070 let max_height = prompt_area.y.saturating_sub(1).min(20);
2071 let popup_area = ratatui::layout::Rect {
2072 // Anchor to the prompt line's x (right of a left dock,
2073 // if any) so the picker never overlaps the dock column.
2074 x: prompt_area.x,
2075 y: prompt_area.y.saturating_sub(max_height),
2076 width,
2077 height: max_height,
2078 };
2079 let __win = self.active_window_mut();
2080 let Some(file_open_state) = &mut __win.file_open_state else {
2081 return;
2082 };
2083 __win.file_browser_layout = crate::view::ui::FileBrowserRenderer::render(
2084 frame,
2085 popup_area,
2086 file_open_state,
2087 &theme,
2088 &hover_target,
2089 Some(&kb_clone),
2090 );
2091 return;
2092 }
2093
2094 if prompt.suggestions.is_empty() {
2095 return;
2096 }
2097
2098 let suggestion_count = prompt.suggestions.len().min(10);
2099 let is_quick_open = prompt.prompt_type == crate::view::prompt::PromptType::QuickOpen;
2100 let hints_height: u16 = if is_quick_open { 1 } else { 0 };
2101 let height = suggestion_count as u16 + 2 + hints_height;
2102
2103 let suggestions_area = ratatui::layout::Rect {
2104 x: prompt_area.x,
2105 y: prompt_area.y.saturating_sub(height),
2106 width,
2107 height: height - hints_height,
2108 };
2109
2110 frame.render_widget(ratatui::widgets::Clear, suggestions_area);
2111
2112 // Adjust the prompt's scroll position to keep the selected item
2113 // visible, scrolling the minimum amount required.
2114 if let Some(prompt) = self.active_window_mut().prompt.as_mut() {
2115 prompt.ensure_selected_visible();
2116 }
2117 let Some(prompt) = &self.active_window().prompt else {
2118 return;
2119 };
2120
2121 let new_suggestions_area = SuggestionsRenderer::render_with_hover(
2122 frame,
2123 suggestions_area,
2124 prompt,
2125 &*self.theme.read().unwrap(),
2126 self.active_window().mouse_state.hover_target.as_ref(),
2127 true,
2128 );
2129 let chrome = self.active_chrome_mut();
2130 chrome.suggestions_area = new_suggestions_area;
2131 if chrome.suggestions_area.is_some() {
2132 chrome.suggestions_outer_area = Some(suggestions_area);
2133 }
2134
2135 if is_quick_open {
2136 let hints_area = ratatui::layout::Rect {
2137 // Align with the prompt / suggestions box, which sit in the
2138 // chrome area to the right of a left dock (`prompt_area.x`).
2139 // Hardcoding `x: 0` here drew the hints starting at the very
2140 // left edge — under the dock column — so the bar was
2141 // partially obscured by the dock and visibly misaligned with
2142 // the suggestions box stacked directly above it.
2143 x: prompt_area.x,
2144 y: prompt_area.y.saturating_sub(hints_height),
2145 width,
2146 height: hints_height,
2147 };
2148 frame.render_widget(ratatui::widgets::Clear, hints_area);
2149 Self::render_quick_open_hints(frame, hints_area, &*self.theme.read().unwrap());
2150 }
2151 }
2152
2153 /// Resolve the overlay's currently-selected match into a real
2154 /// `Buffer` parked in a phantom `LeafId`, so the preview pane can
2155 /// reuse the regular per-leaf renderer (with syntax highlighting,
2156 /// gutter, scrollbars, folding). No-op when the prompt has no
2157 /// selection or its label is not a `path:line[:col]` triple.
2158 /// Render the entire stashed split tree of `self.preview_window_id`
2159 /// into `inner` — Primitive #1 of
2160 /// `docs/internal/orchestrator-sessions-design.md`'s "Rich
2161 /// Control Room rendering". Reuses the editor's existing
2162 /// `render_content` path against the previewed session's
2163 /// stashed `(SplitManager, view_states)` so syntax
2164 /// highlighting, terminal grids, decorations, and folding
2165 /// all surface natively in the preview pane.
2166 ///
2167 /// The previewed session's splits stash is `take`n out for
2168 /// the duration of the call (so we can pass `&mut` through
2169 /// the renderer without re-entering `self.windows`) and put
2170 /// back after. `pending_hardware_cursor` and
2171 /// `cell_theme_map` use scratch locals so the active editor
2172 /// area's hit-testing isn't clobbered by the preview pass.
2173 fn render_session_preview_into_rect(
2174 &mut self,
2175 frame: &mut ratatui::Frame,
2176 inner: ratatui::layout::Rect,
2177 theme: &crate::view::theme::Theme,
2178 ) {
2179 let Some(sid) = self.preview_window_id else {
2180 return;
2181 };
2182
2183 // Lazy materialization: a previewed session whose workspace
2184 // hasn't been restored yet gets restored on its first preview
2185 // frame, so the embed paints real content. No-op once
2186 // materialized (cleared from `materialize_pending`).
2187 self.materialize_window(sid);
2188
2189 // Terminal grid → buffer text "sync" was previously a
2190 // multi-step append/reload/truncate dance that mutated the
2191 // backing file on every preview-render frame just to make
2192 // the live screen visible inside the embed. That worked
2193 // around `render_terminal_splits` being hard-coded to the
2194 // active window's `terminal_buffers` map — during preview
2195 // the active window is the *caller's* session, so the
2196 // overlay couldn't find the previewed terminal.
2197 //
2198 // `render_terminal_splits` is now an `impl Window` method,
2199 // so the preview path can ask the previewed window
2200 // directly. The overlay paints the live PTY grid (with
2201 // colors, attributes, no cursor) on top of `SplitRenderer`'s
2202 // text rendering for every terminal buffer in the embed —
2203 // no file mutation, no reload, no truncate. The buffer's
2204 // backing file stays untouched between frames.
2205
2206 // Pull the previewed window's split stash and sub-fields
2207 // out under one `&mut Window` borrow. Multiple disjoint
2208 // sub-borrows (`buffers`, `event_logs`, `splits`) coexist
2209 // on the same `Window`, so the renderer call can take all
2210 // three by `&mut` while the rest of `&mut self` stays
2211 // available for `composite_buffers` / `config` / etc.
2212 //
2213 // Step 0h: previously this used `splits.take()` + restore
2214 // because the inline-borrow patterns elsewhere couldn't
2215 // co-exist with a held `&mut sid.splits`. Now that all
2216 // per-window state lives on `Window`, we destructure
2217 // `splits.as_mut()` directly — no transient swap, no
2218 // side-effect plumbing — matching design Primitive #1.
2219 // Bail if the session has no stash yet (never been
2220 // activated and never had a terminal / file routed in via
2221 // createTerminal({windowId})), or has been closed under us
2222 // — e.g. an Orchestrator Archive / Delete completes between
2223 // the floating panel's spec being rebuilt and the next
2224 // render, so the embed's `windowId` momentarily points to
2225 // a window the host already removed. Early-return rather
2226 // than panic; the next plugin refresh re-emits the spec
2227 // without the dead embed.
2228 let Some(__win_for_preview) = self.windows.get_mut(&sid) else {
2229 return;
2230 };
2231 let __preview_metadata = &__win_for_preview.buffer_metadata;
2232 let __preview_event_logs = &mut __win_for_preview.event_logs;
2233 let __preview_composite_buffers = &mut __win_for_preview.composite_buffers;
2234 let __preview_composite_view_states = &mut __win_for_preview.composite_view_states;
2235 // Issue #2035: pass the previewed window's actual
2236 // `grouped_subtrees` map. The previous code allocated an
2237 // empty HashMap here, which made the split renderer unable
2238 // to resolve any `active_group_tab` to its panel layout —
2239 // so a session whose active tab was a buffer group (e.g.
2240 // git_log's log/detail panels) silently fell through to
2241 // rendering the split's underlying pre-group buffer.
2242 let __preview_grouped_subtrees = &__win_for_preview.grouped_subtrees;
2243 let preview_tab_bar_visible = __win_for_preview.tab_bar_visible;
2244
2245 // Per-call scratch — keeps the preview pass from
2246 // clobbering the active editor area's hit-testing /
2247 // hardware-cursor placement.
2248 let mut scratch_cell_theme_map: Vec<crate::app::types::CellThemeInfo> = Vec::new();
2249 let mut scratch_pending_cursor: Option<(u16, u16)> = None;
2250 let lsp_waiting = false; // preview never shows LSP-waiting chrome
2251
2252 let mut preview_split_areas: Vec<(
2253 crate::model::event::LeafId,
2254 fresh_core::BufferId,
2255 ratatui::layout::Rect,
2256 ratatui::layout::Rect,
2257 usize,
2258 usize,
2259 )> = Vec::new();
2260 __win_for_preview
2261 .buffers
2262 .with_all_mut(|preview_buffers, mgr, view_states| {
2263 let result = crate::view::ui::SplitRenderer::render_content(
2264 frame,
2265 inner,
2266 &*mgr,
2267 preview_buffers,
2268 __preview_metadata,
2269 __preview_event_logs,
2270 __preview_composite_buffers,
2271 __preview_composite_view_states,
2272 theme,
2273 self.ansi_background.as_ref(),
2274 self.background_fade,
2275 lsp_waiting,
2276 self.config.editor.large_file_threshold_bytes,
2277 self.config.editor.line_wrap,
2278 self.config.editor.estimated_line_length,
2279 self.config.editor.highlight_context_bytes,
2280 Some(view_states),
2281 __preview_grouped_subtrees,
2282 true, // hide_cursor — the active session owns the hardware caret
2283 None, // no tab-hover routing in the preview
2284 None,
2285 None,
2286 false, // not maximized
2287 self.config.editor.relative_line_numbers,
2288 preview_tab_bar_visible,
2289 self.config.editor.use_terminal_bg,
2290 self.session_mode || !self.software_cursor_only,
2291 self.software_cursor_only,
2292 // Scrollbars are noisy in a small preview rect; the
2293 // active session's chrome is the source of truth.
2294 false,
2295 false,
2296 self.config.editor.diagnostics_inline_text,
2297 false, // hide tilde markers in the preview
2298 self.config.editor.highlight_current_column,
2299 self.config.editor.hide_current_line_on_selection,
2300 &mut scratch_cell_theme_map,
2301 inner.width,
2302 &mut scratch_pending_cursor,
2303 );
2304 preview_split_areas = result.0;
2305 });
2306
2307 // Resize the previewed window's terminal PTYs to fit the
2308 // preview embed before painting their grids. Without this,
2309 // the PTY child (e.g. `top`, `htop`, `vim`, claude) keeps
2310 // drawing at the dimensions it had when last active — often
2311 // the full terminal height — so the preview embed only
2312 // shows the top slice of a much taller frame. Resizing
2313 // SIGWINCHes the PTY, which redraws at the new size, and
2314 // the next render frame paints the correctly-sized grid.
2315 // When the user dives into the session,
2316 // `Window::resize_visible_terminals` will resize back up to
2317 // the dive view's split rect.
2318 if let Some(win) = self.windows.get_mut(&sid) {
2319 for (_split_id, buffer_id, content_rect, _scrollbar_rect, _, _) in &preview_split_areas
2320 {
2321 if win.terminal_buffers.contains_key(buffer_id)
2322 && content_rect.width > 0
2323 && content_rect.height > 0
2324 {
2325 win.resize_terminal(*buffer_id, content_rect.width, content_rect.height);
2326 }
2327 }
2328 }
2329
2330 // Overlay live PTY grids for terminal buffers in the
2331 // previewed window's splits — paints colors, attributes,
2332 // and the visible screen on top of `SplitRenderer`'s text
2333 // rendering. `cursor_visible_if_active = false` keeps the
2334 // preview read-only: no blinking cursor over a session
2335 // the user isn't currently driving.
2336 if let Some(win) = self.windows.get(&sid) {
2337 win.render_terminal_splits(frame, &preview_split_areas, false);
2338 }
2339 }
2340
2341 fn prepare_overlay_preview(&mut self) {
2342 use crate::input::quick_open::parse_path_line_col;
2343
2344 let parsed = {
2345 self.active_window()
2346 .prompt
2347 .as_ref()
2348 .and_then(|prompt| {
2349 let idx = prompt.selected_suggestion?;
2350 prompt.suggestions.get(idx)
2351 })
2352 .map(|s| {
2353 // `value` is the authoritative `path:line:col` for the
2354 // result. We must not rely on parsing the user-facing
2355 // label (`text`), which may carry source badges (e.g.
2356 // "[term]") that make it unparseable as a path. Only fall
2357 // back to the label when `value` is absent/unparseable.
2358 if let Some(v) = s.value.as_deref() {
2359 let from_value = parse_path_line_col(v);
2360 if !from_value.0.is_empty() && from_value.1.is_some() {
2361 return from_value;
2362 }
2363 }
2364 parse_path_line_col(&s.text)
2365 })
2366 };
2367 // No selectable result (empty list, no selection, or an
2368 // unparseable entry): blank the preview so the previous match's
2369 // content doesn't linger after the result list clears.
2370 let (path_str, line, col) = match parsed {
2371 Some((path, line, col)) if !path.is_empty() => (path, line, col),
2372 _ => {
2373 self.blank_overlay_preview();
2374 return;
2375 }
2376 };
2377 let line = line.unwrap_or(1).saturating_sub(1);
2378 let col = col.unwrap_or(1).saturating_sub(1);
2379
2380 // Resolve relative to the working directory.
2381 let path_buf = std::path::PathBuf::from(&path_str);
2382 let abs_path = if path_buf.is_absolute() {
2383 path_buf
2384 } else {
2385 self.working_dir().join(&path_buf)
2386 };
2387 // Canonicalize for buffer-dedup parity with open_file_no_focus.
2388 let abs_path = self
2389 .authority()
2390 .filesystem
2391 .canonicalize(&abs_path)
2392 .unwrap_or(abs_path);
2393
2394 // If the standalone state already targets this path, just
2395 // re-seed the cursor and skip the file-load roundtrip.
2396 let already_target = self
2397 .active_window()
2398 .overlay_preview_state
2399 .as_ref()
2400 .is_some_and(|st| {
2401 self.windows
2402 .get(&self.active_window)
2403 .map(|w| &w.buffers)
2404 .expect("active window present")
2405 .get(&st.buffer_id)
2406 .and_then(|s| s.buffer.file_path())
2407 .is_some_and(|p| p == abs_path.as_path())
2408 });
2409
2410 let buffer_id = if already_target {
2411 self.active_window_mut()
2412 .overlay_preview_state
2413 .as_ref()
2414 .unwrap()
2415 .buffer_id
2416 } else {
2417 // Snapshot whether this path was already known so we can
2418 // tell "I just loaded it for preview" from "the user had
2419 // it open" — only the former gets cleaned up on close.
2420 let was_open = self
2421 .buffers()
2422 .iter()
2423 .any(|(_, s)| s.buffer.file_path() == Some(abs_path.as_path()));
2424 // Capture the active split so we can undo the side
2425 // effects of `open_file_no_focus` (it adds the buffer to
2426 // the active split's tabs and may switch its active
2427 // buffer to the loaded file).
2428 let source_split = self
2429 .windows
2430 .get(&self.active_window)
2431 .and_then(|w| w.buffers.splits())
2432 .map(|(mgr, _)| mgr)
2433 .expect("active window must have a populated split layout")
2434 .active_split();
2435 // `open_file_for_preview` always allocates a fresh buffer
2436 // — never repurposes the "no name" empty buffer the user
2437 // is currently looking at — so the background view stays
2438 // intact while we cycle through preview results.
2439 let buffer_id = match self.open_file_for_preview(abs_path.as_path()) {
2440 Ok(id) => id,
2441 Err(_e) => return,
2442 };
2443 if !was_open {
2444 if let Some(meta) = self.active_window_mut().buffer_metadata.get_mut(&buffer_id) {
2445 meta.hidden_from_tabs = true;
2446 }
2447 // Drop the buffer from every split's `open_buffers`
2448 // list so it doesn't surface as a tab anywhere. The
2449 // phantom buffer is rendered exclusively via the
2450 // overlay's standalone view-state — it doesn't need
2451 // to be in `open_buffers`.
2452 let leaf_ids: Vec<_> = self
2453 .windows
2454 .get(&self.active_window)
2455 .and_then(|w| w.buffers.splits())
2456 .map(|(_, vs)| vs)
2457 .expect("active window must have a populated split layout")
2458 .keys()
2459 .copied()
2460 .collect();
2461 for leaf_id in leaf_ids {
2462 if let Some(view_state) = self
2463 .windows
2464 .get_mut(&self.active_window)
2465 .and_then(|w| w.split_view_states_mut())
2466 .expect("active window must have a populated split layout")
2467 .get_mut(&leaf_id)
2468 {
2469 view_state.remove_buffer(buffer_id);
2470 }
2471 }
2472 // open_file_no_focus may have switched the active
2473 // buffer of the source split. Restore it.
2474 let preview_loaded: std::collections::HashSet<BufferId> = self
2475 .active_window_mut()
2476 .overlay_preview_state
2477 .as_ref()
2478 .map(|st| st.loaded_buffers.clone())
2479 .unwrap_or_default();
2480 let __active_id = self.active_window;
2481 let __win = self
2482 .windows
2483 .get_mut(&__active_id)
2484 .expect("active window must exist");
2485 let __buffer_keys: Vec<BufferId> = __win.buffers.ids();
2486 let (__mgr, __vs_map) = __win
2487 .buffers
2488 .splits_mut()
2489 .expect("active window must have a populated split layout");
2490 if let Some(source_state) = __vs_map.get_mut(&source_split) {
2491 if source_state.active_buffer == buffer_id {
2492 let fallback = source_state
2493 .open_buffers
2494 .iter()
2495 .find_map(|t| t.as_buffer())
2496 .or_else(|| {
2497 __buffer_keys
2498 .iter()
2499 .copied()
2500 .find(|b| *b != buffer_id && !preview_loaded.contains(b))
2501 });
2502 if let Some(fb) = fallback {
2503 source_state.switch_buffer(fb);
2504 __mgr.set_split_buffer(source_split, fb);
2505 }
2506 }
2507 }
2508 self.windows
2509 .get_mut(&self.active_window)
2510 .and_then(|w| w.split_manager_mut())
2511 .expect("active window must have a populated split layout")
2512 .set_active_split(source_split);
2513 }
2514 buffer_id
2515 };
2516
2517 // The buffer (if any) the preview pointed at on the previous
2518 // frame. When the selection moves to a result in a *different*
2519 // file we must drop our search-match overlays from the old
2520 // buffer (see the highlight refresh below).
2521 let prev_preview_buffer = self
2522 .active_window()
2523 .overlay_preview_state
2524 .as_ref()
2525 .map(|s| s.buffer_id);
2526
2527 // Build (or update) the standalone preview state. Held off
2528 // `split_view_states` so cross-cutting iteration never touches
2529 // it.
2530 let need_init = self.active_window_mut().overlay_preview_state.is_none();
2531 if need_init {
2532 let mut view_state = crate::view::split::SplitViewState::with_buffer(
2533 self.terminal_width,
2534 self.terminal_height,
2535 buffer_id,
2536 );
2537 view_state.apply_config_defaults(
2538 self.config.editor.line_numbers,
2539 self.config.editor.highlight_current_line,
2540 self.active_window().resolve_line_wrap_for_buffer(buffer_id),
2541 self.config.editor.wrap_indent,
2542 self.active_window()
2543 .resolve_wrap_column_for_buffer(buffer_id),
2544 self.config.editor.rulers.clone(),
2545 self.config.editor.scroll_offset,
2546 );
2547 let mut loaded_buffers = std::collections::HashSet::new();
2548 // Whether this *first* preview buffer was newly loaded.
2549 // The pre-existing case skips the `was_open` branch so
2550 // we re-derive it from buffer_metadata: a buffer with
2551 // hidden_from_tabs=true that we just touched is one we
2552 // owned. Simpler: track via the existing-target check:
2553 // if `already_target` was false above, the buffer was
2554 // either pre-open (we left meta alone) or freshly
2555 // loaded (we set hidden_from_tabs=true). Re-check.
2556 if let Some(meta) = self.active_window().buffer_metadata.get(&buffer_id) {
2557 if meta.hidden_from_tabs {
2558 loaded_buffers.insert(buffer_id);
2559 }
2560 }
2561 self.active_window_mut().overlay_preview_state =
2562 Some(crate::app::types::OverlayPreviewState {
2563 buffer_id,
2564 view_state,
2565 loaded_buffers,
2566 blanked: false,
2567 centered_byte: None,
2568 });
2569 } else {
2570 // Pre-compute hidden flag (immutable borrow on self.windows)
2571 // before taking the mutable borrow on overlay_preview_state.
2572 let hidden_from_tabs = self
2573 .windows
2574 .get(&self.active_window)
2575 .and_then(|w| w.buffer_metadata.get(&buffer_id))
2576 .is_some_and(|meta| meta.hidden_from_tabs);
2577 if let Some(state) = self.active_window_mut().overlay_preview_state.as_mut() {
2578 if state.buffer_id != buffer_id {
2579 state.view_state.switch_buffer(buffer_id);
2580 // Keep the struct's `buffer_id` in lockstep with the
2581 // view-state's active buffer: the renderer looks up the
2582 // buffer to draw via this field, so a stale value here
2583 // renders the *previous* file's text at the new file's
2584 // scroll offset (wrong content, or blank past EOF).
2585 state.buffer_id = buffer_id;
2586 // New file in the preview ⇒ force a recenter below.
2587 state.centered_byte = None;
2588 if hidden_from_tabs {
2589 state.loaded_buffers.insert(buffer_id);
2590 }
2591 }
2592 }
2593 }
2594
2595 // Set the cursor to the match position and centre it vertically.
2596 let byte_offset = self
2597 .buffers()
2598 .get(&buffer_id)
2599 .map(|s| {
2600 s.buffer
2601 .position_to_offset(crate::model::piece_tree::Position { line, column: col })
2602 })
2603 .unwrap_or(0);
2604
2605 // The overlay preview is used exclusively by the Live Grep
2606 // floating overlay, so the prompt input IS the search query.
2607 // Highlight every occurrence in the visible region — previously
2608 // the match was only reachable via the (hidden) cursor, which is
2609 // near-invisible against the preview chrome. Capture the query and
2610 // theme colours before the window borrow below.
2611 let query = self
2612 .active_window()
2613 .prompt
2614 .as_ref()
2615 .map(|p| p.input.clone())
2616 .unwrap_or_default();
2617 let (search_fg, search_bg) = {
2618 let theme = self.theme.read().unwrap();
2619 (theme.search_match_fg, theme.search_match_bg)
2620 };
2621 // Live Grep defaults to regex with smart-case (case-insensitive
2622 // unless the query carries an uppercase letter) — mirror that so
2623 // the highlight tracks what the search actually matched. A query
2624 // that isn't valid regex falls back to a literal match.
2625 let preview_regex = if query.is_empty() {
2626 None
2627 } else {
2628 let case_insensitive = !query.chars().any(|c| c.is_uppercase());
2629 regex::RegexBuilder::new(&query)
2630 .case_insensitive(case_insensitive)
2631 .build()
2632 .or_else(|_| {
2633 regex::RegexBuilder::new(®ex::escape(&query))
2634 .case_insensitive(case_insensitive)
2635 .build()
2636 })
2637 .ok()
2638 };
2639 let preview_ns = crate::view::overlay::OverlayNamespace::from_string(
2640 "overlay-preview-search".to_string(),
2641 );
2642
2643 let active_id = self.active_window;
2644 if let Some(win) = self.windows.get_mut(&active_id) {
2645 // `buffers` and `overlay_preview_state` are distinct fields, so
2646 // these mutable borrows are disjoint.
2647 let preview_buffer = win.buffers.get_mut(&buffer_id);
2648 let preview_state = win.overlay_preview_state.as_mut();
2649 if let (Some(state), Some(pstate)) = (preview_buffer, preview_state) {
2650 pstate.view_state.cursors.primary_mut().position = byte_offset;
2651 // Force line wrapping on for the preview regardless of the
2652 // global `editor.line_wrap` setting (and of a switched-in
2653 // buffer's fresh default): the preview pane has no
2654 // horizontal scroll affordance, so without wrapping a match
2655 // deep in a long line scrolls off-screen. Wrapping moots
2656 // horizontal scroll, so reset it to the left edge.
2657 // `view_state` derefs to the active buffer's
2658 // `BufferViewState`, so this targets the rendered buffer.
2659 pstate.view_state.viewport.line_wrap_enabled = true;
2660 // Recentre only when the selected match changed (issue
2661 // #2119) so a mouse-wheel scroll of the preview is
2662 // preserved; `center_on_position` counts real visual rows so
2663 // a match deep in a wrapped doc still lands mid-pane.
2664 if pstate.centered_byte != Some(byte_offset) {
2665 pstate.view_state.viewport.left_column = 0;
2666 pstate.view_state.viewport.horizontal_scroll_offset = 0;
2667 pstate
2668 .view_state
2669 .viewport
2670 .center_on_position(&mut state.buffer, byte_offset);
2671 pstate.centered_byte = Some(byte_offset);
2672 }
2673 // We have a live target: ensure the pane is shown.
2674 pstate.blanked = false;
2675
2676 // Rebuild the search-match overlays for the now-visible
2677 // region. Cleared + re-added every frame (cheap; bounded
2678 // to the viewport) so they track scrolling and edits, the
2679 // same contract `Window::update_search_highlights` uses.
2680 state
2681 .overlays
2682 .clear_namespace(&preview_ns, &mut state.marker_list);
2683 if let Some(re) = &preview_regex {
2684 let visible_start = pstate.view_state.viewport.top_byte;
2685 let visible_rows = pstate.view_state.viewport.height as usize;
2686 let mut visible_end = visible_start;
2687 {
2688 let mut iter = state.buffer.line_iterator(visible_start, 80);
2689 for _ in 0..visible_rows {
2690 if let Some((line_start, line_content)) = iter.next_line() {
2691 visible_end = line_start + line_content.len();
2692 } else {
2693 break;
2694 }
2695 }
2696 }
2697 visible_end = visible_end.min(state.buffer.len());
2698 let visible_text = state.get_text_range(visible_start, visible_end);
2699 for mat in re.find_iter(&visible_text) {
2700 if mat.start() == mat.end() {
2701 continue;
2702 }
2703 let absolute_pos = visible_start + mat.start();
2704 let match_len = mat.end() - mat.start();
2705 let style = ratatui::style::Style::default().fg(search_fg).bg(search_bg);
2706 let overlay = crate::view::overlay::Overlay::with_namespace(
2707 &mut state.marker_list,
2708 absolute_pos..(absolute_pos + match_len),
2709 crate::view::overlay::OverlayFace::Style { style },
2710 preview_ns.clone(),
2711 )
2712 .with_priority_value(10);
2713 state.overlays.add(overlay);
2714 }
2715 }
2716 }
2717
2718 // The selection jumped to a result in a different file: scrub
2719 // our overlays from the previously-previewed buffer. Matters
2720 // only for buffers the user already had open — preview-loaded
2721 // buffers are closed wholesale on overlay teardown.
2722 if let Some(prev) = prev_preview_buffer {
2723 if prev != buffer_id {
2724 if let Some(prev_state) = win.buffers.get_mut(&prev) {
2725 prev_state
2726 .overlays
2727 .clear_namespace(&preview_ns, &mut prev_state.marker_list);
2728 }
2729 }
2730 }
2731 }
2732 }
2733
2734 /// Blank the Live Grep preview pane: it renders just its frame until
2735 /// the next selectable result. Keeps `overlay_preview_state` (and its
2736 /// `loaded_buffers` cleanup tracking) intact.
2737 fn blank_overlay_preview(&mut self) {
2738 if let Some(state) = self.active_window_mut().overlay_preview_state.as_mut() {
2739 state.blanked = true;
2740 }
2741 }
2742
2743 /// Render the active prompt as a centred floating overlay
2744 /// (issue #1796). Layout, top-down inside the overlay frame:
2745 ///
2746 /// ```text
2747 /// ┌─ Live Grep ──────────────────────────────────[Esc to close]┐
2748 /// │ Search: split_active| 12 / 142 │ ← input row
2749 /// │ ─────────────────────────────────────────────────────────── │
2750 /// │ src/view/split.rs:1117 pub fn split_active( │ preview │ ← results
2751 /// │ src/view/split.rs:1123 self.split_active_pos… │ pane │ (+ optional
2752 /// │ … │ │ preview)
2753 /// └────────────────────────────────────────────────────────────┘
2754 /// ```
2755 ///
2756 /// The overlay does *not* mutate the split tree; it is a pure
2757 /// `ratatui` overdraw, so dismissing leaves the user's underlying
2758 /// layout exactly as it was (the issue-#1796 acceptance test).
2759 fn render_overlay_prompt(&mut self, frame: &mut Frame, area: ratatui::layout::Rect) {
2760 use ratatui::layout::Rect;
2761 use ratatui::style::{Modifier, Style};
2762 use ratatui::text::{Line, Span};
2763 use ratatui::widgets::{Block, Borders, Clear, Paragraph};
2764
2765 // Compute the overlay rect via the same percentage logic the
2766 // popup engine uses. 90% × 90% of the terminal, centred.
2767 let overlay_rect = Self::centered_overlay_rect(area, 90, 90);
2768
2769 // Snapshot view-relevant state before any mutable borrows.
2770 let theme = self.theme.read().unwrap().clone();
2771 // The suggestion list inside the overlay can be ~30 rows
2772 // tall on a typical terminal. Pass the *actual* visible
2773 // count to `ensure_selected_visible_within` so the scroll
2774 // offset only advances when the selection genuinely passes
2775 // the bottom of the visible window — not when it crosses
2776 // the bottom-popup default cap of `MAX_VISIBLE_SUGGESTIONS`
2777 // (= 10), which would scroll prematurely.
2778 //
2779 // Geometry: overlay frame border (2) + input row (1) +
2780 // optional toolbar row (1, when `prompt.title` is non-empty)
2781 // + separator (1). The suggestions popup is rendered
2782 // borderless inside the overlay (the outer frame already
2783 // provides a border, so adding a nested one creates a
2784 // double-frame). Inner content height = overlay.height -
2785 // chrome.
2786 // Toolbar height must be the *actual* rendered row count — a widget
2787 // toolbar is ≥2 rows (e.g. "Search in:" + "Match:") and wraps to more
2788 // on a narrow terminal. Measuring it (vs assuming 1) keeps
2789 // `suggestions_visible_rows` honest, so `ensure_selected_visible`
2790 // doesn't let the selection scroll just past the real list bottom.
2791 let inner_w = overlay_rect.width.saturating_sub(2);
2792 let toolbar_rows: usize = self
2793 .active_window()
2794 .prompt
2795 .as_ref()
2796 .map(|p| {
2797 if let Some(spec) = p.toolbar_widget.as_ref() {
2798 crate::widgets::render_spec_no_autofocus(
2799 spec,
2800 &std::collections::HashMap::new(),
2801 p.toolbar_focus.as_deref().unwrap_or(""),
2802 inner_w as u32,
2803 )
2804 .entries
2805 .len()
2806 } else if p.title.is_empty() {
2807 0
2808 } else {
2809 1
2810 }
2811 })
2812 .unwrap_or(0);
2813 let footer_visible = self
2814 .active_window()
2815 .prompt
2816 .as_ref()
2817 .map(|p| !p.footer.is_empty())
2818 .unwrap_or(false);
2819 // Chrome around the result list: frame border (2) + input (1) +
2820 // separator (1) + toolbar (`toolbar_rows`) + optional full-width footer (1).
2821 let chrome_rows: usize = 4 + toolbar_rows + usize::from(footer_visible);
2822 let suggestions_visible_rows = (overlay_rect.height as usize).saturating_sub(chrome_rows);
2823 if let Some(prompt) = self.active_window_mut().prompt.as_mut() {
2824 // Skip when the user has wheel-scrolled the list — keeping the
2825 // selection pinned in view would undo their scroll (issue #2119).
2826 if !prompt.manual_scroll {
2827 prompt.ensure_selected_visible_within(suggestions_visible_rows);
2828 }
2829 }
2830 let Some(prompt) = self.active_window().prompt.as_ref() else {
2831 return;
2832 };
2833 let prompt = prompt.clone();
2834
2835 // Dim everything outside the overlay rect so the user's
2836 // focus visibly belongs to the popup. Reuses the same RGB-
2837 // darkening pass the Settings modal uses (`view::dimming`)
2838 // — Modifier::DIM alone is barely visible on most terminals.
2839 crate::view::dimming::apply_dimming_excluding(frame, frame.area(), Some(overlay_rect));
2840
2841 // Clear and frame. Plugin-owned prompts can publish their
2842 // own title via `editor.setPromptTitle(...)`; falls back to
2843 // " Live Grep " plus shortcut hints when unset (so a
2844 // Resume-replay prompt and freshly-opened plugin prompt look
2845 // similar even though they take different code paths).
2846 frame.render_widget(Clear, overlay_rect);
2847 let default_title: Vec<fresh_core::api::StyledText> = {
2848 // Mirrors `updateOverlayTitle` in live_grep.ts (kept in
2849 // sync deliberately so a Resume-replay overlay and a
2850 // freshly-opened plugin overlay look identical). The
2851 // input row's prefix already says "Live grep:", so the
2852 // frame title doesn't repeat the feature name — it
2853 // shows shortcut hints only. `resume_live_grep` is
2854 // intentionally NOT shown here; that shortcut only
2855 // matters once the overlay is closed.
2856 use crate::input::keybindings::KeyContext;
2857 use fresh_core::api::{OverlayColorSpec, OverlayOptions, StyledText};
2858 let keybindings = self.keybindings.read().unwrap();
2859 let mut hints: Vec<(String, &str)> = Vec::new();
2860 if let Some(k) = keybindings
2861 .find_keybinding_for_action("cycle_live_grep_provider", KeyContext::Prompt)
2862 {
2863 hints.push((k, "switch grep provider"));
2864 }
2865 if let Some(k) = keybindings
2866 .find_keybinding_for_action("live_grep_export_quickfix", KeyContext::Prompt)
2867 {
2868 hints.push((k, "save matches"));
2869 }
2870 if hints.is_empty() {
2871 Vec::new()
2872 } else {
2873 let hint_style = Some(OverlayOptions {
2874 fg: Some(OverlayColorSpec::ThemeKey("ui.help_key_fg".into())),
2875 ..OverlayOptions::default()
2876 });
2877 let sep_style = Some(OverlayOptions {
2878 fg: Some(OverlayColorSpec::ThemeKey("ui.popup_border_fg".into())),
2879 ..OverlayOptions::default()
2880 });
2881 let mut segs: Vec<StyledText> = Vec::new();
2882 for (i, (k, verb)) in hints.into_iter().enumerate() {
2883 if i > 0 {
2884 segs.push(StyledText {
2885 text: " · ".into(),
2886 style: sep_style.clone(),
2887 });
2888 }
2889 segs.push(StyledText {
2890 text: k,
2891 style: hint_style.clone(),
2892 });
2893 segs.push(StyledText {
2894 text: format!(" {verb}"),
2895 style: None,
2896 });
2897 }
2898 segs
2899 }
2900 };
2901 let title_segs: &[fresh_core::api::StyledText] = if prompt.title.is_empty() {
2902 &default_title
2903 } else {
2904 &prompt.title
2905 };
2906 let normal_title_style = Style::default()
2907 .fg(theme.prompt_fg)
2908 .add_modifier(Modifier::BOLD);
2909 let title_spans: Vec<Span> = title_segs
2910 .iter()
2911 .map(|seg| {
2912 let style = match &seg.style {
2913 Some(opts) => Self::resolve_overlay_style(opts, &theme),
2914 None => normal_title_style,
2915 };
2916 Span::styled(seg.text.clone(), style)
2917 })
2918 .collect();
2919 let block = Block::default()
2920 .borders(Borders::ALL)
2921 .border_style(Style::default().fg(theme.popup_border_fg))
2922 .style(Style::default().bg(theme.suggestion_bg));
2923 let inner = block.inner(overlay_rect);
2924 frame.render_widget(block, overlay_rect);
2925
2926 if inner.height == 0 || inner.width == 0 {
2927 return;
2928 }
2929
2930 // If the plugin supplied a widget toolbar, render it now (full inner
2931 // width) so we know its height before laying out the header band. The
2932 // toggles are real `Toggle` widgets — themed and clickable — rather
2933 // than styled text. `render_spec` is stateless here (empty prior
2934 // state / no focus key): a `Toggle`'s checked-ness lives in the spec,
2935 // and click-to-toggle is routed by key (no registry needed).
2936 let toolbar_focus_key = prompt.toolbar_focus.as_deref().unwrap_or("");
2937 let toolbar_widget_out: Option<crate::widgets::RenderOutput> =
2938 prompt.toolbar_widget.as_ref().map(|spec| {
2939 crate::widgets::render_spec_no_autofocus(
2940 spec,
2941 &std::collections::HashMap::new(),
2942 toolbar_focus_key,
2943 inner.width as u32,
2944 )
2945 });
2946
2947 // Layout: a full-width HEADER band (input + toolbar + separator)
2948 // spans the whole inner width at the top; the BODY below it splits
2949 // into results | preview; a full-width FOOTER (when the plugin set
2950 // one) sits at the very bottom. This gives the toolbar the entire
2951 // pane width — the scope checkboxes don't fit when squeezed into the
2952 // left half beside the preview — and places the preview *under* the
2953 // toolbar, side-by-side with the result list. See
2954 // docs/internal/global-search-ux.md §12.
2955 let toolbar_h: u16 = match &toolbar_widget_out {
2956 Some(out) => out.entries.len() as u16,
2957 None if !prompt.title.is_empty() => 1,
2958 None => 0,
2959 };
2960 let footer_h: u16 = if prompt.footer.is_empty() { 0 } else { 1 };
2961 // Header rows = input(1) + toolbar(toolbar_h) + separator(1).
2962 let header_h: u16 = 2 + toolbar_h;
2963 let body = Rect {
2964 x: inner.x,
2965 y: inner.y.saturating_add(header_h),
2966 width: inner.width,
2967 height: inner.height.saturating_sub(header_h + footer_h),
2968 };
2969
2970 // Split the body into results | preview. Below ~120 cols, stack
2971 // results-only (preview hidden — see design doc §5 "preview pane size
2972 // when terminal is narrow").
2973 let preview_min_cols: u16 = 120;
2974 let show_preview = overlay_rect.width >= preview_min_cols && body.height > 0;
2975 let (results_area, preview_area) = if show_preview {
2976 let results_w = body.width / 2;
2977 (
2978 Rect {
2979 x: body.x,
2980 y: body.y,
2981 width: results_w,
2982 height: body.height,
2983 },
2984 Some(Rect {
2985 x: body.x + results_w,
2986 y: body.y,
2987 width: body.width - results_w,
2988 height: body.height,
2989 }),
2990 )
2991 } else {
2992 (body, None)
2993 };
2994
2995 // Cache the result/preview rects so the mouse-wheel handler can route
2996 // the wheel to the pane under the pointer (issue #2119).
2997 self.active_chrome_mut().prompt_results_area = Some(results_area);
2998 self.active_chrome_mut().prompt_preview_area = preview_area;
2999
3000 // The prompt input is the full-width top row of the header band.
3001 let input_row = Rect {
3002 x: inner.x,
3003 y: inner.y,
3004 width: inner.width,
3005 height: 1,
3006 };
3007 // Two distinct styles on this row so the user can tell
3008 // the static title (`prompt.message`) apart from the
3009 // editable input field. Title gets the popup-chrome bg
3010 // (matching the toolbar/footer); input + right-side
3011 // padding + count get the editor bg so they read as one
3012 // contiguous text field. All colours from theme keys.
3013 let title_style = Style::default()
3014 .fg(theme.suggestion_fg)
3015 .bg(theme.suggestion_bg);
3016 let input_style = Style::default().fg(theme.editor_fg).bg(theme.editor_bg);
3017 let count_str = if prompt.suggestions.is_empty() {
3018 String::new()
3019 } else {
3020 format!(
3021 "{} / {}",
3022 prompt.selected_suggestion.map(|i| i + 1).unwrap_or(0),
3023 prompt.suggestions.len()
3024 )
3025 };
3026 use crate::primitives::display_width::str_width;
3027 let count_w = str_width(&count_str);
3028 // Reserve one trailing column so the count doesn't sit
3029 // flush against the right border.
3030 let right_gap: usize = if count_w > 0 { 1 } else { 0 };
3031 // Right cluster: "<status> <count>" — the plugin's search status
3032 // (e.g. "Searching…", "No matches") sits just left of the count, so
3033 // it's on the same row the user is typing on rather than a wasted
3034 // chrome row. Two-space gap between status and count when both show.
3035 let status_str = prompt.status.clone();
3036 let status_w = str_width(&status_str);
3037 let status_gap: usize = if status_w > 0 && count_w > 0 { 2 } else { 0 };
3038 let right_cluster_w = status_w + status_gap + count_w + right_gap;
3039 let visible_input_width = (input_row.width as usize).saturating_sub(right_cluster_w);
3040 let truncated_input: String = prompt
3041 .input
3042 .chars()
3043 .take(visible_input_width.saturating_sub(str_width(&prompt.message)))
3044 .collect();
3045 // Pad between the typed input and the right cluster so the count is
3046 // right-aligned (with `right_gap` empty cols at the very edge),
3047 // independent of how much the user has typed.
3048 let used = str_width(&prompt.message) + str_width(&truncated_input) + right_cluster_w;
3049 let pad = (input_row.width as usize).saturating_sub(used);
3050 let dim = Style::default()
3051 .fg(theme.popup_border_fg)
3052 .bg(theme.editor_bg);
3053 let line = Line::from(vec![
3054 Span::styled(prompt.message.clone(), title_style),
3055 Span::styled(truncated_input, input_style),
3056 Span::styled(" ".repeat(pad), input_style),
3057 Span::styled(status_str, dim),
3058 Span::styled(" ".repeat(status_gap), input_style),
3059 Span::styled(count_str, dim),
3060 ]);
3061 frame.render_widget(Paragraph::new(line).style(input_style), input_row);
3062
3063 // Cursor position on the input row — only when the input is focused.
3064 // When a toolbar control owns focus, the highlighted toggle is the
3065 // focus indicator and the input caret would be misleading.
3066 let input_focused = prompt.toolbar_focus.is_none();
3067 let cursor_x = (str_width(&prompt.message)
3068 + str_width(&prompt.input[..prompt.cursor_pos.min(prompt.input.len())]))
3069 as u16;
3070 if input_focused && cursor_x < input_row.width {
3071 frame.set_cursor_position((input_row.x + cursor_x, input_row.y));
3072 }
3073
3074 // Optional toolbar row (the styled segments the plugin set
3075 // via setPromptTitle, e.g. "Provider: rg · Alt+P switch
3076 // grep provider · …"). Sits between the input row and the
3077 // separator so the user sees feature-scoped controls right
3078 // under what they're typing — not on the frame border
3079 // where shortcut hints get visually lost.
3080 self.active_chrome_mut().prompt_toolbar_hits.clear();
3081 if let Some(out) = &toolbar_widget_out {
3082 // Widget toolbar: paint each rendered row across the full width
3083 // and record screen-space hit rects (key → rect) for click
3084 // routing. `HitArea` carries byte offsets within the row's text;
3085 // convert to display columns so the rect lines up with the glyphs.
3086 use crate::primitives::display_width::str_width;
3087 let band_y = inner.y + 1;
3088 for (i, entry) in out.entries.iter().enumerate() {
3089 let y = band_y + i as u16;
3090 if y >= inner.y + inner.height {
3091 break;
3092 }
3093 paint_text_property_entry(frame, entry, inner.x, y, inner.width, &theme);
3094 }
3095 for hit in &out.hits {
3096 if hit.widget_key.is_empty() {
3097 continue;
3098 }
3099 let Some(entry) = out.entries.get(hit.buffer_row as usize) else {
3100 continue;
3101 };
3102 let text = &entry.text;
3103 let start_col = str_width(text.get(..hit.byte_start).unwrap_or(""));
3104 let end_col = str_width(text.get(..hit.byte_end).unwrap_or(text));
3105 let y = band_y + hit.buffer_row as u16;
3106 let rect = Rect {
3107 x: inner.x + start_col as u16,
3108 y,
3109 width: (end_col.saturating_sub(start_col)) as u16,
3110 height: 1,
3111 };
3112 self.active_chrome_mut()
3113 .prompt_toolbar_hits
3114 .push((hit.widget_key.clone(), rect));
3115 }
3116 } else if !prompt.title.is_empty() && inner.height >= 2 {
3117 let toolbar = Rect {
3118 x: inner.x,
3119 y: inner.y + 1,
3120 width: inner.width,
3121 height: 1,
3122 };
3123 frame.render_widget(
3124 Paragraph::new(Line::from(title_spans))
3125 .style(Style::default().bg(theme.suggestion_bg)),
3126 toolbar,
3127 );
3128 }
3129
3130 // Separator row (full width), closing the header band.
3131 if inner.height >= 2 + toolbar_h {
3132 let sep = Rect {
3133 x: inner.x,
3134 y: inner.y + 1 + toolbar_h,
3135 width: inner.width,
3136 height: 1,
3137 };
3138 let sep_style = Style::default()
3139 .fg(theme.popup_border_fg)
3140 .bg(theme.suggestion_bg);
3141 let sep_text = "─".repeat(inner.width as usize);
3142 frame.render_widget(Paragraph::new(sep_text).style(sep_style), sep);
3143 }
3144
3145 // Suggestions list fills `results_area` (the left half of the body)
3146 // entirely — the input, toolbar and separator now live in the header
3147 // band above, and the footer is a separate full-width row below, so
3148 // there's no in-column chrome to subtract here. Carve off the
3149 // rightmost 1-column lane for a scrollbar so the user can see how far
3150 // through the result set the selection is — only when the result set
3151 // actually exceeds the visible rows; otherwise the scrollbar is
3152 // visual noise.
3153 if results_area.height >= 1 {
3154 // No `-2` for popup-own-border — we render the
3155 // borderless variant below since the overlay frame is
3156 // already a border.
3157 let inner_rows = results_area.height as usize;
3158 let needs_scrollbar = prompt.suggestions.len() > inner_rows.max(1);
3159 let scrollbar_w: u16 = if needs_scrollbar { 1 } else { 0 };
3160 let list_area = Rect {
3161 x: results_area.x,
3162 y: results_area.y,
3163 width: results_area.width.saturating_sub(scrollbar_w),
3164 height: results_area.height,
3165 };
3166 self.active_chrome_mut().suggestions_area = SuggestionsRenderer::render_with_hover(
3167 frame,
3168 list_area,
3169 &prompt,
3170 &theme,
3171 self.active_window_mut().mouse_state.hover_target.as_ref(),
3172 false,
3173 );
3174 if self.active_chrome_mut().suggestions_area.is_some() {
3175 self.active_chrome_mut().suggestions_outer_area = Some(list_area);
3176 }
3177 // Render the scrollbar in the carved lane. Reuses the
3178 // shared `view::ui::scrollbar` widget so thumb sizing
3179 // and theme colours match scrollbars elsewhere in the
3180 // editor (split rendering, file explorer, …).
3181 if needs_scrollbar {
3182 use crate::view::ui::scrollbar::{
3183 render_scrollbar, ScrollbarColors, ScrollbarState,
3184 };
3185 // Scrollbar rect aligns with the borderless
3186 // suggestions list — same y/height as the list itself
3187 // since there's no popup-own border to skip.
3188 let scrollbar_rect = Rect {
3189 x: results_area.x + results_area.width - 1,
3190 y: list_area.y,
3191 width: 1,
3192 height: list_area.height,
3193 };
3194 let state = ScrollbarState::new(
3195 prompt.suggestions.len(),
3196 inner_rows.max(1),
3197 prompt.scroll_offset,
3198 );
3199 render_scrollbar(
3200 frame,
3201 scrollbar_rect,
3202 &state,
3203 &ScrollbarColors::from_theme(&theme),
3204 );
3205 // Cache the rect for mouse hit testing in
3206 // `mouse_input.rs::handle_click_prompt_scrollbar`.
3207 self.active_chrome_mut().suggestions_scrollbar_rect = Some(scrollbar_rect);
3208 } else {
3209 self.active_chrome_mut().suggestions_scrollbar_rect = None;
3210 }
3211 } else {
3212 self.active_chrome_mut().suggestions_scrollbar_rect = None;
3213 }
3214
3215 // Plugin-supplied footer chrome row (Primitive #2 chrome
3216 // region). Each segment is a `StyledText` — same styling
3217 // primitive used by `setPromptTitle` and inline overlays,
3218 // so plugins can theme hotkey hints with `ui.help_key_fg`,
3219 // separators with `ui.popup_border_fg`, etc.
3220 if footer_h == 1 && inner.height >= 1 {
3221 let footer_row = Rect {
3222 x: inner.x,
3223 y: inner.y + inner.height - 1,
3224 width: inner.width,
3225 height: 1,
3226 };
3227 let footer_default_style = Style::default()
3228 .fg(theme.suggestion_fg)
3229 .bg(theme.suggestion_bg);
3230 let footer_spans: Vec<Span> = prompt
3231 .footer
3232 .iter()
3233 .map(|seg| {
3234 let style = match &seg.style {
3235 Some(opts) => Self::resolve_overlay_style(opts, &theme),
3236 None => footer_default_style,
3237 };
3238 Span::styled(seg.text.clone(), style)
3239 })
3240 .collect();
3241 frame.render_widget(
3242 Paragraph::new(Line::from(footer_spans))
3243 .style(Style::default().bg(theme.suggestion_bg)),
3244 footer_row,
3245 );
3246 }
3247
3248 // Right-half preview pane: a real Buffer rendered via the
3249 // same per-leaf pipeline regular splits use. Buffer + cursor
3250 // are already seeded by `prepare_overlay_preview` (called
3251 // earlier in the render flow). Borrows are split here so we
3252 // can hand out independent `&mut` references to the
3253 // renderer's internals without going back through `&mut self`.
3254 if let Some(preview_rect) = preview_area {
3255 // Frame the preview area first (vertical separator) so
3256 // the renderer fills the inner rect.
3257 use ratatui::widgets::{Block, Borders, Clear};
3258 frame.render_widget(Clear, preview_rect);
3259 let block = Block::default()
3260 .borders(Borders::LEFT)
3261 .border_style(Style::default().fg(theme.popup_border_fg))
3262 .style(Style::default().bg(theme.suggestion_bg));
3263 let inner = block.inner(preview_rect);
3264 frame.render_widget(block, preview_rect);
3265
3266 // Primitive #1: if the active plugin asked us to
3267 // preview a specific (inactive) session in this
3268 // rect, render that session's entire stashed split
3269 // tree natively into `inner`. Falls back to the
3270 // existing path-based phantom-leaf preview when no
3271 // session override is set.
3272 if inner.height > 0
3273 && inner.width > 0
3274 && self
3275 .preview_window_id
3276 .is_some_and(|sid| sid != self.active_window && self.windows.contains_key(&sid))
3277 {
3278 self.render_session_preview_into_rect(frame, inner, &theme);
3279 } else if inner.height > 0 && inner.width > 0 {
3280 // Snapshot scalar config values up front so the
3281 // mutable-borrow split below has minimal scope.
3282 // AnsiBackground isn't Clone, so it's taken as a
3283 // borrow; Rust permits disjoint-field splitting
3284 // between `&self.ansi_background` and the `&mut`
3285 // accesses below because they touch distinct fields.
3286 let bg_fade = self.background_fade;
3287 let estimated_line_length = self.config.editor.estimated_line_length;
3288 let highlight_context_bytes = self.config.editor.highlight_context_bytes;
3289 let relative_line_numbers = self.config.editor.relative_line_numbers;
3290 let use_terminal_bg = self.config.editor.use_terminal_bg;
3291 let session_mode = self.session_mode || !self.software_cursor_only;
3292 let software_cursor_only = self.software_cursor_only;
3293 let diagnostics_inline_text = self.config.editor.diagnostics_inline_text;
3294 let show_tilde = false; // preview hides tilde markers
3295 let highlight_current_column = self.config.editor.highlight_current_column;
3296 let screen_width = frame.area().width;
3297
3298 let ansi_ref = self.ansi_background.as_ref();
3299 let __win = self
3300 .windows
3301 .get_mut(&self.active_window)
3302 .expect("active window present");
3303 let buffers = &mut __win.buffers;
3304 let event_logs = &mut __win.event_logs;
3305 let cell_theme_map = &mut __win.chrome_layout.cell_theme_map;
3306 let Some(preview_state) = __win.overlay_preview_state.as_mut() else {
3307 return;
3308 };
3309 // Blanked: the current query has no selectable result, so
3310 // leave the framed pane empty rather than rendering a stale
3311 // match.
3312 if preview_state.blanked {
3313 return;
3314 }
3315 preview_state
3316 .view_state
3317 .viewport
3318 .resize(inner.width, inner.height);
3319 let buffer_id = preview_state.buffer_id;
3320
3321 if let Some(state) = buffers.get_mut(&buffer_id) {
3322 // Deref the SplitViewState once to a concrete
3323 // `&mut BufferViewState` so disjoint field
3324 // splits (`viewport` + `folds`) are visible
3325 // to the borrow checker.
3326 let buf_state = preview_state.view_state.active_state_mut();
3327 let cursors = buf_state.cursors.clone();
3328 let view_mode = buf_state.view_mode.clone();
3329 let compose_width = buf_state.compose_width;
3330 let compose_column_guides = buf_state.compose_column_guides.clone();
3331 let view_transform = buf_state.view_transform.clone();
3332 let rulers = buf_state.rulers.clone();
3333 let show_line_numbers = buf_state.show_line_numbers;
3334 let highlight_current_line = buf_state.highlight_current_line;
3335 let viewport_ref = &mut buf_state.viewport;
3336 let folds_ref = &mut buf_state.folds;
3337 let event_log = event_logs.get_mut(&buffer_id);
3338 let _ = crate::view::ui::SplitRenderer::render_phantom_leaf(
3339 frame,
3340 state,
3341 &cursors,
3342 viewport_ref,
3343 folds_ref,
3344 event_log,
3345 inner,
3346 &theme,
3347 ansi_ref,
3348 bg_fade,
3349 view_mode,
3350 compose_width,
3351 compose_column_guides,
3352 view_transform,
3353 estimated_line_length,
3354 highlight_context_bytes,
3355 buffer_id,
3356 relative_line_numbers,
3357 use_terminal_bg,
3358 session_mode,
3359 software_cursor_only,
3360 &rulers,
3361 show_line_numbers,
3362 highlight_current_line,
3363 diagnostics_inline_text,
3364 show_tilde,
3365 highlight_current_column,
3366 cell_theme_map,
3367 screen_width,
3368 );
3369 }
3370 }
3371 }
3372 }
3373
3374 /// Render hover highlights for interactive elements (separators, scrollbars)
3375 pub(super) fn render_hover_highlights(&self, frame: &mut Frame) {
3376 use ratatui::style::Style;
3377 use ratatui::text::Span;
3378 use ratatui::widgets::Paragraph;
3379
3380 match &self.active_window().mouse_state.hover_target {
3381 Some(HoverTarget::SplitSeparator(split_id, direction)) => {
3382 // Highlight the separator with hover color
3383 for (sid, dir, x, y, length) in &self.active_layout().separator_areas {
3384 if sid == split_id && dir == direction {
3385 let (hover_fg, editor_bg) = {
3386 let theme = self.theme.read().unwrap();
3387 (theme.split_separator_hover_fg, theme.editor_bg)
3388 };
3389 let hover_style = Style::default().fg(hover_fg).bg(editor_bg);
3390 match dir {
3391 SplitDirection::Horizontal => {
3392 let line_text = "─".repeat(*length as usize);
3393 let paragraph =
3394 Paragraph::new(Span::styled(line_text, hover_style));
3395 frame.render_widget(
3396 paragraph,
3397 ratatui::layout::Rect::new(*x, *y, *length, 1),
3398 );
3399 }
3400 SplitDirection::Vertical => {
3401 for offset in 0..*length {
3402 let paragraph = Paragraph::new(Span::styled("│", hover_style));
3403 frame.render_widget(
3404 paragraph,
3405 ratatui::layout::Rect::new(*x, y + offset, 1, 1),
3406 );
3407 }
3408 }
3409 }
3410 }
3411 }
3412 }
3413 Some(HoverTarget::ScrollbarThumb(split_id)) => {
3414 // Highlight scrollbar thumb
3415 for (sid, _buffer_id, _content_rect, scrollbar_rect, thumb_start, thumb_end) in
3416 &self.active_layout().split_areas
3417 {
3418 if sid == split_id {
3419 let hover_style = Style::default().bg(self
3420 .theme
3421 .read()
3422 .unwrap()
3423 .scrollbar_thumb_hover_fg);
3424 for row_offset in *thumb_start..*thumb_end {
3425 let paragraph = Paragraph::new(Span::styled(" ", hover_style));
3426 frame.render_widget(
3427 paragraph,
3428 ratatui::layout::Rect::new(
3429 scrollbar_rect.x,
3430 scrollbar_rect.y + row_offset as u16,
3431 1,
3432 1,
3433 ),
3434 );
3435 }
3436 }
3437 }
3438 }
3439 Some(HoverTarget::ScrollbarTrack(split_id, hovered_row)) => {
3440 // Highlight only the hovered cell on the scrollbar track
3441 for (sid, _buffer_id, _content_rect, scrollbar_rect, _thumb_start, _thumb_end) in
3442 &self.active_layout().split_areas
3443 {
3444 if sid == split_id {
3445 let track_hover_style = Style::default().bg(self
3446 .theme
3447 .read()
3448 .unwrap()
3449 .scrollbar_track_hover_fg);
3450 let paragraph = Paragraph::new(Span::styled(" ", track_hover_style));
3451 frame.render_widget(
3452 paragraph,
3453 ratatui::layout::Rect::new(
3454 scrollbar_rect.x,
3455 scrollbar_rect.y + hovered_row,
3456 1,
3457 1,
3458 ),
3459 );
3460 }
3461 }
3462 }
3463 Some(HoverTarget::FileExplorerBorder) => {
3464 // Highlight the file explorer border for resize
3465 if let Some(explorer_area) = self.active_layout().file_explorer_area {
3466 let (hover_fg, editor_bg) = {
3467 let theme = self.theme.read().unwrap();
3468 (theme.split_separator_hover_fg, theme.editor_bg)
3469 };
3470 let hover_style = Style::default().fg(hover_fg).bg(editor_bg);
3471 let border_x = explorer_area.x + explorer_area.width.saturating_sub(1);
3472 for row_offset in 0..explorer_area.height {
3473 let paragraph = Paragraph::new(Span::styled("│", hover_style));
3474 frame.render_widget(
3475 paragraph,
3476 ratatui::layout::Rect::new(
3477 border_x,
3478 explorer_area.y + row_offset,
3479 1,
3480 1,
3481 ),
3482 );
3483 }
3484 }
3485 }
3486 // Menu hover is handled by MenuRenderer
3487 _ => {}
3488 }
3489 }
3490
3491 /// Render the tab context menu
3492 fn render_tab_context_menu(&self, frame: &mut Frame, menu: &TabContextMenu) {
3493 use ratatui::style::Style;
3494 use ratatui::text::{Line, Span};
3495 use ratatui::widgets::{Block, Borders, Clear, Paragraph};
3496
3497 let items = super::types::TabContextMenuItem::all();
3498 let menu_width = 22u16; // "Close to the Right" + padding
3499 let menu_height = items.len() as u16 + 2; // items + borders
3500
3501 // Adjust position to stay within screen bounds
3502 let screen_width = frame.area().width;
3503 let screen_height = frame.area().height;
3504
3505 let menu_x = if menu.position.0 + menu_width > screen_width {
3506 screen_width.saturating_sub(menu_width)
3507 } else {
3508 menu.position.0
3509 };
3510
3511 let menu_y = if menu.position.1 + menu_height > screen_height {
3512 screen_height.saturating_sub(menu_height)
3513 } else {
3514 menu.position.1
3515 };
3516
3517 let area = ratatui::layout::Rect::new(menu_x, menu_y, menu_width, menu_height);
3518
3519 // Clear the area first
3520 frame.render_widget(Clear, area);
3521
3522 // Build the menu lines
3523 let mut lines = Vec::new();
3524 for (idx, item) in items.iter().enumerate() {
3525 let is_highlighted = idx == menu.highlighted;
3526
3527 let style = if is_highlighted {
3528 Style::default()
3529 .fg(self.theme.read().unwrap().menu_highlight_fg)
3530 .bg(self.theme.read().unwrap().menu_highlight_bg)
3531 } else {
3532 Style::default()
3533 .fg(self.theme.read().unwrap().menu_dropdown_fg)
3534 .bg(self.theme.read().unwrap().menu_dropdown_bg)
3535 };
3536
3537 // Pad the label to fill the menu width
3538 let label = item.label();
3539 let content_width = (menu_width as usize).saturating_sub(2); // -2 for borders
3540 let padded_label = format!(" {:<width$}", label, width = content_width - 1);
3541
3542 lines.push(Line::from(vec![Span::styled(padded_label, style)]));
3543 }
3544
3545 let block = Block::default()
3546 .borders(Borders::ALL)
3547 .border_style(Style::default().fg(self.theme.read().unwrap().menu_border_fg))
3548 .style(Style::default().bg(self.theme.read().unwrap().menu_dropdown_bg));
3549
3550 let paragraph = Paragraph::new(lines).block(block);
3551 frame.render_widget(paragraph, area);
3552 }
3553
3554 /// Render the "+" new-tab popup menu (New Terminal / New File).
3555 fn render_new_tab_menu(&self, frame: &mut Frame, menu: &super::types::NewTabMenu) {
3556 use ratatui::style::Style;
3557 use ratatui::text::{Line, Span};
3558 use ratatui::widgets::{Block, Borders, Clear, Paragraph};
3559
3560 let items = super::types::NewTabMenuItem::all();
3561 let menu_width = super::types::NEW_TAB_MENU_WIDTH;
3562 let menu_height = items.len() as u16 + 2; // items + borders
3563
3564 let screen_width = frame.area().width;
3565 let screen_height = frame.area().height;
3566
3567 let menu_x = if menu.position.0 + menu_width > screen_width {
3568 screen_width.saturating_sub(menu_width)
3569 } else {
3570 menu.position.0
3571 };
3572 let menu_y = if menu.position.1 + menu_height > screen_height {
3573 screen_height.saturating_sub(menu_height)
3574 } else {
3575 menu.position.1
3576 };
3577
3578 let area = ratatui::layout::Rect::new(menu_x, menu_y, menu_width, menu_height);
3579
3580 frame.render_widget(Clear, area);
3581
3582 let mut lines = Vec::new();
3583 for (idx, item) in items.iter().enumerate() {
3584 let is_highlighted = idx == menu.highlighted;
3585
3586 let style = if is_highlighted {
3587 Style::default()
3588 .fg(self.theme.read().unwrap().menu_highlight_fg)
3589 .bg(self.theme.read().unwrap().menu_highlight_bg)
3590 } else {
3591 Style::default()
3592 .fg(self.theme.read().unwrap().menu_dropdown_fg)
3593 .bg(self.theme.read().unwrap().menu_dropdown_bg)
3594 };
3595
3596 let label = item.label();
3597 let content_width = (menu_width as usize).saturating_sub(2);
3598 let padded_label = format!(" {:<width$}", label, width = content_width - 1);
3599
3600 lines.push(Line::from(vec![Span::styled(padded_label, style)]));
3601 }
3602
3603 let block = Block::default()
3604 .borders(Borders::ALL)
3605 .border_style(Style::default().fg(self.theme.read().unwrap().menu_border_fg))
3606 .style(Style::default().bg(self.theme.read().unwrap().menu_dropdown_bg));
3607
3608 let paragraph = Paragraph::new(lines).block(block);
3609 frame.render_widget(paragraph, area);
3610 }
3611
3612 /// Render the file explorer context menu
3613 fn render_file_explorer_context_menu(
3614 &self,
3615 frame: &mut Frame,
3616 menu: &super::types::FileExplorerContextMenu,
3617 ) {
3618 use ratatui::style::Style;
3619 use ratatui::text::{Line, Span};
3620 use ratatui::widgets::{Block, Borders, Clear, Paragraph};
3621
3622 let items = menu.items();
3623 let menu_width = super::types::FILE_EXPLORER_CONTEXT_MENU_WIDTH;
3624 let menu_height = menu.height();
3625 let (menu_x, menu_y) = menu.clamped_position(frame.area().width, frame.area().height);
3626
3627 let area = ratatui::layout::Rect::new(menu_x, menu_y, menu_width, menu_height);
3628
3629 frame.render_widget(Clear, area);
3630
3631 let mut lines = Vec::new();
3632 for (idx, item) in items.iter().enumerate() {
3633 let is_highlighted = idx == menu.highlighted;
3634
3635 let style = if is_highlighted {
3636 Style::default()
3637 .fg(self.theme.read().unwrap().menu_highlight_fg)
3638 .bg(self.theme.read().unwrap().menu_highlight_bg)
3639 } else {
3640 Style::default()
3641 .fg(self.theme.read().unwrap().menu_dropdown_fg)
3642 .bg(self.theme.read().unwrap().menu_dropdown_bg)
3643 };
3644
3645 let label = item.label();
3646 let content_width = (menu_width as usize).saturating_sub(2);
3647 let padded_label = format!(" {:<width$}", label, width = content_width - 1);
3648
3649 lines.push(Line::from(vec![Span::styled(padded_label, style)]));
3650 }
3651
3652 let block = Block::default()
3653 .borders(Borders::ALL)
3654 .border_style(Style::default().fg(self.theme.read().unwrap().menu_border_fg))
3655 .style(Style::default().bg(self.theme.read().unwrap().menu_dropdown_bg));
3656
3657 let paragraph = Paragraph::new(lines).block(block);
3658 frame.render_widget(paragraph, area);
3659 }
3660
3661 /// Render the tab drag drop zone overlay
3662 fn render_tab_drop_zone(&self, frame: &mut Frame, drag_state: &super::types::TabDragState) {
3663 use ratatui::style::Modifier;
3664
3665 let Some(ref drop_zone) = drag_state.drop_zone else {
3666 return;
3667 };
3668
3669 let split_id = drop_zone.split_id();
3670
3671 // Find the content area for the target split
3672 let split_area = self
3673 .active_layout()
3674 .split_areas
3675 .iter()
3676 .find(|(sid, _, _, _, _, _)| *sid == split_id)
3677 .map(|(_, _, content_rect, _, _, _)| *content_rect);
3678
3679 let Some(content_rect) = split_area else {
3680 return;
3681 };
3682
3683 // Determine the highlight area based on drop zone type
3684 use super::types::TabDropZone;
3685
3686 let highlight_area = match drop_zone {
3687 TabDropZone::TabBar(_, _) | TabDropZone::SplitCenter(_) => {
3688 // For tab bar and center drops, highlight the entire split area
3689 // This indicates the tab will be added to this split's tab bar
3690 content_rect
3691 }
3692 TabDropZone::SplitLeft(_) => {
3693 // Left 50% of the split (matches the actual split size created)
3694 let width = (content_rect.width / 2).max(3);
3695 ratatui::layout::Rect::new(
3696 content_rect.x,
3697 content_rect.y,
3698 width,
3699 content_rect.height,
3700 )
3701 }
3702 TabDropZone::SplitRight(_) => {
3703 // Right 50% of the split (matches the actual split size created)
3704 let width = (content_rect.width / 2).max(3);
3705 let x = content_rect.x + content_rect.width - width;
3706 ratatui::layout::Rect::new(x, content_rect.y, width, content_rect.height)
3707 }
3708 TabDropZone::SplitTop(_) => {
3709 // Top 50% of the split (matches the actual split size created)
3710 let height = (content_rect.height / 2).max(2);
3711 ratatui::layout::Rect::new(
3712 content_rect.x,
3713 content_rect.y,
3714 content_rect.width,
3715 height,
3716 )
3717 }
3718 TabDropZone::SplitBottom(_) => {
3719 // Bottom 50% of the split (matches the actual split size created)
3720 let height = (content_rect.height / 2).max(2);
3721 let y = content_rect.y + content_rect.height - height;
3722 ratatui::layout::Rect::new(content_rect.x, y, content_rect.width, height)
3723 }
3724 };
3725
3726 // Draw the overlay with the drop zone color
3727 // We apply a semi-transparent effect by modifying existing cells
3728 let buf = frame.buffer_mut();
3729 let drop_zone_bg = self.theme.read().unwrap().tab_drop_zone_bg;
3730 let drop_zone_border = self.theme.read().unwrap().tab_drop_zone_border;
3731
3732 // Fill the highlight area with a semi-transparent overlay
3733 for y in highlight_area.y..highlight_area.y + highlight_area.height {
3734 for x in highlight_area.x..highlight_area.x + highlight_area.width {
3735 if let Some(cell) = buf.cell_mut((x, y)) {
3736 // Blend the drop zone color with the existing background
3737 // For a simple effect, we just set the background
3738 cell.set_bg(drop_zone_bg);
3739
3740 // Draw border on edges
3741 let is_border = x == highlight_area.x
3742 || x == highlight_area.x + highlight_area.width - 1
3743 || y == highlight_area.y
3744 || y == highlight_area.y + highlight_area.height - 1;
3745
3746 if is_border {
3747 cell.set_fg(drop_zone_border);
3748 cell.set_style(cell.style().add_modifier(Modifier::BOLD));
3749 }
3750 }
3751 }
3752 }
3753
3754 // Draw a border indicator based on the zone type
3755 match drop_zone {
3756 TabDropZone::SplitLeft(_) => {
3757 // Draw vertical indicator on left edge
3758 for y in highlight_area.y..highlight_area.y + highlight_area.height {
3759 if let Some(cell) = buf.cell_mut((highlight_area.x, y)) {
3760 cell.set_symbol("▌");
3761 cell.set_fg(drop_zone_border);
3762 }
3763 }
3764 }
3765 TabDropZone::SplitRight(_) => {
3766 // Draw vertical indicator on right edge
3767 let x = highlight_area.x + highlight_area.width - 1;
3768 for y in highlight_area.y..highlight_area.y + highlight_area.height {
3769 if let Some(cell) = buf.cell_mut((x, y)) {
3770 cell.set_symbol("▐");
3771 cell.set_fg(drop_zone_border);
3772 }
3773 }
3774 }
3775 TabDropZone::SplitTop(_) => {
3776 // Draw horizontal indicator on top edge
3777 for x in highlight_area.x..highlight_area.x + highlight_area.width {
3778 if let Some(cell) = buf.cell_mut((x, highlight_area.y)) {
3779 cell.set_symbol("▀");
3780 cell.set_fg(drop_zone_border);
3781 }
3782 }
3783 }
3784 TabDropZone::SplitBottom(_) => {
3785 // Draw horizontal indicator on bottom edge
3786 let y = highlight_area.y + highlight_area.height - 1;
3787 for x in highlight_area.x..highlight_area.x + highlight_area.width {
3788 if let Some(cell) = buf.cell_mut((x, y)) {
3789 cell.set_symbol("▄");
3790 cell.set_fg(drop_zone_border);
3791 }
3792 }
3793 }
3794 TabDropZone::SplitCenter(_) | TabDropZone::TabBar(_, _) => {
3795 // For center and tab bar, the filled background is sufficient
3796 }
3797 }
3798 }
3799
3800 /// Recompute the view_line_mappings layout without drawing.
3801 /// Used during macro replay so that visual-line movements (MoveLineEnd,
3802 /// MoveUp, MoveDown on wrapped lines) see correct, up-to-date layout
3803 /// information between each replayed action.
3804 pub fn recompute_layout(&mut self, width: u16, height: u16) {
3805 let size = ratatui::layout::Rect::new(0, 0, width, height);
3806
3807 // Replicate the pre-render sync steps from render()
3808 let active_split = self
3809 .windows
3810 .get(&self.active_window)
3811 .and_then(|w| w.buffers.splits())
3812 .map(|(mgr, _)| mgr)
3813 .expect("active window must have a populated split layout")
3814 .active_split();
3815 self.active_window_mut()
3816 .pre_sync_ensure_visible(active_split);
3817 self.active_window_mut().sync_scroll_groups();
3818
3819 // Replicate the layout computation that produces editor_content_area.
3820 // Same constraints as render(): [menu_bar, main_content, status_bar, search_options, prompt_line]
3821 let constraints = vec![
3822 Constraint::Length(if self.active_window_mut().menu_bar_visible {
3823 1
3824 } else {
3825 0
3826 }),
3827 Constraint::Min(0),
3828 Constraint::Length(if self.active_window_mut().status_bar_visible {
3829 1
3830 } else {
3831 0
3832 }), // status bar
3833 Constraint::Length(0), // search options (doesn't matter for layout)
3834 Constraint::Length(if self.active_window_mut().prompt_line_visible {
3835 1
3836 } else {
3837 0
3838 }), // prompt line
3839 ];
3840 let main_chunks = Layout::default()
3841 .direction(Direction::Vertical)
3842 .constraints(constraints)
3843 .split(size);
3844 let main_content_area = main_chunks[1];
3845
3846 // Compute editor_content_area (with file explorer split if visible)
3847 let file_explorer_should_show = self.file_explorer_visible()
3848 && (self.file_explorer().is_some()
3849 || self.active_window().file_explorer_sync_in_progress);
3850 let editor_content_area = if file_explorer_should_show {
3851 let explorer_cols = self
3852 .active_window()
3853 .file_explorer_width
3854 .to_cols(main_content_area.width);
3855 let horizontal_chunks = Layout::default()
3856 .direction(Direction::Horizontal)
3857 .constraints([Constraint::Length(explorer_cols), Constraint::Min(0)])
3858 .split(main_content_area);
3859 horizontal_chunks[1]
3860 } else {
3861 main_content_area
3862 };
3863
3864 // Compute layout for all visible splits and update cached view_line_mappings.
3865 // Take one &mut borrow on the active window's splits; destructure into
3866 // (&SplitManager, &mut HashMap<...>) so both arguments come from the
3867 // same `&mut self.windows` borrow.
3868 let active_window_id = self.active_window;
3869 let __win_l = self
3870 .windows
3871 .get_mut(&active_window_id)
3872 .expect("active window must exist");
3873 let tab_bar_visible = __win_l.tab_bar_visible;
3874 let theme = self.theme.read().unwrap().clone();
3875 let view_line_mappings = __win_l
3876 .buffers
3877 .with_all_mut(|buffers, mgr, vs_map| {
3878 SplitRenderer::compute_content_layout(
3879 editor_content_area,
3880 &*mgr,
3881 buffers,
3882 vs_map,
3883 &theme,
3884 false, // lsp_waiting — not relevant for layout
3885 self.config.editor.estimated_line_length,
3886 self.config.editor.highlight_context_bytes,
3887 self.config.editor.relative_line_numbers,
3888 self.config.editor.use_terminal_bg,
3889 self.session_mode || !self.software_cursor_only,
3890 self.software_cursor_only,
3891 tab_bar_visible,
3892 self.config.editor.show_vertical_scrollbar,
3893 self.config.editor.show_horizontal_scrollbar,
3894 self.config.editor.diagnostics_inline_text,
3895 self.config.editor.show_tilde,
3896 )
3897 })
3898 .expect("active window must have a populated split layout");
3899
3900 self.active_layout_mut().view_line_mappings = view_line_mappings;
3901 }
3902
3903 /// Clear the search history
3904 /// Used primarily for testing to ensure test isolation
3905 pub fn clear_search_history(&mut self) {
3906 if let Some(history) = self.active_window_mut().prompt_histories.get_mut("search") {
3907 history.clear();
3908 }
3909 }
3910
3911 /// Emit an OSC 2 escape sequence to set the host terminal's window/tab
3912 /// title based on the active buffer's display name and the project name
3913 /// (the working directory's last path component). Deduplicated against
3914 /// the last title we wrote so we don't spam stdout every frame.
3915 ///
3916 /// Gated by `editor.set_window_title` (default on). Terminals that
3917 /// don't implement OSC 2 silently drop the sequence.
3918 fn update_terminal_title(&mut self, display_name: &str) {
3919 if !self.config.editor.set_window_title {
3920 return;
3921 }
3922 let project_name = self.working_dir().file_name().and_then(|s| s.to_str());
3923 let new_title =
3924 crate::services::terminal_title::build_window_title(display_name, project_name);
3925 if self.last_window_title.as_deref() == Some(new_title.as_str()) {
3926 return;
3927 }
3928 crate::services::terminal_title::write_terminal_title(&new_title);
3929 self.last_window_title = Some(new_title);
3930 }
3931
3932 /// Save all prompt histories to disk
3933 /// Called on shutdown to persist history across sessions
3934 pub fn save_histories(&self) {
3935 // Ensure data directory exists
3936 if let Err(e) = self
3937 .authority()
3938 .filesystem
3939 .create_dir_all(&self.dir_context.data_dir)
3940 {
3941 tracing::warn!("Failed to create data directory: {}", e);
3942 return;
3943 }
3944
3945 // Save all prompt histories
3946 for (key, history) in &self.active_window().prompt_histories {
3947 let path = self.dir_context.prompt_history_path(key);
3948 if let Err(e) = history.save_to_file(&path) {
3949 tracing::warn!("Failed to save {} history: {}", key, e);
3950 } else {
3951 tracing::debug!("Saved {} history to {:?}", key, path);
3952 }
3953 }
3954 }
3955
3956 /// Resolve a plugin-supplied [`OverlayOptions`] to a ratatui
3957 /// [`Style`] against the active theme. RGB colours pass through;
3958 /// theme keys (e.g. `"ui.help_key_fg"`) are looked up via
3959 /// `theme.resolve_theme_key`. Mirrors the resolution
3960 /// `OverlayFace::from_options` + char_style.rs do for buffer
3961 /// overlays — pulled here so the prompt-frame renderer can build
3962 /// styled spans inline.
3963 /// Compute a centered overlay rect of `width_pct` × `height_pct`
3964 /// of the given area. Mirrors `PopupPosition::CenteredOverlay`
3965 /// math used by `render_overlay_prompt`; minimum 20×8 cells so
3966 /// content stays legible on tiny terminals.
3967 pub(super) fn centered_overlay_rect(
3968 area: ratatui::layout::Rect,
3969 width_pct: u8,
3970 height_pct: u8,
3971 ) -> ratatui::layout::Rect {
3972 let w_pct = width_pct.clamp(1, 100) as u32;
3973 let h_pct = height_pct.clamp(1, 100) as u32;
3974 let w = ((area.width as u32 * w_pct) / 100) as u16;
3975 let h = ((area.height as u32 * h_pct) / 100) as u16;
3976 let w = w.max(20).min(area.width);
3977 let h = h.max(8).min(area.height);
3978 ratatui::layout::Rect {
3979 x: area.x + (area.width.saturating_sub(w)) / 2,
3980 y: area.y + (area.height.saturating_sub(h)) / 2,
3981 width: w,
3982 height: h,
3983 }
3984 }
3985
3986 /// Render the currently-mounted floating widget panel: dim the
3987 /// background outside the centered rect, draw the frame, paint
3988 /// the panel's rendered entries inside, and place the hardware
3989 /// caret at the focused TextInput. Stores the inner rect on the
3990 /// `FloatingWidgetState` so the click hit-test can recover the
3991 /// geometry on the next mouse event.
3992 /// Split `size` into an optional full-height left dock column and
3993 /// the remaining chrome area. Returns `(None, size)` unless a
3994 /// floating panel is currently placed as a `LeftDock`. The dock
3995 /// width is clamped so it can never crowd out the chrome.
3996 pub(super) fn compute_dock_split(
3997 &self,
3998 size: ratatui::layout::Rect,
3999 ) -> (Option<ratatui::layout::Rect>, ratatui::layout::Rect) {
4000 // The editor is the priority. Reserve at least EDITOR_MIN columns
4001 // for the buffer, and once the terminal is too narrow to fit a
4002 // worthwhile dock alongside that editor, hide the dock entirely
4003 // (it reappears when the terminal grows). Previously the dock kept
4004 // ALL but 4 columns, squishing the editor to a useless sliver on a
4005 // narrow terminal.
4006 const EDITOR_MIN: u16 = 20;
4007 const DOCK_MIN: u16 = 24;
4008 let requested = match self.dock.as_ref().map(|f| f.placement) {
4009 Some(super::PanelPlacement::LeftDock { width_cols }) => width_cols,
4010 _ => return (None, size),
4011 };
4012 // Widest the dock may be while leaving the editor its minimum.
4013 let max_dock = size.width.saturating_sub(EDITOR_MIN);
4014 if max_dock < DOCK_MIN {
4015 // Not enough room for a usable dock + editor — give the editor
4016 // the whole frame this render.
4017 return (None, size);
4018 }
4019 // Honor the requested (drag-set) width, but never crowd the editor
4020 // below EDITOR_MIN. In the shrink band the dock narrows from its
4021 // requested width down to DOCK_MIN before it hides.
4022 let width = requested.min(max_dock).max(1);
4023 let dock = ratatui::layout::Rect {
4024 x: size.x,
4025 y: size.y,
4026 width,
4027 height: size.height,
4028 };
4029 let chrome = ratatui::layout::Rect {
4030 x: size.x.saturating_add(width),
4031 y: size.y,
4032 width: size.width.saturating_sub(width),
4033 height: size.height,
4034 };
4035 (Some(dock), chrome)
4036 }
4037
4038 pub(super) fn render_floating_widget_panel(
4039 &mut self,
4040 frame: &mut Frame,
4041 area: ratatui::layout::Rect,
4042 slot: super::PanelSlot,
4043 ) {
4044 use ratatui::widgets::{Block, Borders, Clear};
4045
4046 let (
4047 width_pct,
4048 height_pct,
4049 entries,
4050 focus_cursor,
4051 embeds,
4052 overlays,
4053 scroll_regions,
4054 placement,
4055 panel_focused,
4056 scrollbar_zone_hovered,
4057 ) = match self.panel(slot) {
4058 Some(fwp) => (
4059 fwp.width_pct,
4060 fwp.height_pct,
4061 fwp.entries.clone(),
4062 fwp.focus_cursor,
4063 fwp.embeds.clone(),
4064 fwp.overlays.clone(),
4065 fwp.scroll_regions.clone(),
4066 fwp.placement,
4067 fwp.focused,
4068 fwp.scrollbar_zone_hovered,
4069 ),
4070 None => return,
4071 };
4072 let theme = self.theme.read().unwrap().clone();
4073 // Compute the requested rect from width%/height%, then
4074 // shrink the height to fit the rendered content (Bug 7).
4075 // Plugins call `mount({widthPct, heightPct})` mostly because
4076 // they don't know how tall their content is up front; the
4077 // requested height should act as a *max*, not a fixed
4078 // canvas. Without this shrink, the new-session form's 10
4079 // content rows leave ~20 blank rows under "Tab next S-Tab
4080 // prev Enter submit Esc cancel" inside a 90%-of-screen
4081 // panel.
4082 //
4083 // Entries include every row the spec produces — including
4084 // WindowEmbed reservations (each `windowEmbed({rows: N})`
4085 // contributes N blank entries plus an EmbedRect that paints
4086 // over them at draw time). So `entries.len() + 2` (top
4087 // border + content + bottom border) is the natural fit.
4088 // A left-dock panel fills its carved column (`area` is already
4089 // the dock rect) at full height and does NOT dim the chrome —
4090 // it's a persistent, non-modal companion to the editor, not a
4091 // modal overlay. The centered placement keeps the historical
4092 // fit-to-content + background-dim behaviour.
4093 let is_dock = matches!(placement, super::PanelPlacement::LeftDock { .. });
4094 let overlay_rect = match placement {
4095 super::PanelPlacement::LeftDock { .. } => area,
4096 super::PanelPlacement::Anchored { x, y } => {
4097 // Size to the rendered content (not a percentage): an
4098 // unobtrusive popup hugs its items. Width = widest entry +
4099 // borders; height = entry count + borders. Then clamp the
4100 // top-left so the whole box stays on screen.
4101 use crate::primitives::display_width::str_width;
4102 let content_w = entries
4103 .iter()
4104 .map(|e| str_width(&e.text) as u16)
4105 .max()
4106 .unwrap_or(0);
4107 let w = content_w.saturating_add(2).clamp(6, area.width);
4108 let needed_h = (entries.len() as u16).saturating_add(2);
4109 let h = needed_h.clamp(3, area.height);
4110 let max_x = area.x + area.width.saturating_sub(w);
4111 let max_y = area.y + area.height.saturating_sub(h);
4112 ratatui::layout::Rect {
4113 x: x.clamp(area.x, max_x),
4114 y: y.clamp(area.y, max_y),
4115 width: w,
4116 height: h,
4117 }
4118 }
4119 super::PanelPlacement::Centered => {
4120 let requested = Self::centered_overlay_rect(area, width_pct, height_pct);
4121 let needed_h = (entries.len() as u16).saturating_add(2);
4122 let effective_h = needed_h.min(requested.height).max(3);
4123 ratatui::layout::Rect {
4124 x: requested.x,
4125 y: area.y + (area.height.saturating_sub(effective_h)) / 2,
4126 width: requested.width,
4127 height: effective_h,
4128 }
4129 }
4130 };
4131
4132 // Only the centered modal dims the background; the dock and the
4133 // anchored context-menu popup paint over the editor without it.
4134 if matches!(placement, super::PanelPlacement::Centered) {
4135 crate::view::dimming::apply_dimming_excluding(frame, area, Some(overlay_rect));
4136 }
4137 frame.render_widget(Clear, overlay_rect);
4138 // The dock draws ONLY a right border (a thin draggable divider) —
4139 // no top/left/bottom — so it reclaims those rows/cols for content
4140 // and reads as a panel attached to the left edge. The centered
4141 // modal keeps a full box.
4142 //
4143 // A focused dock lights its divider with the accent `theme.cursor`
4144 // (the same colour the file explorer uses for its focused border),
4145 // so exactly one chrome region wears the accent at a time. A blurred
4146 // dock falls back to the muted `popup_border_fg`, matching every
4147 // other unfocused panel and making "who has the keyboard" obvious.
4148 let dock_border_fg = if is_dock && panel_focused {
4149 theme.cursor
4150 } else {
4151 theme.popup_border_fg
4152 };
4153 let block = Block::default()
4154 .borders(if is_dock {
4155 Borders::RIGHT
4156 } else {
4157 Borders::ALL
4158 })
4159 .border_style(ratatui::style::Style::default().fg(dock_border_fg))
4160 .style(ratatui::style::Style::default().bg(theme.suggestion_bg));
4161 let inner = block.inner(overlay_rect);
4162 frame.render_widget(block, overlay_rect);
4163
4164 if inner.width == 0 || inner.height == 0 {
4165 if let Some(fwp) = self.panel_mut(slot) {
4166 fwp.last_inner_rect = Some(inner);
4167 }
4168 return;
4169 }
4170
4171 let max_rows = inner.height as usize;
4172 for (i, entry) in entries.iter().take(max_rows).enumerate() {
4173 paint_text_property_entry(
4174 frame,
4175 entry,
4176 inner.x,
4177 inner.y + i as u16,
4178 inner.width,
4179 &theme,
4180 );
4181 }
4182
4183 // Walk WindowEmbed widgets and paint their referenced
4184 // editor window into the cells they reserved. Each embed
4185 // rect is panel-relative; translate to screen cells via
4186 // `inner`. We temporarily borrow `preview_window_id` to
4187 // reuse the existing per-window paint path — it reads
4188 // that field to decide which session to draw.
4189 let saved_preview = self.preview_window_id;
4190 for emb in &embeds {
4191 if emb.window_id == 0 {
4192 continue;
4193 }
4194 let ex = inner.x.saturating_add(emb.col_in_row as u16);
4195 let ey = inner.y.saturating_add(emb.buffer_row as u16);
4196 // Clip the embed rect to the panel's inner area so a
4197 // partially-offscreen embed (tiny terminal) doesn't
4198 // paint into the frame border.
4199 let max_w = inner.x.saturating_add(inner.width).saturating_sub(ex);
4200 let max_h = inner.y.saturating_add(inner.height).saturating_sub(ey);
4201 let w = (emb.width_cols as u16).min(max_w);
4202 let h = (emb.height_rows as u16).min(max_h);
4203 if w == 0 || h == 0 {
4204 continue;
4205 }
4206 let rect = ratatui::layout::Rect {
4207 x: ex,
4208 y: ey,
4209 width: w,
4210 height: h,
4211 };
4212 self.preview_window_id = Some(fresh_core::WindowId(emb.window_id as u64));
4213 self.render_session_preview_into_rect(frame, rect, &theme);
4214 }
4215 self.preview_window_id = saved_preview;
4216
4217 // Dock "seamless tab (missing wall)": erase the right-edge divider
4218 // across the active session card's rows and scoop it away with
4219 // rounded corners just above and below, so the active card reads as
4220 // merging into the editor to its right (a file-folder / browser
4221 // tab). Painted over the wall the block drew and the card entries —
4222 // but BEFORE the scrollbar below, so a visible scrollbar paints on
4223 // top of (rather than being erased by) the tab's border cells.
4224 // No-op for non-dock panels and for an empty dock.
4225 if is_dock {
4226 paint_dock_seamless_active_tab(
4227 frame,
4228 overlay_rect,
4229 inner,
4230 &entries,
4231 max_rows,
4232 dock_border_fg,
4233 theme.suggestion_bg,
4234 );
4235 }
4236
4237 // Paint a draggable scrollbar over the rightmost column of each
4238 // overflowing list, reusing the canonical `render_scrollbar` /
4239 // `ScrollbarState` (same path as the keybinding editor &
4240 // settings dialog). Record each track's screen rect + state so
4241 // the mouse handlers can hit-test press/drag against it.
4242 let mut scrollbar_tracks: Vec<super::WidgetScrollbarTrack> = Vec::new();
4243 // The dock's list scrollbars are overlay-style: shown ONLY while the
4244 // pointer is over the list, and hidden otherwise — even when the list
4245 // holds keyboard focus. Every other panel keeps its scrollbar always
4246 // visible.
4247 //
4248 // Hover is read from the panel-global `scrollbar_zone_hovered` memo
4249 // (maintained by the mouse-move handler), NOT from a per-window
4250 // cursor position: the latter is stored per editor window, so paging
4251 // through sessions with next/prev-window would swap in each window's
4252 // stale cursor and flicker the bar on for some sessions and off for
4253 // others even though the pointer never moved.
4254 let dock_overlay_scrollbar = is_dock;
4255 let mut scrollbar_hover_zones: Vec<ratatui::layout::Rect> = Vec::new();
4256 {
4257 use crate::view::ui::scrollbar::{render_scrollbar, ScrollbarColors, ScrollbarState};
4258 let colors = ScrollbarColors::from_theme(&theme);
4259 for region in &scroll_regions {
4260 // Scrollbar column = right edge of the list's column,
4261 // clamped inside the panel. Height = visible rows,
4262 // clamped to the panel bottom.
4263 let mut sb_x = inner
4264 .x
4265 .saturating_add(region.col_in_row as u16)
4266 .saturating_add((region.width_cols.saturating_sub(1)) as u16)
4267 .min(inner.x + inner.width.saturating_sub(1));
4268 // The dock reserves an editor-side gutter between the list and
4269 // its divider; nudge its scrollbar one column right into that
4270 // gutter so it hugs the divider/edge instead of floating a
4271 // column inboard. Still clamped inside the panel.
4272 if dock_overlay_scrollbar {
4273 sb_x = sb_x
4274 .saturating_add(1)
4275 .min(inner.x + inner.width.saturating_sub(1));
4276 }
4277 let sb_y = inner.y.saturating_add(region.buffer_row as u16);
4278 if sb_y >= inner.y + inner.height {
4279 continue;
4280 }
4281 let max_h = inner.y + inner.height - sb_y;
4282 let sb_h = (region.height_rows as u16).min(max_h);
4283 if sb_h == 0 {
4284 continue;
4285 }
4286 let sb_rect = ratatui::layout::Rect {
4287 x: sb_x,
4288 y: sb_y,
4289 width: 1,
4290 height: sb_h,
4291 };
4292 // Hover zone = the list's whole visible region; hovering it
4293 // anywhere reveals the bar. Recorded every draw so the
4294 // mouse-move handler can re-render on enter/leave.
4295 let zone = ratatui::layout::Rect {
4296 x: inner.x,
4297 y: sb_y,
4298 width: inner.width,
4299 height: sb_h,
4300 };
4301 scrollbar_hover_zones.push(zone);
4302 let show = !dock_overlay_scrollbar || scrollbar_zone_hovered;
4303 if !show {
4304 // Hidden: skip painting and recording a draggable track —
4305 // an invisible bar shouldn't be grabbable. (The pointer
4306 // can't be on the track without being in the zone, so a
4307 // visible bar is always available before a press lands.)
4308 continue;
4309 }
4310 let state = ScrollbarState::new(region.total, region.visible, region.scroll);
4311 render_scrollbar(frame, sb_rect, &state, &colors);
4312 scrollbar_tracks.push(super::WidgetScrollbarTrack {
4313 list_key: region.list_key.clone(),
4314 rect: sb_rect,
4315 total: region.total,
4316 visible: region.visible,
4317 scroll: region.scroll,
4318 });
4319 }
4320 }
4321
4322 // Paint overlay rows AFTER the main entries + embeds. Each
4323 // overlay row sits on top of whatever's at its
4324 // `buffer_row` (the row it would have occupied if it
4325 // weren't floating). Used for dropdown completions
4326 // anchored to a text input — the completion list rows
4327 // overpaint the form's static rows beneath without
4328 // shifting them on every show / hide.
4329 //
4330 // Clear the row first so the underlying entry's text
4331 // doesn't bleed past the overlay's content width.
4332 // `Paragraph` only paints cells it has content for; a
4333 // bare `Clear` resets the row to the panel background
4334 // (the `Block` here just supplies the bg style — no
4335 // borders).
4336 let panel_bg = theme.popup_bg;
4337 let panel_bg_style = ratatui::style::Style::default().bg(panel_bg);
4338 for o in &overlays {
4339 let row_y = inner.y.saturating_add(o.buffer_row as u16);
4340 if row_y >= inner.y.saturating_add(inner.height) {
4341 continue;
4342 }
4343 let row_rect = ratatui::layout::Rect {
4344 x: inner.x,
4345 y: row_y,
4346 width: inner.width,
4347 height: 1,
4348 };
4349 frame.render_widget(Clear, row_rect);
4350 frame.render_widget(Block::default().style(panel_bg_style), row_rect);
4351 paint_text_property_entry(frame, &o.entry, inner.x, row_y, inner.width, &theme);
4352 }
4353
4354 if let Some(fc) = focus_cursor {
4355 let cx = inner.x.saturating_add(byte_to_screen_col(
4356 entries
4357 .get(fc.buffer_row as usize)
4358 .map(|e| e.text.as_str())
4359 .unwrap_or(""),
4360 fc.byte_in_row as usize,
4361 ) as u16);
4362 let cy = inner.y.saturating_add(fc.buffer_row as u16);
4363 if cx < inner.x + inner.width && cy < inner.y + inner.height {
4364 frame.set_cursor_position((cx, cy));
4365 }
4366 } else if panel_focused {
4367 // No focused text input, and the panel owns the keyboard —
4368 // the underlying editor's `set_cursor_position` (called
4369 // earlier this frame) would otherwise leave a hardware
4370 // caret blinking inside the dimmed buffer behind the panel.
4371 // Park it on the panel's bottom-right corner so it hides
4372 // under the panel chrome. A *blurred* dock skips this: the
4373 // editor beside it is focused and must keep its caret.
4374 let cx = inner.x + inner.width.saturating_sub(1);
4375 let cy = inner.y + inner.height.saturating_sub(1);
4376 frame.set_cursor_position((cx, cy));
4377 }
4378
4379 if let Some(fwp) = self.panel_mut(slot) {
4380 fwp.last_inner_rect = Some(inner);
4381 fwp.scrollbar_tracks = scrollbar_tracks;
4382 fwp.scrollbar_hover_zones = scrollbar_hover_zones;
4383 }
4384 }
4385
4386 fn resolve_overlay_style(
4387 opts: &fresh_core::api::OverlayOptions,
4388 theme: &crate::view::theme::Theme,
4389 ) -> ratatui::style::Style {
4390 use crate::view::theme::named_color_from_str;
4391 use fresh_core::api::OverlayColorSpec;
4392 use ratatui::style::{Color, Modifier, Style};
4393
4394 let resolve = |spec: &OverlayColorSpec| -> Option<Color> {
4395 match spec {
4396 OverlayColorSpec::Rgb(r, g, b) => Some(Color::Rgb(*r, *g, *b)),
4397 OverlayColorSpec::ThemeKey(k) => {
4398 named_color_from_str(k).or_else(|| theme.resolve_theme_key(k))
4399 }
4400 }
4401 };
4402
4403 let mut style = Style::default();
4404 if let Some(ref fg) = opts.fg {
4405 if let Some(c) = resolve(fg) {
4406 style = style.fg(c);
4407 }
4408 }
4409 if let Some(ref bg) = opts.bg {
4410 if let Some(c) = resolve(bg) {
4411 style = style.bg(c);
4412 }
4413 }
4414 let mut m = Modifier::empty();
4415 if opts.bold {
4416 m |= Modifier::BOLD;
4417 }
4418 if opts.italic {
4419 m |= Modifier::ITALIC;
4420 }
4421 if opts.underline {
4422 m |= Modifier::UNDERLINED;
4423 }
4424 if opts.strikethrough {
4425 m |= Modifier::CROSSED_OUT;
4426 }
4427 if !m.is_empty() {
4428 style = style.add_modifier(m);
4429 }
4430 style
4431 }
4432}
4433
4434/// Paint the dock's "seamless tab (missing wall)" treatment for the
4435/// active session card.
4436///
4437/// The dock normally draws a full-height right-edge divider (the
4438/// "wall") separating its column from the editor. For the active
4439/// session — the one mirrored in the main view — we erase the wall
4440/// across the card's rows and scoop the divider away with rounded
4441/// corners just above and below it, so the card visually merges into
4442/// the editor to its right:
4443///
4444/// ```text
4445/// │ <- wall (untouched) above
4446/// ╭──────────────────╯ <- top edge scoops up into the wall
4447/// │ session (active) <- right side open: flows into the editor
4448/// ╰──────────────────╮ <- bottom edge scoops down into the wall
4449/// │ <- wall resumes below
4450/// ```
4451///
4452/// The active card is located by the heavy box glyphs that
4453/// `mark_selected_card` stamps onto exactly one card's rows; its first
4454/// and last such rows bound the band. No-ops when no card is selected
4455/// (e.g. an empty dock) so the plain wall stands.
4456fn paint_dock_seamless_active_tab(
4457 frame: &mut ratatui::Frame,
4458 overlay_rect: ratatui::layout::Rect,
4459 inner: ratatui::layout::Rect,
4460 entries: &[fresh_core::text_property::TextPropertyEntry],
4461 max_rows: usize,
4462 border_fg: ratatui::style::Color,
4463 bg: ratatui::style::Color,
4464) {
4465 // Rows of the (single) selected card carry the heavy box glyphs that
4466 // `mark_selected_card` stamps — the corners on its border rows and the
4467 // `┃` bars on its content rows. No other dock row uses them.
4468 fn is_active_card_row(s: &str) -> bool {
4469 s.chars().any(|c| matches!(c, '┏' | '┓' | '┗' | '┛' | '┃'))
4470 }
4471 fn set_cell(
4472 frame: &mut ratatui::Frame,
4473 x: u16,
4474 y: u16,
4475 sym: &str,
4476 fg: ratatui::style::Color,
4477 bg: ratatui::style::Color,
4478 ) {
4479 if let Some(cell) = frame.buffer_mut().cell_mut((x, y)) {
4480 cell.set_symbol(sym);
4481 cell.set_fg(fg);
4482 cell.set_bg(bg);
4483 }
4484 }
4485
4486 // Locate the active card's contiguous row band.
4487 let mut top: Option<usize> = None;
4488 let mut bot = 0usize;
4489 for (i, e) in entries.iter().take(max_rows).enumerate() {
4490 if is_active_card_row(&e.text) {
4491 top.get_or_insert(i);
4492 bot = i;
4493 }
4494 }
4495 let Some(top) = top else { return };
4496 // Need a top border, at least one content row, and a bottom border.
4497 if bot < top + 2 {
4498 return;
4499 }
4500
4501 // `inner.x` is the dock's left edge; the wall sits one column past the
4502 // inner area (the block's `Borders::RIGHT`).
4503 let wall_x = overlay_rect.x + overlay_rect.width.saturating_sub(1);
4504 let left_x = inner.x;
4505 if wall_x <= left_x + 1 {
4506 return;
4507 }
4508 let top_y = inner.y + top as u16;
4509 let bot_y = inner.y + bot as u16;
4510
4511 // Top edge of the tab: ╭───…───╯ (╯ scoops up into the wall above).
4512 set_cell(frame, left_x, top_y, "╭", border_fg, bg);
4513 for x in (left_x + 1)..wall_x {
4514 set_cell(frame, x, top_y, "─", border_fg, bg);
4515 }
4516 set_cell(frame, wall_x, top_y, "╯", border_fg, bg);
4517
4518 // Bottom edge: ╰───…───╮ (╮ scoops down into the wall below).
4519 set_cell(frame, left_x, bot_y, "╰", border_fg, bg);
4520 for x in (left_x + 1)..wall_x {
4521 set_cell(frame, x, bot_y, "─", border_fg, bg);
4522 }
4523 set_cell(frame, wall_x, bot_y, "╮", border_fg, bg);
4524
4525 // Content rows: keep the left border, open the right — erase the card's
4526 // own right border, the gutter, and the wall — so the row flows into the
4527 // editor with no divider.
4528 for r in (top + 1)..bot {
4529 let y = inner.y + r as u16;
4530 set_cell(frame, left_x, y, "│", border_fg, bg);
4531 for x in wall_x.saturating_sub(2)..=wall_x {
4532 set_cell(frame, x, y, " ", border_fg, bg);
4533 }
4534 }
4535}
4536
4537/// Paint a single rendered widget entry into the frame buffer at
4538/// `(x, y)` over `width` cells. Resolves the entry's segments / inline
4539/// overlays to styled spans using the panel's theme; trailing columns
4540/// are filled with spaces in the panel's bg so the row reads as one
4541/// solid line.
4542fn paint_text_property_entry(
4543 frame: &mut ratatui::Frame,
4544 entry: &fresh_core::text_property::TextPropertyEntry,
4545 x: u16,
4546 y: u16,
4547 width: u16,
4548 theme: &crate::view::theme::Theme,
4549) {
4550 use ratatui::style::Style;
4551 use ratatui::text::{Line, Span};
4552 use ratatui::widgets::Paragraph;
4553
4554 let mut normalized = entry.clone();
4555 normalized.normalize_widths();
4556 let mut text = normalized.text.clone();
4557 while text.ends_with('\n') {
4558 text.pop();
4559 }
4560
4561 let base_bg = theme.suggestion_bg;
4562 let base_style = if let Some(opts) = normalized.style.as_ref() {
4563 // Resolve the entry's row-level style, then fill in the
4564 // suggestion_bg only when the style didn't supply one
4565 // of its own. Without this guard, calling `.bg(base_bg)`
4566 // unconditionally would wipe out a row-level
4567 // `popup_selection_bg` (the highlight on the completion
4568 // popup's selected candidate) — `Style::bg` is a
4569 // replacement, not a merge.
4570 let mut resolved = Editor::resolve_overlay_style(opts, theme);
4571 // Fill in the suggestion surface's fg/bg when the style didn't
4572 // supply its own — `suggestion_fg` is the foreground partner for
4573 // `suggestion_bg`. Without an fg default, unstyled toolbar text
4574 // (toggle labels, "save matches") fell through to the terminal's
4575 // default foreground, which is unreadable on light themes.
4576 if resolved.fg.is_none() {
4577 resolved = resolved.fg(theme.suggestion_fg);
4578 }
4579 if resolved.bg.is_none() {
4580 resolved.bg(base_bg)
4581 } else {
4582 resolved
4583 }
4584 } else {
4585 Style::default().fg(theme.suggestion_fg).bg(base_bg)
4586 };
4587
4588 // Split the line at inline-overlay byte boundaries so each
4589 // resulting span carries one consistent style. The overlays are
4590 // produced in declaration order by the widget renderer; later
4591 // overlays override earlier ones for any cells they cover.
4592 // Snap every boundary to a grapheme-cluster boundary. Overlay
4593 // offsets can land mid-codepoint after a row is truncated with a
4594 // multi-byte `…` (the overlay end isn't re-clamped to the new
4595 // text), and slicing `text[a..b]` on such an index panics. Valid
4596 // boundaries are kept as-is; an interior one floors to the previous
4597 // grapheme boundary (worst case a span edge shifts by one cluster,
4598 // invisible in practice).
4599 let snap = |i: usize| {
4600 let i = i.min(text.len());
4601 if text.is_char_boundary(i) {
4602 i
4603 } else {
4604 crate::primitives::grapheme::prev_grapheme_boundary(&text, i)
4605 }
4606 };
4607 let boundaries: std::collections::BTreeSet<usize> = std::iter::once(0)
4608 .chain(std::iter::once(text.len()))
4609 .chain(
4610 normalized
4611 .inline_overlays
4612 .iter()
4613 .flat_map(|o| [snap(o.start), snap(o.end)]),
4614 )
4615 .collect();
4616 let bounds: Vec<usize> = boundaries.into_iter().collect();
4617
4618 let mut spans: Vec<Span<'_>> = Vec::new();
4619 for win in bounds.windows(2) {
4620 let (a, b) = (win[0], win[1]);
4621 if a >= b {
4622 continue;
4623 }
4624 let slice = text[a..b].to_string();
4625 // Merge (don't replace) overlapping overlays so a later
4626 // overlay can override individual properties (bg, fg,
4627 // italic, …) without wiping the earlier overlay's other
4628 // properties. The text-input renderer relies on this:
4629 // the placeholder overlay sets fg + italic, then the
4630 // focused overlay sets bg only — without per-property
4631 // merge the focused-bg overlay would also clear the
4632 // placeholder's italic-dim styling, making placeholder
4633 // text indistinguishable from a typed value under focus.
4634 let mut style = base_style;
4635 for o in &normalized.inline_overlays {
4636 let os = o.start.min(text.len());
4637 let oe = o.end.min(text.len());
4638 if a >= os && b <= oe && oe > os {
4639 let resolved = Editor::resolve_overlay_style(&o.style, theme);
4640 if let Some(fg) = resolved.fg {
4641 style = style.fg(fg);
4642 }
4643 if let Some(bg) = resolved.bg {
4644 style = style.bg(bg);
4645 }
4646 // Ratatui `Style` carries add/sub modifier sets;
4647 // OR the additions in so subsequent overlays can
4648 // add italic / bold / etc. on top of the prior
4649 // overlay's modifiers.
4650 style = style.add_modifier(resolved.add_modifier);
4651 style = style.remove_modifier(resolved.sub_modifier);
4652 }
4653 }
4654 // Ensure a bg is set: ratatui will paint the slot with
4655 // the terminal's default bg otherwise, which doesn't
4656 // match the surrounding panel chrome.
4657 if style.bg.is_none() {
4658 style = style.bg(base_bg);
4659 }
4660 spans.push(Span::styled(slice, style));
4661 }
4662
4663 let line = Line::from(spans);
4664 let rect = ratatui::layout::Rect {
4665 x,
4666 y,
4667 width,
4668 height: 1,
4669 };
4670 frame.render_widget(Paragraph::new(line).style(base_style), rect);
4671}
4672
4673/// Translate a UTF-8 byte offset within a rendered line into a
4674/// display-column offset, walking codepoints with their Unicode
4675/// width. Used to place the hardware caret on the focused
4676/// TextInput's byte position.
4677fn byte_to_screen_col(text: &str, target_byte: usize) -> usize {
4678 use unicode_width::UnicodeWidthChar;
4679 let mut byte = 0;
4680 let mut col = 0usize;
4681 for ch in text.chars() {
4682 if byte >= target_byte {
4683 break;
4684 }
4685 col += UnicodeWidthChar::width(ch).unwrap_or(0);
4686 byte += ch.len_utf8();
4687 }
4688 col
4689}