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