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