Skip to main content

fresh/app/
render.rs

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