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