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 editor to the terminal
7    pub fn render(&mut self, frame: &mut Frame) {
8        let _span = tracing::info_span!("render").entered();
9        let size = frame.area();
10
11        // Let active animations snapshot the previous frame's buffer
12        // from the runner's own cache. We can't read the live
13        // `frame.buffer_mut()` — ratatui resets it before each draw —
14        // so the runner keeps a post-apply clone from the last frame.
15        self.animations.capture_before_all();
16
17        // Save frame dimensions for recompute_layout (used by macro replay)
18        self.cached_layout.last_frame_width = size.width;
19        self.cached_layout.last_frame_height = size.height;
20
21        // Reset per-cell theme key map for this frame
22        self.cached_layout.reset_cell_theme_map();
23
24        // Attach any queued LSP auto-start prompt to the currently
25        // active buffer. Done here (rather than at file-open) so the
26        // popup follows the user's focus through a session restore
27        // that opens several files of the same language in
28        // succession. No-op when nothing is queued.
29        self.drain_pending_lsp_prompt_for_active_buffer();
30
31        // For scroll sync groups, we need to update the active split's viewport position BEFORE
32        // calling sync_scroll_groups, so that the sync reads the correct position.
33        // Otherwise, cursor movements like 'G' (go to end) won't sync properly because
34        // viewport.top_byte hasn't been updated yet.
35        let active_split = self.split_manager.active_split();
36        {
37            let _span = tracing::info_span!("pre_sync_ensure_visible").entered();
38            self.pre_sync_ensure_visible(active_split);
39        }
40
41        // Synchronize scroll sync groups (anchor-based scroll for side-by-side diffs)
42        // This sets viewport positions based on the authoritative scroll_line in each group
43        {
44            let _span = tracing::info_span!("sync_scroll_groups").entered();
45            self.sync_scroll_groups();
46        }
47
48        // NOTE: Viewport sync with cursor is handled by split_rendering.rs which knows the
49        // correct content area dimensions. Don't sync here with incorrect EditorState viewport size.
50
51        // Prepare all buffers for rendering (pre-load viewport data for lazy loading)
52        // Each split may have a different viewport position on the same buffer
53        let mut semantic_ranges: std::collections::HashMap<BufferId, (usize, usize)> =
54            std::collections::HashMap::new();
55        {
56            let _span = tracing::info_span!("compute_semantic_ranges").entered();
57            for (split_id, view_state) in &self.split_view_states {
58                if let Some(buffer_id) = self.split_manager.get_buffer_id((*split_id).into()) {
59                    if let Some(state) = self.buffers.get(&buffer_id) {
60                        let start_line = state.buffer.get_line_number(view_state.viewport.top_byte);
61                        let visible_lines =
62                            view_state.viewport.visible_line_count().saturating_sub(1);
63                        let end_line = start_line.saturating_add(visible_lines);
64                        semantic_ranges
65                            .entry(buffer_id)
66                            .and_modify(|(min_start, max_end)| {
67                                *min_start = (*min_start).min(start_line);
68                                *max_end = (*max_end).max(end_line);
69                            })
70                            .or_insert((start_line, end_line));
71                    }
72                }
73            }
74        }
75        for (buffer_id, (start_line, end_line)) in semantic_ranges {
76            self.maybe_request_semantic_tokens_range(buffer_id, start_line, end_line);
77            self.maybe_request_semantic_tokens_full_debounced(buffer_id);
78            self.maybe_request_folding_ranges_debounced(buffer_id);
79        }
80
81        {
82            let _span = tracing::info_span!("prepare_for_render").entered();
83            for (split_id, view_state) in &self.split_view_states {
84                if let Some(buffer_id) = self.split_manager.get_buffer_id((*split_id).into()) {
85                    if let Some(state) = self.buffers.get_mut(&buffer_id) {
86                        let top_byte = view_state.viewport.top_byte;
87                        let height = view_state.viewport.height;
88                        if let Err(e) = state.prepare_for_render(top_byte, height) {
89                            tracing::error!("Failed to prepare buffer for render: {}", e);
90                            // Continue with partial rendering
91                        }
92                    }
93                }
94            }
95        }
96
97        // Refresh search highlights only during incremental search (when prompt is active)
98        // After search is confirmed, overlays exist for ALL matches and shouldn't be overwritten
99        let is_search_prompt_active = self.prompt.as_ref().is_some_and(|p| {
100            matches!(
101                p.prompt_type,
102                PromptType::Search | PromptType::ReplaceSearch | PromptType::QueryReplaceSearch
103            )
104        });
105        if is_search_prompt_active {
106            if let Some(ref search_state) = self.search_state {
107                let query = search_state.query.clone();
108                self.update_search_highlights(&query);
109            }
110        }
111
112        // Determine if we need to show search options bar
113        let show_search_options = self.prompt.as_ref().is_some_and(|p| {
114            matches!(
115                p.prompt_type,
116                PromptType::Search
117                    | PromptType::ReplaceSearch
118                    | PromptType::Replace { .. }
119                    | PromptType::QueryReplaceSearch
120                    | PromptType::QueryReplace { .. }
121            )
122        });
123
124        // Hide status bar when suggestions popup or file browser popup is shown
125        let has_suggestions = self
126            .prompt
127            .as_ref()
128            .is_some_and(|p| !p.suggestions.is_empty());
129        let has_file_browser = self.prompt.as_ref().is_some_and(|p| {
130            matches!(
131                p.prompt_type,
132                PromptType::OpenFile | PromptType::SwitchProject | PromptType::SaveFileAs
133            )
134        }) && self.file_open_state.is_some();
135
136        // Build main vertical layout: [menu_bar, main_content, status_bar, search_options, prompt_line]
137        // Status bar is hidden when suggestions popup is shown
138        // Search options bar is shown when in search prompt
139        let constraints = vec![
140            Constraint::Length(if self.menu_bar_visible { 1 } else { 0 }), // Menu bar
141            Constraint::Min(0),                                            // Main content area
142            Constraint::Length(
143                if !self.status_bar_visible || has_suggestions || has_file_browser {
144                    0
145                } else {
146                    1
147                },
148            ), // Status bar (hidden when toggled off or with popups)
149            Constraint::Length(if show_search_options { 1 } else { 0 }),   // Search options bar
150            Constraint::Length(if self.prompt_line_visible || self.prompt.is_some() {
151                1
152            } else {
153                0
154            }), // Prompt line (auto-hidden when no prompt active)
155        ];
156
157        let main_chunks = Layout::default()
158            .direction(Direction::Vertical)
159            .constraints(constraints)
160            .split(size);
161
162        let menu_bar_area = main_chunks[0];
163        let main_content_area = main_chunks[1];
164        let status_bar_idx = 2;
165        let search_options_idx = 3;
166        let prompt_line_idx = 4;
167
168        // Split main content area based on file explorer visibility
169        // Also keep the layout split if a sync is in progress (to avoid flicker)
170        let editor_content_area;
171        let file_explorer_should_show = self.file_explorer_visible
172            && (self.file_explorer.is_some() || self.file_explorer_sync_in_progress);
173
174        if file_explorer_should_show {
175            // Split horizontally based on side placement
176            tracing::trace!(
177                "render: file explorer layout active (present={}, sync_in_progress={}, side={:?})",
178                self.file_explorer.is_some(),
179                self.file_explorer_sync_in_progress,
180                self.file_explorer_side
181            );
182            let explorer_cols = self.file_explorer_width.to_cols(main_content_area.width);
183
184            let (explorer_area, editor_area) = match self.file_explorer_side {
185                FileExplorerSide::Left => {
186                    let chunks = Layout::default()
187                        .direction(Direction::Horizontal)
188                        .constraints([Constraint::Length(explorer_cols), Constraint::Min(0)])
189                        .split(main_content_area);
190                    (chunks[0], chunks[1])
191                }
192                FileExplorerSide::Right => {
193                    let chunks = Layout::default()
194                        .direction(Direction::Horizontal)
195                        .constraints([Constraint::Min(0), Constraint::Length(explorer_cols)])
196                        .split(main_content_area);
197                    (chunks[1], chunks[0])
198                }
199            };
200
201            self.cached_layout.file_explorer_area = Some(explorer_area);
202            editor_content_area = editor_area;
203
204            // Get connection string before mutable borrow of file_explorer.
205            let remote_connection = self.connection_display_string();
206
207            // Render file explorer (only if we have it - during sync we just keep the area reserved)
208            if let Some(ref mut explorer) = self.file_explorer {
209                let is_focused = self.key_context == KeyContext::FileExplorer;
210
211                // Build set of files with unsaved changes
212                let mut files_with_unsaved_changes = std::collections::HashSet::new();
213                for (buffer_id, state) in &self.buffers {
214                    if state.buffer.is_modified() {
215                        if let Some(metadata) = self.buffer_metadata.get(buffer_id) {
216                            if let Some(file_path) = metadata.file_path() {
217                                files_with_unsaved_changes.insert(file_path.clone());
218                            }
219                        }
220                    }
221                }
222
223                let close_button_hovered = matches!(
224                    &self.mouse_state.hover_target,
225                    Some(HoverTarget::FileExplorerCloseButton)
226                );
227                let keybindings = self.keybindings.read().unwrap();
228                let empty: Vec<std::path::PathBuf> = Vec::new();
229                let cut_paths = self
230                    .file_explorer_clipboard
231                    .as_ref()
232                    .filter(|cb| cb.is_cut)
233                    .map(|cb| cb.paths.as_slice())
234                    .unwrap_or(empty.as_slice());
235                FileExplorerRenderer::render(
236                    explorer,
237                    frame,
238                    explorer_area,
239                    is_focused,
240                    &files_with_unsaved_changes,
241                    &self.file_explorer_decoration_cache,
242                    &keybindings,
243                    self.key_context.clone(),
244                    &self.theme,
245                    close_button_hovered,
246                    remote_connection.as_deref(),
247                    cut_paths,
248                );
249            }
250            // Note: if file_explorer is None but sync_in_progress is true,
251            // we just leave the area blank (or could render a placeholder)
252        } else {
253            // No file explorer: use entire main content area for editor
254            self.cached_layout.file_explorer_area = None;
255            editor_content_area = main_content_area;
256        }
257
258        // Note: Tabs are now rendered within each split by SplitRenderer
259
260        // Trigger lines_changed hooks for newly visible lines in all visible buffers
261        // This allows plugins to add overlays before rendering
262        // Only lines that haven't been seen before are sent (batched for efficiency)
263        // Use non-blocking hooks to avoid deadlock when actions are awaiting
264        if self.plugin_manager.is_active() {
265            let hooks_start = std::time::Instant::now();
266            // Get visible buffers and their areas
267            let visible_buffers = self.split_manager.get_visible_buffers(editor_content_area);
268
269            let mut total_new_lines = 0usize;
270            for (split_id, buffer_id, split_area) in visible_buffers {
271                // Get viewport from SplitViewState (the authoritative source)
272                let viewport_top_byte = self
273                    .split_view_states
274                    .get(&split_id)
275                    .map(|vs| vs.viewport.top_byte)
276                    .unwrap_or(0);
277
278                if let Some(state) = self.buffers.get_mut(&buffer_id) {
279                    // Fire render_start hook once per buffer
280                    self.plugin_manager.run_hook(
281                        "render_start",
282                        crate::services::plugins::hooks::HookArgs::RenderStart { buffer_id },
283                    );
284
285                    // Fire view_transform_request hook with base tokens
286                    // This allows plugins to transform the view (e.g., soft breaks for markdown)
287                    let visible_count = split_area.height as usize;
288                    let is_binary = state.buffer.is_binary();
289                    let line_ending = state.buffer.line_ending();
290                    let base_tokens =
291                        crate::view::ui::split_rendering::SplitRenderer::build_base_tokens_for_hook(
292                            &mut state.buffer,
293                            viewport_top_byte,
294                            self.config.editor.estimated_line_length,
295                            visible_count,
296                            is_binary,
297                            line_ending,
298                        );
299                    let viewport_start = viewport_top_byte;
300                    let viewport_end = base_tokens
301                        .last()
302                        .and_then(|t| t.source_offset)
303                        .unwrap_or(viewport_start);
304                    let cursor_positions: Vec<usize> = self
305                        .split_view_states
306                        .get(&split_id)
307                        .map(|vs| vs.cursors.iter().map(|(_, c)| c.position).collect())
308                        .unwrap_or_default();
309                    self.plugin_manager.run_hook(
310                        "view_transform_request",
311                        crate::services::plugins::hooks::HookArgs::ViewTransformRequest {
312                            buffer_id,
313                            split_id: split_id.into(),
314                            viewport_start,
315                            viewport_end,
316                            tokens: base_tokens,
317                            cursor_positions,
318                        },
319                    );
320
321                    // We just sent fresh base tokens to the plugin, so any
322                    // future SubmitViewTransform from this request will be valid.
323                    // Clear the stale flag so the response will be accepted.
324                    if let Some(vs) = self.split_view_states.get_mut(&split_id) {
325                        vs.view_transform_stale = false;
326                    }
327
328                    // Use the split area height as visible line count
329                    let visible_count = split_area.height as usize;
330                    let top_byte = viewport_top_byte;
331
332                    // Get or create the seen byte ranges set for this buffer
333                    let seen_byte_ranges = self.seen_byte_ranges.entry(buffer_id).or_default();
334
335                    // Collect only NEW lines (not seen before based on byte range)
336                    let mut new_lines: Vec<crate::services::plugins::hooks::LineInfo> = Vec::new();
337                    let mut line_number = state.buffer.get_line_number(top_byte);
338                    let mut iter = state
339                        .buffer
340                        .line_iterator(top_byte, self.config.editor.estimated_line_length);
341
342                    for _ in 0..visible_count {
343                        if let Some((line_start, line_content)) = iter.next_line() {
344                            let byte_end = line_start + line_content.len();
345                            let byte_range = (line_start, byte_end);
346
347                            // Only add if this byte range hasn't been seen before
348                            if !seen_byte_ranges.contains(&byte_range) {
349                                new_lines.push(crate::services::plugins::hooks::LineInfo {
350                                    line_number,
351                                    byte_start: line_start,
352                                    byte_end,
353                                    content: line_content,
354                                });
355                                seen_byte_ranges.insert(byte_range);
356                            }
357                            line_number += 1;
358                        } else {
359                            break;
360                        }
361                    }
362
363                    // Send batched hook if there are new lines
364                    if !new_lines.is_empty() {
365                        total_new_lines += new_lines.len();
366                        self.plugin_manager.run_hook(
367                            "lines_changed",
368                            crate::services::plugins::hooks::HookArgs::LinesChanged {
369                                buffer_id,
370                                lines: new_lines,
371                            },
372                        );
373                    }
374                }
375            }
376            let hooks_elapsed = hooks_start.elapsed();
377            tracing::trace!(
378                new_lines = total_new_lines,
379                elapsed_ms = hooks_elapsed.as_millis(),
380                elapsed_us = hooks_elapsed.as_micros(),
381                "lines_changed hooks total"
382            );
383
384            // Process any plugin commands (like AddOverlay) that resulted from the hooks.
385            //
386            // This is non-blocking: we collect whatever the plugin has sent so far.
387            // The plugin thread runs in parallel, and because we proactively call
388            // handle_refresh_lines after cursor_moved (in fire_cursor_hooks), the
389            // lines_changed hook fires early in the render cycle. By the time we
390            // reach this point, the plugin has typically already processed all hooks
391            // and sent back conceal/overlay commands. On rare occasions (high CPU
392            // load), the response arrives one frame late, which is imperceptible
393            // at 60fps. The plugin's own refreshLines() call from cursor_moved
394            // ensures a follow-up render cycle picks up any missed commands.
395            let commands = self.plugin_manager.process_commands();
396            if !commands.is_empty() {
397                let cmd_names: Vec<String> =
398                    commands.iter().map(|c| c.debug_variant_name()).collect();
399                tracing::trace!(count = commands.len(), cmds = ?cmd_names, "process_commands during render");
400            }
401            for command in commands {
402                if let Err(e) = self.handle_plugin_command(command) {
403                    tracing::error!("Error handling plugin command: {}", e);
404                }
405            }
406
407            // Flush any deferred grammar rebuilds as a single batch
408            self.flush_pending_grammars();
409        }
410
411        // Render editor content (same for both layouts)
412        let lsp_waiting = !self.pending_completion_requests.is_empty()
413            || self.pending_goto_definition_request.is_some();
414
415        // Hide the hardware cursor when menu is open, file explorer is focused, terminal mode,
416        // or settings UI is open
417        // (the file explorer will set its own cursor position when focused)
418        // (terminal mode renders its own cursor via the terminal emulator)
419        // (settings UI is a modal that doesn't need the editor cursor)
420        // This also causes visual cursor indicators in the editor to be dimmed
421        let settings_visible = self.settings_state.as_ref().is_some_and(|s| s.visible);
422        let hide_cursor = self.menu_state.active_menu.is_some()
423            || self.key_context == KeyContext::FileExplorer
424            || self.terminal_mode
425            || settings_visible
426            || self.keybinding_editor.is_some();
427
428        // Convert HoverTarget to tab hover info for rendering
429        let hovered_tab = match &self.mouse_state.hover_target {
430            Some(HoverTarget::TabName(target, split_id)) => Some((*target, *split_id, false)),
431            Some(HoverTarget::TabCloseButton(target, split_id)) => Some((*target, *split_id, true)),
432            _ => None,
433        };
434
435        // Get hovered close split button
436        let hovered_close_split = match &self.mouse_state.hover_target {
437            Some(HoverTarget::CloseSplitButton(split_id)) => Some(*split_id),
438            _ => None,
439        };
440
441        // Get hovered maximize split button
442        let hovered_maximize_split = match &self.mouse_state.hover_target {
443            Some(HoverTarget::MaximizeSplitButton(split_id)) => Some(*split_id),
444            _ => None,
445        };
446
447        let is_maximized = self.split_manager.is_maximized();
448
449        // The active split's buffer renderer records where the hardware
450        // cursor *wants* to appear here; we only commit it to the frame at
451        // the very end of this draw pass, after popups have been rendered,
452        // so a popup covering the cursor cell causes the cursor to be
453        // hidden (otherwise the hardware caret would bleed through the
454        // popup).
455        let mut pending_hardware_cursor: Option<(u16, u16)> = None;
456
457        let _content_span = tracing::info_span!("render_content").entered();
458        let (
459            split_areas,
460            tab_layouts,
461            close_split_areas,
462            maximize_split_areas,
463            view_line_mappings,
464            horizontal_scrollbar_areas,
465            grouped_separator_areas,
466        ) = SplitRenderer::render_content(
467            frame,
468            editor_content_area,
469            &self.split_manager,
470            &mut self.buffers,
471            &self.buffer_metadata,
472            &mut self.event_logs,
473            &mut self.composite_buffers,
474            &mut self.composite_view_states,
475            &self.theme,
476            self.ansi_background.as_ref(),
477            self.background_fade,
478            lsp_waiting,
479            self.config.editor.large_file_threshold_bytes,
480            self.config.editor.line_wrap,
481            self.config.editor.estimated_line_length,
482            self.config.editor.highlight_context_bytes,
483            Some(&mut self.split_view_states),
484            &self.grouped_subtrees,
485            hide_cursor,
486            hovered_tab,
487            hovered_close_split,
488            hovered_maximize_split,
489            is_maximized,
490            self.config.editor.relative_line_numbers,
491            self.tab_bar_visible,
492            self.config.editor.use_terminal_bg,
493            self.session_mode || !self.software_cursor_only,
494            self.software_cursor_only,
495            self.config.editor.show_vertical_scrollbar,
496            self.config.editor.show_horizontal_scrollbar,
497            self.config.editor.diagnostics_inline_text,
498            self.config.editor.show_tilde,
499            self.config.editor.highlight_current_column,
500            &mut self.cached_layout.cell_theme_map,
501            size.width,
502            &mut pending_hardware_cursor,
503        );
504
505        drop(_content_span);
506
507        // Cursor-jump animation: compare the cursor's screen position to
508        // the prior frame and animate either when the cursor crossed split
509        // panes or moved more than two rows within the same pane. The
510        // trail crosses pane separators when the jump is across splits —
511        // that's the intended "follow the focus" cue.
512        self.maybe_start_cursor_jump_animation(pending_hardware_cursor, active_split);
513
514        // Detect viewport changes and fire hooks
515        // Compare against previous frame's viewport state (stored in self.previous_viewports)
516        // This correctly detects changes from scroll events that happen before render()
517        if self.plugin_manager.is_active() {
518            for (split_id, view_state) in &self.split_view_states {
519                let current = (
520                    view_state.viewport.top_byte,
521                    view_state.viewport.width,
522                    view_state.viewport.height,
523                );
524                // Compare against previous frame's state
525                // Skip new splits (None case) - only fire hooks for established splits
526                // This matches the original behavior where hooks only fire for splits
527                // that existed at the start of render
528                let (changed, previous) = match self.previous_viewports.get(split_id) {
529                    Some(previous) => (*previous != current, Some(*previous)),
530                    None => (false, None), // Skip new splits until they're established
531                };
532                tracing::trace!(
533                    "viewport_changed check: split={:?} current={:?} previous={:?} changed={}",
534                    split_id,
535                    current,
536                    previous,
537                    changed
538                );
539                if changed {
540                    if let Some(buffer_id) = self.split_manager.get_buffer_id((*split_id).into()) {
541                        // Compute top_line if line info is available
542                        let top_line = self.buffers.get(&buffer_id).and_then(|state| {
543                            if state.buffer.line_count().is_some() {
544                                Some(state.buffer.get_line_number(view_state.viewport.top_byte))
545                            } else {
546                                None
547                            }
548                        });
549                        tracing::debug!(
550                            "Firing viewport_changed hook: split={:?} buffer={:?} top_byte={} top_line={:?}",
551                            split_id,
552                            buffer_id,
553                            view_state.viewport.top_byte,
554                            top_line
555                        );
556                        self.plugin_manager.run_hook(
557                            "viewport_changed",
558                            crate::services::plugins::hooks::HookArgs::ViewportChanged {
559                                split_id: (*split_id).into(),
560                                buffer_id,
561                                top_byte: view_state.viewport.top_byte,
562                                top_line,
563                                width: view_state.viewport.width,
564                                height: view_state.viewport.height,
565                            },
566                        );
567                    }
568                }
569            }
570        }
571
572        // Update previous_viewports for next frame's comparison
573        self.previous_viewports.clear();
574        for (split_id, view_state) in &self.split_view_states {
575            self.previous_viewports.insert(
576                *split_id,
577                (
578                    view_state.viewport.top_byte,
579                    view_state.viewport.width,
580                    view_state.viewport.height,
581                ),
582            );
583        }
584
585        // Render terminal content on top of split content for terminal buffers
586        self.render_terminal_splits(frame, &split_areas);
587
588        self.cached_layout.split_areas = split_areas;
589        self.cached_layout.horizontal_scrollbar_areas = horizontal_scrollbar_areas;
590        self.cached_layout.tab_layouts = tab_layouts;
591        self.cached_layout.close_split_areas = close_split_areas;
592        self.cached_layout.maximize_split_areas = maximize_split_areas;
593        self.cached_layout.view_line_mappings = view_line_mappings;
594
595        // Promote any deferred virtual-buffer animations whose Rect is now
596        // known. Done here (after split_areas is recomputed, before
597        // apply_all runs at the end of render) so the first frame of the
598        // effect lands on the same paint that made the buffer visible.
599        self.drain_pending_vb_animations();
600        let mut separator_areas = self
601            .split_manager
602            .get_separators_with_ids(editor_content_area);
603        // Grouped subtrees live in a side-map outside the main split tree, so
604        // their inner separators are not visited by `get_separators_with_ids`
605        // above. The renderer collected them (using the same content rect it
606        // drew them at) — merge so clicks on those rendered columns register.
607        separator_areas.extend(grouped_separator_areas);
608        self.cached_layout.separator_areas = separator_areas;
609        self.cached_layout.editor_content_area = Some(editor_content_area);
610
611        // Render hover highlights for separators and scrollbars
612        self.render_hover_highlights(frame);
613
614        // Initialize popup/suggestion layout state (rendered after status bar below)
615        self.cached_layout.suggestions_area = None;
616        self.cached_layout.suggestions_outer_area = None;
617        self.file_browser_layout = None;
618
619        // Clone all immutable values before the mutable borrow
620        let display_name = self
621            .buffer_metadata
622            .get(&self.active_buffer())
623            .map(|m| m.display_name.clone())
624            .unwrap_or_else(|| "[No Name]".to_string());
625
626        // Reflect the active buffer in the terminal window/tab title. Only
627        // writes when the title actually changes so we don't flood stdout
628        // with OSC sequences every frame.
629        self.update_terminal_title(&display_name);
630
631        let status_message = self.status_message.clone();
632        let plugin_status_message = self.plugin_status_message.clone();
633        let prompt = self.prompt.clone();
634        // Compute a simple buffer-aware LSP indicator.
635        // Compose the LSP status-bar segment for the active buffer. This
636        // runs every render — the editor has no precomputed LSP-status
637        // string cached anywhere else, so there is a single source of
638        // truth for what the user sees.
639        //
640        // Priority order (first non-empty wins):
641        //
642        //   1. Active `$/progress` work for this language — e.g.
643        //      "LSP (cpp): indexing (42%)". Conveys the transient
644        //      startup/indexing phase.
645        //   2. A running server — "LSP". Short because detail belongs
646        //      in LSP-specific UI, not the compact status bar pill.
647        //   3. Configured `auto_start=true` servers that haven't started
648        //      (error / crashed / pending) — "LSP off".
649        //   4. Configured `enabled && !auto_start` servers that the user
650        //      has to opt into — "LSP: off (N)".
651        //   5. Nothing.
652        //
653        // Rules 3 and 4 address heuristic eval H-1: without them, a
654        // configured-but-dormant server is indistinguishable from "no
655        // LSP at all."
656        let current_language = self
657            .buffers
658            .get(&self.active_buffer())
659            .map(|s| s.language.clone())
660            .unwrap_or_default();
661        let buffer_lsp_disabled_reason = self
662            .buffer_metadata
663            .get(&self.active_buffer())
664            .filter(|m| !m.lsp_enabled)
665            .and_then(|m| m.lsp_disabled_reason.as_deref());
666        let (lsp_status, lsp_indicator_state) = compose_lsp_status(
667            &current_language,
668            buffer_lsp_disabled_reason,
669            &self.lsp_progress,
670            &self.lsp_server_statuses,
671            &self.config.lsp,
672            &self.user_dismissed_lsp_languages,
673        );
674        let theme = self.theme.clone();
675        let keybindings_cloned = self.keybindings.read().unwrap().clone(); // Clone the keybindings
676        let chord_state_cloned = self.chord_state.clone(); // Clone the chord state
677
678        // Get update availability info
679        let update_available = self.latest_version().map(|v| v.to_string());
680
681        // Render status bar (hidden when toggled off, or when suggestions/file browser popup is shown)
682        if self.status_bar_visible && !has_suggestions && !has_file_browser {
683            // Get warning level for colored indicator (respects config setting)
684            // LSP warning level is scoped to the current buffer's language
685            let (warning_level, general_warning_count) =
686                if self.config.warnings.show_status_indicator {
687                    let lsp_level = {
688                        use crate::services::async_bridge::LspServerStatus;
689                        let mut level = WarningLevel::None;
690                        for ((lang, _), status) in &self.lsp_server_statuses {
691                            if lang == &current_language {
692                                match status {
693                                    LspServerStatus::Error => {
694                                        level = WarningLevel::Error;
695                                        break;
696                                    }
697                                    LspServerStatus::Starting | LspServerStatus::Initializing => {
698                                        if level != WarningLevel::Error {
699                                            level = WarningLevel::Warning;
700                                        }
701                                    }
702                                    _ => {}
703                                }
704                            }
705                        }
706                        level
707                    };
708                    (lsp_level, self.get_general_warning_count())
709                } else {
710                    (WarningLevel::None, 0)
711                };
712
713            // Compute status bar hover state for styling
714            use crate::view::ui::status_bar::StatusBarHover;
715            let status_bar_hover = match &self.mouse_state.hover_target {
716                Some(HoverTarget::StatusBarLspIndicator) => StatusBarHover::LspIndicator,
717                Some(HoverTarget::StatusBarWarningBadge) => StatusBarHover::WarningBadge,
718                Some(HoverTarget::StatusBarLineEndingIndicator) => {
719                    StatusBarHover::LineEndingIndicator
720                }
721                Some(HoverTarget::StatusBarEncodingIndicator) => StatusBarHover::EncodingIndicator,
722                Some(HoverTarget::StatusBarLanguageIndicator) => StatusBarHover::LanguageIndicator,
723                Some(HoverTarget::StatusBarRemoteIndicator) => StatusBarHover::RemoteIndicator,
724                _ => StatusBarHover::None,
725            };
726
727            let remote_connection = self.connection_display_string();
728
729            // Get session name for display (only in session mode)
730            let session_name = self.session_name().map(|s| s.to_string());
731
732            let active_split = self.effective_active_split();
733            let active_buf = self.active_buffer();
734            let default_cursors = crate::model::cursor::Cursors::new();
735            let status_cursors = self
736                .split_view_states
737                .get(&active_split)
738                .map(|vs| &vs.cursors)
739                .unwrap_or(&default_cursors);
740            let is_read_only = self
741                .buffer_metadata
742                .get(&active_buf)
743                .map(|m| m.read_only)
744                .unwrap_or(false);
745            let is_synthetic_placeholder = self
746                .buffer_metadata
747                .get(&active_buf)
748                .map(|m| m.synthetic_placeholder)
749                .unwrap_or(false);
750            let mut status_ctx = crate::view::ui::status_bar::StatusBarContext {
751                state: self.buffers.get_mut(&active_buf).unwrap(),
752                cursors: status_cursors,
753                status_message: &status_message,
754                plugin_status_message: &plugin_status_message,
755                lsp_status: &lsp_status,
756                lsp_indicator_state,
757                theme: &theme,
758                display_name: &display_name,
759                keybindings: &keybindings_cloned,
760                chord_state: &chord_state_cloned,
761                update_available: update_available.as_deref(),
762                warning_level,
763                general_warning_count,
764                hover: status_bar_hover,
765                remote_connection: remote_connection.as_deref(),
766                session_name: session_name.as_deref(),
767                read_only: is_read_only,
768                remote_state_override: self.remote_indicator_override.as_ref(),
769                is_synthetic_placeholder,
770                // Filled in by `render_status` from the user's
771                // status_bar config; the value here is just a
772                // safe default for the rare path that builds the
773                // ctx but doesn't run `render_status`.
774                remote_indicator_on_bar: false,
775            };
776            let status_bar_layout = StatusBarRenderer::render_status_bar(
777                frame,
778                main_chunks[status_bar_idx],
779                &mut status_ctx,
780                &self.config.editor.status_bar,
781            );
782
783            // Store status bar layout for click detection
784            let status_bar_area = main_chunks[status_bar_idx];
785            self.cached_layout.status_bar_area =
786                Some((status_bar_area.y, status_bar_area.x, status_bar_area.width));
787            self.cached_layout.status_bar_lsp_area = status_bar_layout.lsp_indicator;
788            self.cached_layout.status_bar_warning_area = status_bar_layout.warning_badge;
789            self.cached_layout.status_bar_line_ending_area =
790                status_bar_layout.line_ending_indicator;
791            self.cached_layout.status_bar_encoding_area = status_bar_layout.encoding_indicator;
792            self.cached_layout.status_bar_language_area = status_bar_layout.language_indicator;
793            self.cached_layout.status_bar_message_area = status_bar_layout.message_area;
794            self.cached_layout.status_bar_remote_area = status_bar_layout.remote_indicator;
795        }
796
797        // Render search options bar when in search prompt
798        if show_search_options {
799            // Show "Confirm" option only in replace modes
800            let confirm_each = self.prompt.as_ref().and_then(|p| {
801                if matches!(
802                    p.prompt_type,
803                    PromptType::ReplaceSearch
804                        | PromptType::Replace { .. }
805                        | PromptType::QueryReplaceSearch
806                        | PromptType::QueryReplace { .. }
807                ) {
808                    Some(self.search_confirm_each)
809                } else {
810                    None
811                }
812            });
813
814            // Determine hover state for search options
815            use crate::view::ui::status_bar::SearchOptionsHover;
816            let search_options_hover = match &self.mouse_state.hover_target {
817                Some(HoverTarget::SearchOptionCaseSensitive) => SearchOptionsHover::CaseSensitive,
818                Some(HoverTarget::SearchOptionWholeWord) => SearchOptionsHover::WholeWord,
819                Some(HoverTarget::SearchOptionRegex) => SearchOptionsHover::Regex,
820                Some(HoverTarget::SearchOptionConfirmEach) => SearchOptionsHover::ConfirmEach,
821                _ => SearchOptionsHover::None,
822            };
823
824            let search_options_layout = StatusBarRenderer::render_search_options(
825                frame,
826                main_chunks[search_options_idx],
827                self.search_case_sensitive,
828                self.search_whole_word,
829                self.search_use_regex,
830                confirm_each,
831                &theme,
832                &keybindings_cloned,
833                search_options_hover,
834            );
835            self.cached_layout.search_options_layout = Some(search_options_layout);
836        } else {
837            self.cached_layout.search_options_layout = None;
838        }
839
840        // Render prompt line if active
841        if let Some(prompt) = &prompt {
842            // Use specialized renderer for file/folder open prompt to show colorized path
843            if matches!(
844                prompt.prompt_type,
845                crate::view::prompt::PromptType::OpenFile
846                    | crate::view::prompt::PromptType::SwitchProject
847            ) {
848                if let Some(file_open_state) = &self.file_open_state {
849                    StatusBarRenderer::render_file_open_prompt(
850                        frame,
851                        main_chunks[prompt_line_idx],
852                        prompt,
853                        file_open_state,
854                        &theme,
855                    );
856                } else {
857                    StatusBarRenderer::render_prompt(
858                        frame,
859                        main_chunks[prompt_line_idx],
860                        prompt,
861                        &theme,
862                    );
863                }
864            } else {
865                StatusBarRenderer::render_prompt(
866                    frame,
867                    main_chunks[prompt_line_idx],
868                    prompt,
869                    &theme,
870                );
871            }
872        }
873
874        // Render file browser popup or suggestions popup AFTER status bar + prompt,
875        // so they overlay on top of both (fixes bottom border being overwritten by status bar)
876        self.render_prompt_popups(frame, main_chunks[prompt_line_idx], size.width);
877
878        // Render popups from the active buffer state
879        // Clone theme to avoid borrow checker issues with active_state_mut()
880        let theme_clone = self.theme.clone();
881        let hover_target = self.mouse_state.hover_target.clone();
882
883        // Clear popup areas and recalculate
884        self.cached_layout.popup_areas.clear();
885
886        // Collect popup information without holding a mutable borrow
887        let popup_info: Vec<_> = {
888            // Get viewport from active split's SplitViewState
889            let active_split = self.split_manager.active_split();
890            let viewport = self
891                .split_view_states
892                .get(&active_split)
893                .map(|vs| vs.viewport.clone());
894
895            // Get the content_rect for the active split from the cached layout.
896            // This is the absolute screen rect (already accounts for file explorer,
897            // tab bar, scrollbars, etc.). The gutter is rendered inside this rect,
898            // so we add gutter_width to get the text content origin.
899            let content_rect = self
900                .cached_layout
901                .split_areas
902                .iter()
903                .find(|(split_id, _, _, _, _, _)| *split_id == active_split)
904                .map(|(_, _, rect, _, _, _)| *rect);
905
906            let primary_cursor = self
907                .split_view_states
908                .get(&active_split)
909                .map(|vs| *vs.cursors.primary());
910            let state = self.active_state_mut();
911            if state.popups.is_visible() {
912                // Get the primary cursor position for popup positioning
913                let primary_cursor =
914                    primary_cursor.unwrap_or_else(|| crate::model::cursor::Cursor::new(0));
915
916                // Compute gutter width so we know where text content starts
917                let gutter_width = viewport
918                    .as_ref()
919                    .map(|vp| vp.gutter_width(&state.buffer) as u16)
920                    .unwrap_or(0);
921
922                let cursor_screen_pos = viewport
923                    .as_ref()
924                    .map(|vp| vp.cursor_screen_position(&mut state.buffer, &primary_cursor))
925                    .unwrap_or((0, 0));
926
927                // For completion popups, compute the word-start screen position so
928                // the popup aligns with the beginning of the word being completed,
929                // not the current cursor position.
930                let word_start_screen_pos = {
931                    use crate::primitives::word_navigation::find_completion_word_start;
932                    let word_start =
933                        find_completion_word_start(&state.buffer, primary_cursor.position);
934                    let word_start_cursor = crate::model::cursor::Cursor::new(word_start);
935                    viewport
936                        .as_ref()
937                        .map(|vp| vp.cursor_screen_position(&mut state.buffer, &word_start_cursor))
938                        .unwrap_or((0, 0))
939                };
940
941                // Use content_rect as the single source of truth for the text
942                // content area origin. content_rect.x is the split's left edge
943                // (already past the file explorer), content_rect.y is below the
944                // tab bar. Adding gutter_width gives us the text content start.
945                let (base_x, base_y) = content_rect
946                    .map(|r| (r.x + gutter_width, r.y))
947                    .unwrap_or((gutter_width, 1));
948
949                let cursor_screen_pos =
950                    (cursor_screen_pos.0 + base_x, cursor_screen_pos.1 + base_y);
951                let word_start_screen_pos = (
952                    word_start_screen_pos.0 + base_x,
953                    word_start_screen_pos.1 + base_y,
954                );
955
956                // Collect popup data
957                state
958                    .popups
959                    .all()
960                    .iter()
961                    .enumerate()
962                    .map(|(popup_idx, popup)| {
963                        // Use word-start x for completion popups, cursor x for others
964                        let popup_pos = if popup.kind == crate::view::popup::PopupKind::Completion {
965                            (word_start_screen_pos.0, cursor_screen_pos.1)
966                        } else {
967                            cursor_screen_pos
968                        };
969                        let popup_area = popup.calculate_area(size, Some(popup_pos));
970
971                        // Track popup area for mouse hit testing
972                        // Account for description height when calculating the list item area
973                        let desc_height = popup.description_height();
974                        let inner_area = if popup.bordered {
975                            ratatui::layout::Rect {
976                                x: popup_area.x + 1,
977                                y: popup_area.y + 1 + desc_height,
978                                width: popup_area.width.saturating_sub(2),
979                                height: popup_area.height.saturating_sub(2 + desc_height),
980                            }
981                        } else {
982                            ratatui::layout::Rect {
983                                x: popup_area.x,
984                                y: popup_area.y + desc_height,
985                                width: popup_area.width,
986                                height: popup_area.height.saturating_sub(desc_height),
987                            }
988                        };
989
990                        let num_items = match &popup.content {
991                            crate::view::popup::PopupContent::List { items, .. } => items.len(),
992                            _ => 0,
993                        };
994
995                        // Calculate total content lines and scrollbar rect
996                        let total_lines = popup.item_count();
997                        let visible_lines = inner_area.height as usize;
998                        let scrollbar_rect = if total_lines > visible_lines && inner_area.width > 2
999                        {
1000                            Some(ratatui::layout::Rect {
1001                                x: inner_area.x + inner_area.width - 1,
1002                                y: inner_area.y,
1003                                width: 1,
1004                                height: inner_area.height,
1005                            })
1006                        } else {
1007                            None
1008                        };
1009
1010                        (
1011                            popup_idx,
1012                            popup_area,
1013                            inner_area,
1014                            popup.scroll_offset,
1015                            num_items,
1016                            scrollbar_rect,
1017                            total_lines,
1018                        )
1019                    })
1020                    .collect()
1021            } else {
1022                Vec::new()
1023            }
1024        };
1025
1026        // Store popup areas for mouse hit testing
1027        self.cached_layout.popup_areas = popup_info.clone();
1028
1029        // Now render popups
1030        let state = self.active_state_mut();
1031        if state.popups.is_visible() {
1032            for (popup_idx, popup) in state.popups.all().iter().enumerate() {
1033                if let Some((_, popup_area, _, _, _, _, _)) = popup_info.get(popup_idx) {
1034                    popup.render_with_hover(
1035                        frame,
1036                        *popup_area,
1037                        &theme_clone,
1038                        hover_target.as_ref(),
1039                    );
1040                }
1041            }
1042        }
1043
1044        // Render editor-level popups (e.g. plugin action popups) on top of any
1045        // buffer content so they stay visible across buffer switches and over
1046        // virtual buffers (Dashboard, diagnostics) that own the whole split.
1047        // These don't need cursor-relative positioning — they all use absolute
1048        // positions like BottomRight or Centered.
1049        //
1050        // Queue semantics: concurrent action popups stack in `global_popups`,
1051        // but only the top one renders & receives input. Deeper popups
1052        // surface as the top is resolved — the alternative (drawing all at
1053        // the same BottomRight slot) makes them illegible.
1054        self.cached_layout.global_popup_areas.clear();
1055        if let Some(popup) = self.global_popups.top() {
1056            let top_idx = self.global_popups.all().len() - 1;
1057            let popup_area = popup.calculate_area(size, None);
1058            let desc_height = popup.description_height();
1059            let inner_area = if popup.bordered {
1060                ratatui::layout::Rect {
1061                    x: popup_area.x + 1,
1062                    y: popup_area.y + 1 + desc_height,
1063                    width: popup_area.width.saturating_sub(2),
1064                    height: popup_area.height.saturating_sub(2 + desc_height),
1065                }
1066            } else {
1067                ratatui::layout::Rect {
1068                    x: popup_area.x,
1069                    y: popup_area.y + desc_height,
1070                    width: popup_area.width,
1071                    height: popup_area.height.saturating_sub(desc_height),
1072                }
1073            };
1074            let num_items = match &popup.content {
1075                crate::view::popup::PopupContent::List { items, .. } => items.len(),
1076                _ => 0,
1077            };
1078            self.cached_layout.global_popup_areas.push((
1079                top_idx,
1080                popup_area,
1081                inner_area,
1082                popup.scroll_offset,
1083                num_items,
1084            ));
1085            popup.render_with_hover(frame, popup_area, &theme_clone, hover_target.as_ref());
1086        }
1087
1088        // Render menu bar last so dropdown appears on top of all other content
1089        // Update menu context with current editor state
1090        self.update_menu_context();
1091
1092        // Render settings modal (before menu bar so menus can overlay)
1093        // Check visibility first to avoid borrow conflict with dimming
1094        let settings_visible = self
1095            .settings_state
1096            .as_ref()
1097            .map(|s| s.visible)
1098            .unwrap_or(false);
1099        if settings_visible {
1100            // Dim the editor content behind the settings modal
1101            crate::view::dimming::apply_dimming(frame, size);
1102        }
1103        if let Some(ref mut settings_state) = self.settings_state {
1104            if settings_state.visible {
1105                settings_state.update_focus_states();
1106                let settings_layout = crate::view::settings::render_settings(
1107                    frame,
1108                    size,
1109                    settings_state,
1110                    &self.theme,
1111                );
1112                self.cached_layout.settings_layout = Some(settings_layout);
1113            }
1114        }
1115
1116        // Render calibration wizard if active
1117        if let Some(ref wizard) = self.calibration_wizard {
1118            // Dim the editor content behind the wizard modal
1119            crate::view::dimming::apply_dimming(frame, size);
1120            crate::view::calibration_wizard::render_calibration_wizard(
1121                frame,
1122                size,
1123                wizard,
1124                &self.theme,
1125            );
1126        }
1127
1128        // Render keybinding editor if active
1129        if let Some(ref mut kb_editor) = self.keybinding_editor {
1130            crate::view::dimming::apply_dimming(frame, size);
1131            crate::view::keybinding_editor::render_keybinding_editor(
1132                frame,
1133                size,
1134                kb_editor,
1135                &self.theme,
1136            );
1137        }
1138
1139        // Render event debug dialog if active
1140        if let Some(ref debug) = self.event_debug {
1141            // Dim the editor content behind the dialog modal
1142            crate::view::dimming::apply_dimming(frame, size);
1143            crate::view::event_debug::render_event_debug(frame, size, debug, &self.theme);
1144        }
1145
1146        if self.menu_bar_visible {
1147            // Pre-expand DynamicSubmenu items once per registry; without this
1148            // MenuRenderer::render rescans + reparses every theme JSON file
1149            // on every frame.
1150            self.expanded_menus_cache.update(
1151                &self.theme_registry,
1152                &self.menus,
1153                &self.menu_state.themes_dir,
1154            );
1155            let expanded = self.expanded_menus_cache.get().expect("just updated");
1156            let keybindings = self.keybindings.read().unwrap();
1157            self.cached_layout.menu_layout = Some(crate::view::ui::MenuRenderer::render(
1158                frame,
1159                menu_bar_area,
1160                expanded,
1161                &self.menu_state,
1162                &keybindings,
1163                &self.theme,
1164                self.mouse_state.hover_target.as_ref(),
1165                self.config.editor.menu_bar_mnemonics,
1166            ));
1167        } else {
1168            self.cached_layout.menu_layout = None;
1169        }
1170
1171        // Render tab context menu if open
1172        if let Some(ref menu) = self.tab_context_menu {
1173            self.render_tab_context_menu(frame, menu);
1174        }
1175
1176        if let Some(ref menu) = self.file_explorer_context_menu {
1177            self.render_file_explorer_context_menu(frame, menu);
1178        }
1179
1180        // Record non-editor region theme keys for the theme inspector
1181        self.record_non_editor_theme_regions();
1182
1183        // Render theme info popup (Ctrl+Right-Click)
1184        self.render_theme_info_popup(frame);
1185
1186        // Render tab drag drop zone overlay if dragging a tab
1187        if let Some(ref drag_state) = self.mouse_state.dragging_tab {
1188            if drag_state.is_dragging() {
1189                self.render_tab_drop_zone(frame, drag_state);
1190            }
1191        }
1192
1193        // Render software mouse cursor when GPM is active
1194        // GPM can't draw its cursor on the alternate screen buffer used by TUI apps,
1195        // so we draw our own cursor at the tracked mouse position.
1196        // This must happen LAST in the render flow so we can read the already-rendered
1197        // cell content and invert it.
1198        if self.gpm_active {
1199            if let Some((col, row)) = self.mouse_cursor_position {
1200                use ratatui::style::Modifier;
1201
1202                // Only render if within screen bounds
1203                if col < size.width && row < size.height {
1204                    // Get the cell at this position and add REVERSED modifier to invert colors
1205                    let buf = frame.buffer_mut();
1206                    if let Some(cell) = buf.cell_mut((col, row)) {
1207                        cell.set_style(cell.style().add_modifier(Modifier::REVERSED));
1208                    }
1209                }
1210            }
1211        }
1212
1213        // When keyboard capture mode is active, dim all UI elements outside the terminal
1214        // to visually indicate that focus is exclusively on the terminal
1215        if self.keyboard_capture && self.terminal_mode {
1216            // Find the active split's content area
1217            let active_split = self.split_manager.active_split();
1218            let active_split_area = self
1219                .cached_layout
1220                .split_areas
1221                .iter()
1222                .find(|(split_id, _, _, _, _, _)| *split_id == active_split)
1223                .map(|(_, _, content_rect, _, _, _)| *content_rect);
1224
1225            if let Some(terminal_area) = active_split_area {
1226                self.apply_keyboard_capture_dimming(frame, terminal_area);
1227            }
1228        }
1229
1230        // Commit the active-split hardware cursor (deferred since
1231        // `render_content`) unless a popup has been drawn over that cell.
1232        // Ratatui draws the hardware caret on top of every cell, so a
1233        // popup cannot hide the cursor by painting cells — the only way
1234        // to hide it is to leave `Frame::cursor_position` as `None`, which
1235        // triggers `Terminal::hide_cursor` at the end of the draw.
1236        //
1237        // When a prompt is active the prompt renderer already placed the
1238        // caret on the prompt line via `frame.set_cursor_position`; don't
1239        // override it with the (now-irrelevant) buffer cursor.
1240        if let Some((cx, cy)) = pending_hardware_cursor {
1241            if self.prompt.is_none() && !self.cursor_obscured_by_overlay(cx, cy) {
1242                frame.set_cursor_position((cx, cy));
1243            }
1244        }
1245
1246        // Convert all colors for terminal capability (256/16 color fallback)
1247        crate::view::color_support::convert_buffer_colors(
1248            frame.buffer_mut(),
1249            self.color_capability,
1250        );
1251
1252        // Frame-buffer animations run last so they mutate the final paint.
1253        self.animations.apply_all(frame.buffer_mut());
1254    }
1255
1256    /// Compare the hardware cursor's screen position to the previous frame's
1257    /// and, if it moved by more than the "jump" threshold, start a
1258    /// `CursorJump` animation from the old to the new on-screen position.
1259    /// Successive jumps cancel the prior animation so trail effects don't
1260    /// pile up.
1261    ///
1262    /// Cross-split and cross-buffer transitions (focus change, tab switch)
1263    /// are also animated — the trail crosses pane separators on its way
1264    /// from one buffer's cursor cell to another's.
1265    ///
1266    /// The threshold is intentionally generous: arrow-key/typing moves
1267    /// (small `dx`/`dy`) must NOT trigger the animation, but search jumps,
1268    /// goto-line/definition, and pane switches (which always cross several
1269    /// rows or many columns) must.
1270    fn maybe_start_cursor_jump_animation(
1271        &mut self,
1272        current_pos: Option<(u16, u16)>,
1273        active_split: crate::model::event::LeafId,
1274    ) {
1275        // Honour the global animations toggle. Tests default to
1276        // `animations = false` so single-tick `render()` calls observe the
1277        // settled buffer instead of a mid-flight trail; users can also
1278        // disable animations entirely from config.
1279        if !self.config.editor.animations {
1280            self.previous_cursor_screen_pos = current_pos.map(|p| (p, active_split));
1281            return;
1282        }
1283
1284        let Some(current) = current_pos else {
1285            // Cursor is hidden this frame (e.g. prompt has focus). Reset the
1286            // tracker so the re-emerging cursor doesn't animate from a stale
1287            // spot when focus returns to a buffer.
1288            self.previous_cursor_screen_pos = None;
1289            return;
1290        };
1291
1292        let prev_entry = self.previous_cursor_screen_pos;
1293        // Update tracking unconditionally for the next frame.
1294        self.previous_cursor_screen_pos = Some((current, active_split));
1295
1296        let Some((prev, prev_split)) = prev_entry else {
1297            return;
1298        };
1299        if prev == current && prev_split == active_split {
1300            return;
1301        }
1302
1303        let dx = (current.0 as i32 - prev.0 as i32).abs();
1304        let dy = (current.1 as i32 - prev.1 as i32).abs();
1305        // Animate when the cursor crossed split panes, or when it made a
1306        // non-incremental move within the same pane: more than two rows
1307        // vertically, or at least ten columns horizontally. Small hops
1308        // (typing, arrow keys, word-jump, home/end on short lines) are
1309        // intentionally skipped.
1310        let crossed_panes = prev_split != active_split;
1311        let row_jump = dy > 2;
1312        let col_jump = dx >= 10;
1313        if !crossed_panes && !row_jump && !col_jump {
1314            return;
1315        }
1316
1317        // Cancel any prior cursor-jump animation so trails don't stack.
1318        if let Some(prev_anim) = self.cursor_jump_animation.take() {
1319            self.animations.cancel(prev_anim);
1320        }
1321
1322        let id = self.animations.start(
1323            // The bounding box is for runner bookkeeping only — CursorJump
1324            // paints at absolute screen coords and ignores `area`.
1325            ratatui::layout::Rect {
1326                x: prev.0.min(current.0),
1327                y: prev.1.min(current.1),
1328                width: dx as u16 + 1,
1329                height: dy as u16 + 1,
1330            },
1331            crate::view::animation::AnimationKind::CursorJump {
1332                from: prev,
1333                to: current,
1334                duration: std::time::Duration::from_millis(140),
1335                cursor_color: self.theme.cursor,
1336                bg_color: self.theme.editor_bg,
1337            },
1338        );
1339        self.cursor_jump_animation = Some(id);
1340    }
1341
1342    /// Returns true if `(x, y)` falls inside any popup-style overlay that
1343    /// was rendered this frame. Used to decide whether the hardware cursor
1344    /// should be shown or hidden so it does not bleed through a popup.
1345    fn cursor_obscured_by_overlay(&self, x: u16, y: u16) -> bool {
1346        let inside = |rect: ratatui::layout::Rect| -> bool {
1347            x >= rect.x
1348                && x < rect.x.saturating_add(rect.width)
1349                && y >= rect.y
1350                && y < rect.y.saturating_add(rect.height)
1351        };
1352
1353        if self
1354            .cached_layout
1355            .popup_areas
1356            .iter()
1357            .any(|entry| inside(entry.1))
1358        {
1359            return true;
1360        }
1361        if self
1362            .cached_layout
1363            .global_popup_areas
1364            .iter()
1365            .any(|entry| inside(entry.1))
1366        {
1367            return true;
1368        }
1369        if let Some((rect, _, _, _)) = self.cached_layout.suggestions_area {
1370            if inside(rect) {
1371                return true;
1372            }
1373        }
1374        if let Some(ref fb) = self.file_browser_layout {
1375            if inside(fb.popup_area) {
1376                return true;
1377            }
1378        }
1379        false
1380    }
1381
1382    /// Render the Quick Open hints line showing available mode prefixes
1383    fn render_quick_open_hints(
1384        frame: &mut Frame,
1385        area: ratatui::layout::Rect,
1386        theme: &crate::view::theme::Theme,
1387    ) {
1388        use ratatui::style::{Modifier, Style};
1389        use ratatui::text::{Line, Span};
1390        use ratatui::widgets::Paragraph;
1391        use rust_i18n::t;
1392
1393        let hints_style = Style::default()
1394            .fg(theme.line_number_fg)
1395            .bg(theme.suggestion_selected_bg)
1396            .add_modifier(Modifier::DIM);
1397        let hints_text = t!("quick_open.mode_hints");
1398        // Left-align with small margin
1399        let left_margin = 2;
1400        let hints_width = crate::primitives::display_width::str_width(&hints_text);
1401        let mut spans = Vec::new();
1402        spans.push(Span::styled(" ".repeat(left_margin), hints_style));
1403        spans.push(Span::styled(hints_text.to_string(), hints_style));
1404        let remaining = (area.width as usize).saturating_sub(left_margin + hints_width);
1405        spans.push(Span::styled(" ".repeat(remaining), hints_style));
1406
1407        let paragraph = Paragraph::new(Line::from(spans));
1408        frame.render_widget(paragraph, area);
1409    }
1410
1411    /// Apply dimming effect to UI elements outside the focused terminal area
1412    /// This visually indicates that keyboard capture mode is active
1413    fn apply_keyboard_capture_dimming(
1414        &self,
1415        frame: &mut Frame,
1416        terminal_area: ratatui::layout::Rect,
1417    ) {
1418        let size = frame.area();
1419        crate::view::dimming::apply_dimming_excluding(frame, size, Some(terminal_area));
1420    }
1421
1422    /// Render file browser or suggestions popup as overlay above the prompt line.
1423    /// Called after status bar + prompt so the popup draws on top of both.
1424    fn render_prompt_popups(
1425        &mut self,
1426        frame: &mut Frame,
1427        prompt_area: ratatui::layout::Rect,
1428        width: u16,
1429    ) {
1430        let Some(prompt) = &self.prompt else { return };
1431
1432        if matches!(
1433            prompt.prompt_type,
1434            PromptType::OpenFile | PromptType::SwitchProject | PromptType::SaveFileAs
1435        ) {
1436            let Some(file_open_state) = &self.file_open_state else {
1437                return;
1438            };
1439            let max_height = prompt_area.y.saturating_sub(1).min(20);
1440            let popup_area = ratatui::layout::Rect {
1441                x: 0,
1442                y: prompt_area.y.saturating_sub(max_height),
1443                width,
1444                height: max_height,
1445            };
1446            let keybindings = self.keybindings.read().unwrap();
1447            self.file_browser_layout = crate::view::ui::FileBrowserRenderer::render(
1448                frame,
1449                popup_area,
1450                file_open_state,
1451                &self.theme,
1452                &self.mouse_state.hover_target,
1453                Some(&*keybindings),
1454            );
1455            return;
1456        }
1457
1458        if prompt.suggestions.is_empty() {
1459            return;
1460        }
1461
1462        let suggestion_count = prompt.suggestions.len().min(10);
1463        let is_quick_open = prompt.prompt_type == crate::view::prompt::PromptType::QuickOpen;
1464        let hints_height: u16 = if is_quick_open { 1 } else { 0 };
1465        let height = suggestion_count as u16 + 2 + hints_height;
1466
1467        let suggestions_area = ratatui::layout::Rect {
1468            x: 0,
1469            y: prompt_area.y.saturating_sub(height),
1470            width,
1471            height: height - hints_height,
1472        };
1473
1474        frame.render_widget(ratatui::widgets::Clear, suggestions_area);
1475
1476        // Adjust the prompt's scroll position to keep the selected item
1477        // visible, scrolling the minimum amount required.
1478        if let Some(prompt) = self.prompt.as_mut() {
1479            prompt.ensure_selected_visible();
1480        }
1481        let Some(prompt) = &self.prompt else { return };
1482
1483        self.cached_layout.suggestions_area = SuggestionsRenderer::render_with_hover(
1484            frame,
1485            suggestions_area,
1486            prompt,
1487            &self.theme,
1488            self.mouse_state.hover_target.as_ref(),
1489        );
1490        if self.cached_layout.suggestions_area.is_some() {
1491            self.cached_layout.suggestions_outer_area = Some(suggestions_area);
1492        }
1493
1494        if is_quick_open {
1495            let hints_area = ratatui::layout::Rect {
1496                x: 0,
1497                y: prompt_area.y.saturating_sub(hints_height),
1498                width,
1499                height: hints_height,
1500            };
1501            frame.render_widget(ratatui::widgets::Clear, hints_area);
1502            Self::render_quick_open_hints(frame, hints_area, &self.theme);
1503        }
1504    }
1505
1506    /// Render hover highlights for interactive elements (separators, scrollbars)
1507    pub(super) fn render_hover_highlights(&self, frame: &mut Frame) {
1508        use ratatui::style::Style;
1509        use ratatui::text::Span;
1510        use ratatui::widgets::Paragraph;
1511
1512        match &self.mouse_state.hover_target {
1513            Some(HoverTarget::SplitSeparator(split_id, direction)) => {
1514                // Highlight the separator with hover color
1515                for (sid, dir, x, y, length) in &self.cached_layout.separator_areas {
1516                    if sid == split_id && dir == direction {
1517                        let hover_style = Style::default().fg(self.theme.split_separator_hover_fg);
1518                        match dir {
1519                            SplitDirection::Horizontal => {
1520                                let line_text = "─".repeat(*length as usize);
1521                                let paragraph =
1522                                    Paragraph::new(Span::styled(line_text, hover_style));
1523                                frame.render_widget(
1524                                    paragraph,
1525                                    ratatui::layout::Rect::new(*x, *y, *length, 1),
1526                                );
1527                            }
1528                            SplitDirection::Vertical => {
1529                                for offset in 0..*length {
1530                                    let paragraph = Paragraph::new(Span::styled("│", hover_style));
1531                                    frame.render_widget(
1532                                        paragraph,
1533                                        ratatui::layout::Rect::new(*x, y + offset, 1, 1),
1534                                    );
1535                                }
1536                            }
1537                        }
1538                    }
1539                }
1540            }
1541            Some(HoverTarget::ScrollbarThumb(split_id)) => {
1542                // Highlight scrollbar thumb
1543                for (sid, _buffer_id, _content_rect, scrollbar_rect, thumb_start, thumb_end) in
1544                    &self.cached_layout.split_areas
1545                {
1546                    if sid == split_id {
1547                        let hover_style = Style::default().bg(self.theme.scrollbar_thumb_hover_fg);
1548                        for row_offset in *thumb_start..*thumb_end {
1549                            let paragraph = Paragraph::new(Span::styled(" ", hover_style));
1550                            frame.render_widget(
1551                                paragraph,
1552                                ratatui::layout::Rect::new(
1553                                    scrollbar_rect.x,
1554                                    scrollbar_rect.y + row_offset as u16,
1555                                    1,
1556                                    1,
1557                                ),
1558                            );
1559                        }
1560                    }
1561                }
1562            }
1563            Some(HoverTarget::ScrollbarTrack(split_id, hovered_row)) => {
1564                // Highlight only the hovered cell on the scrollbar track
1565                for (sid, _buffer_id, _content_rect, scrollbar_rect, _thumb_start, _thumb_end) in
1566                    &self.cached_layout.split_areas
1567                {
1568                    if sid == split_id {
1569                        let track_hover_style =
1570                            Style::default().bg(self.theme.scrollbar_track_hover_fg);
1571                        let paragraph = Paragraph::new(Span::styled(" ", track_hover_style));
1572                        frame.render_widget(
1573                            paragraph,
1574                            ratatui::layout::Rect::new(
1575                                scrollbar_rect.x,
1576                                scrollbar_rect.y + hovered_row,
1577                                1,
1578                                1,
1579                            ),
1580                        );
1581                    }
1582                }
1583            }
1584            Some(HoverTarget::FileExplorerBorder) => {
1585                // Highlight the file explorer border for resize
1586                if let Some(explorer_area) = self.cached_layout.file_explorer_area {
1587                    let hover_style = Style::default().fg(self.theme.split_separator_hover_fg);
1588                    let border_x = explorer_area.x + explorer_area.width.saturating_sub(1);
1589                    for row_offset in 0..explorer_area.height {
1590                        let paragraph = Paragraph::new(Span::styled("│", hover_style));
1591                        frame.render_widget(
1592                            paragraph,
1593                            ratatui::layout::Rect::new(
1594                                border_x,
1595                                explorer_area.y + row_offset,
1596                                1,
1597                                1,
1598                            ),
1599                        );
1600                    }
1601                }
1602            }
1603            // Menu hover is handled by MenuRenderer
1604            _ => {}
1605        }
1606    }
1607
1608    /// Render the tab context menu
1609    fn render_tab_context_menu(&self, frame: &mut Frame, menu: &TabContextMenu) {
1610        use ratatui::style::Style;
1611        use ratatui::text::{Line, Span};
1612        use ratatui::widgets::{Block, Borders, Clear, Paragraph};
1613
1614        let items = super::types::TabContextMenuItem::all();
1615        let menu_width = 22u16; // "Close to the Right" + padding
1616        let menu_height = items.len() as u16 + 2; // items + borders
1617
1618        // Adjust position to stay within screen bounds
1619        let screen_width = frame.area().width;
1620        let screen_height = frame.area().height;
1621
1622        let menu_x = if menu.position.0 + menu_width > screen_width {
1623            screen_width.saturating_sub(menu_width)
1624        } else {
1625            menu.position.0
1626        };
1627
1628        let menu_y = if menu.position.1 + menu_height > screen_height {
1629            screen_height.saturating_sub(menu_height)
1630        } else {
1631            menu.position.1
1632        };
1633
1634        let area = ratatui::layout::Rect::new(menu_x, menu_y, menu_width, menu_height);
1635
1636        // Clear the area first
1637        frame.render_widget(Clear, area);
1638
1639        // Build the menu lines
1640        let mut lines = Vec::new();
1641        for (idx, item) in items.iter().enumerate() {
1642            let is_highlighted = idx == menu.highlighted;
1643
1644            let style = if is_highlighted {
1645                Style::default()
1646                    .fg(self.theme.menu_highlight_fg)
1647                    .bg(self.theme.menu_highlight_bg)
1648            } else {
1649                Style::default()
1650                    .fg(self.theme.menu_dropdown_fg)
1651                    .bg(self.theme.menu_dropdown_bg)
1652            };
1653
1654            // Pad the label to fill the menu width
1655            let label = item.label();
1656            let content_width = (menu_width as usize).saturating_sub(2); // -2 for borders
1657            let padded_label = format!(" {:<width$}", label, width = content_width - 1);
1658
1659            lines.push(Line::from(vec![Span::styled(padded_label, style)]));
1660        }
1661
1662        let block = Block::default()
1663            .borders(Borders::ALL)
1664            .border_style(Style::default().fg(self.theme.menu_border_fg))
1665            .style(Style::default().bg(self.theme.menu_dropdown_bg));
1666
1667        let paragraph = Paragraph::new(lines).block(block);
1668        frame.render_widget(paragraph, area);
1669    }
1670
1671    /// Render the file explorer context menu
1672    fn render_file_explorer_context_menu(
1673        &self,
1674        frame: &mut Frame,
1675        menu: &super::types::FileExplorerContextMenu,
1676    ) {
1677        use ratatui::style::Style;
1678        use ratatui::text::{Line, Span};
1679        use ratatui::widgets::{Block, Borders, Clear, Paragraph};
1680
1681        let items = menu.items();
1682        let menu_width = super::types::FILE_EXPLORER_CONTEXT_MENU_WIDTH;
1683        let menu_height = menu.height();
1684        let (menu_x, menu_y) = menu.clamped_position(frame.area().width, frame.area().height);
1685
1686        let area = ratatui::layout::Rect::new(menu_x, menu_y, menu_width, menu_height);
1687
1688        frame.render_widget(Clear, area);
1689
1690        let mut lines = Vec::new();
1691        for (idx, item) in items.iter().enumerate() {
1692            let is_highlighted = idx == menu.highlighted;
1693
1694            let style = if is_highlighted {
1695                Style::default()
1696                    .fg(self.theme.menu_highlight_fg)
1697                    .bg(self.theme.menu_highlight_bg)
1698            } else {
1699                Style::default()
1700                    .fg(self.theme.menu_dropdown_fg)
1701                    .bg(self.theme.menu_dropdown_bg)
1702            };
1703
1704            let label = item.label();
1705            let content_width = (menu_width as usize).saturating_sub(2);
1706            let padded_label = format!(" {:<width$}", label, width = content_width - 1);
1707
1708            lines.push(Line::from(vec![Span::styled(padded_label, style)]));
1709        }
1710
1711        let block = Block::default()
1712            .borders(Borders::ALL)
1713            .border_style(Style::default().fg(self.theme.menu_border_fg))
1714            .style(Style::default().bg(self.theme.menu_dropdown_bg));
1715
1716        let paragraph = Paragraph::new(lines).block(block);
1717        frame.render_widget(paragraph, area);
1718    }
1719
1720    /// Render the tab drag drop zone overlay
1721    fn render_tab_drop_zone(&self, frame: &mut Frame, drag_state: &super::types::TabDragState) {
1722        use ratatui::style::Modifier;
1723
1724        let Some(ref drop_zone) = drag_state.drop_zone else {
1725            return;
1726        };
1727
1728        let split_id = drop_zone.split_id();
1729
1730        // Find the content area for the target split
1731        let split_area = self
1732            .cached_layout
1733            .split_areas
1734            .iter()
1735            .find(|(sid, _, _, _, _, _)| *sid == split_id)
1736            .map(|(_, _, content_rect, _, _, _)| *content_rect);
1737
1738        let Some(content_rect) = split_area else {
1739            return;
1740        };
1741
1742        // Determine the highlight area based on drop zone type
1743        use super::types::TabDropZone;
1744
1745        let highlight_area = match drop_zone {
1746            TabDropZone::TabBar(_, _) | TabDropZone::SplitCenter(_) => {
1747                // For tab bar and center drops, highlight the entire split area
1748                // This indicates the tab will be added to this split's tab bar
1749                content_rect
1750            }
1751            TabDropZone::SplitLeft(_) => {
1752                // Left 50% of the split (matches the actual split size created)
1753                let width = (content_rect.width / 2).max(3);
1754                ratatui::layout::Rect::new(
1755                    content_rect.x,
1756                    content_rect.y,
1757                    width,
1758                    content_rect.height,
1759                )
1760            }
1761            TabDropZone::SplitRight(_) => {
1762                // Right 50% of the split (matches the actual split size created)
1763                let width = (content_rect.width / 2).max(3);
1764                let x = content_rect.x + content_rect.width - width;
1765                ratatui::layout::Rect::new(x, content_rect.y, width, content_rect.height)
1766            }
1767            TabDropZone::SplitTop(_) => {
1768                // Top 50% of the split (matches the actual split size created)
1769                let height = (content_rect.height / 2).max(2);
1770                ratatui::layout::Rect::new(
1771                    content_rect.x,
1772                    content_rect.y,
1773                    content_rect.width,
1774                    height,
1775                )
1776            }
1777            TabDropZone::SplitBottom(_) => {
1778                // Bottom 50% of the split (matches the actual split size created)
1779                let height = (content_rect.height / 2).max(2);
1780                let y = content_rect.y + content_rect.height - height;
1781                ratatui::layout::Rect::new(content_rect.x, y, content_rect.width, height)
1782            }
1783        };
1784
1785        // Draw the overlay with the drop zone color
1786        // We apply a semi-transparent effect by modifying existing cells
1787        let buf = frame.buffer_mut();
1788        let drop_zone_bg = self.theme.tab_drop_zone_bg;
1789        let drop_zone_border = self.theme.tab_drop_zone_border;
1790
1791        // Fill the highlight area with a semi-transparent overlay
1792        for y in highlight_area.y..highlight_area.y + highlight_area.height {
1793            for x in highlight_area.x..highlight_area.x + highlight_area.width {
1794                if let Some(cell) = buf.cell_mut((x, y)) {
1795                    // Blend the drop zone color with the existing background
1796                    // For a simple effect, we just set the background
1797                    cell.set_bg(drop_zone_bg);
1798
1799                    // Draw border on edges
1800                    let is_border = x == highlight_area.x
1801                        || x == highlight_area.x + highlight_area.width - 1
1802                        || y == highlight_area.y
1803                        || y == highlight_area.y + highlight_area.height - 1;
1804
1805                    if is_border {
1806                        cell.set_fg(drop_zone_border);
1807                        cell.set_style(cell.style().add_modifier(Modifier::BOLD));
1808                    }
1809                }
1810            }
1811        }
1812
1813        // Draw a border indicator based on the zone type
1814        match drop_zone {
1815            TabDropZone::SplitLeft(_) => {
1816                // Draw vertical indicator on left edge
1817                for y in highlight_area.y..highlight_area.y + highlight_area.height {
1818                    if let Some(cell) = buf.cell_mut((highlight_area.x, y)) {
1819                        cell.set_symbol("▌");
1820                        cell.set_fg(drop_zone_border);
1821                    }
1822                }
1823            }
1824            TabDropZone::SplitRight(_) => {
1825                // Draw vertical indicator on right edge
1826                let x = highlight_area.x + highlight_area.width - 1;
1827                for y in highlight_area.y..highlight_area.y + highlight_area.height {
1828                    if let Some(cell) = buf.cell_mut((x, y)) {
1829                        cell.set_symbol("▐");
1830                        cell.set_fg(drop_zone_border);
1831                    }
1832                }
1833            }
1834            TabDropZone::SplitTop(_) => {
1835                // Draw horizontal indicator on top edge
1836                for x in highlight_area.x..highlight_area.x + highlight_area.width {
1837                    if let Some(cell) = buf.cell_mut((x, highlight_area.y)) {
1838                        cell.set_symbol("▀");
1839                        cell.set_fg(drop_zone_border);
1840                    }
1841                }
1842            }
1843            TabDropZone::SplitBottom(_) => {
1844                // Draw horizontal indicator on bottom edge
1845                let y = highlight_area.y + highlight_area.height - 1;
1846                for x in highlight_area.x..highlight_area.x + highlight_area.width {
1847                    if let Some(cell) = buf.cell_mut((x, y)) {
1848                        cell.set_symbol("▄");
1849                        cell.set_fg(drop_zone_border);
1850                    }
1851                }
1852            }
1853            TabDropZone::SplitCenter(_) | TabDropZone::TabBar(_, _) => {
1854                // For center and tab bar, the filled background is sufficient
1855            }
1856        }
1857    }
1858
1859    /// Recompute the view_line_mappings layout without drawing.
1860    /// Used during macro replay so that visual-line movements (MoveLineEnd,
1861    /// MoveUp, MoveDown on wrapped lines) see correct, up-to-date layout
1862    /// information between each replayed action.
1863    pub fn recompute_layout(&mut self, width: u16, height: u16) {
1864        let size = ratatui::layout::Rect::new(0, 0, width, height);
1865
1866        // Replicate the pre-render sync steps from render()
1867        let active_split = self.split_manager.active_split();
1868        self.pre_sync_ensure_visible(active_split);
1869        self.sync_scroll_groups();
1870
1871        // Replicate the layout computation that produces editor_content_area.
1872        // Same constraints as render(): [menu_bar, main_content, status_bar, search_options, prompt_line]
1873        let constraints = vec![
1874            Constraint::Length(if self.menu_bar_visible { 1 } else { 0 }),
1875            Constraint::Min(0),
1876            Constraint::Length(if self.status_bar_visible { 1 } else { 0 }), // status bar
1877            Constraint::Length(0), // search options (doesn't matter for layout)
1878            Constraint::Length(if self.prompt_line_visible { 1 } else { 0 }), // prompt line
1879        ];
1880        let main_chunks = Layout::default()
1881            .direction(Direction::Vertical)
1882            .constraints(constraints)
1883            .split(size);
1884        let main_content_area = main_chunks[1];
1885
1886        // Compute editor_content_area (with file explorer split if visible)
1887        let file_explorer_should_show = self.file_explorer_visible
1888            && (self.file_explorer.is_some() || self.file_explorer_sync_in_progress);
1889        let editor_content_area = if file_explorer_should_show {
1890            let explorer_cols = self.file_explorer_width.to_cols(main_content_area.width);
1891            let horizontal_chunks = Layout::default()
1892                .direction(Direction::Horizontal)
1893                .constraints([Constraint::Length(explorer_cols), Constraint::Min(0)])
1894                .split(main_content_area);
1895            horizontal_chunks[1]
1896        } else {
1897            main_content_area
1898        };
1899
1900        // Compute layout for all visible splits and update cached view_line_mappings
1901        let view_line_mappings = SplitRenderer::compute_content_layout(
1902            editor_content_area,
1903            &self.split_manager,
1904            &mut self.buffers,
1905            &mut self.split_view_states,
1906            &self.theme,
1907            false, // lsp_waiting — not relevant for layout
1908            self.config.editor.estimated_line_length,
1909            self.config.editor.highlight_context_bytes,
1910            self.config.editor.relative_line_numbers,
1911            self.config.editor.use_terminal_bg,
1912            self.session_mode || !self.software_cursor_only,
1913            self.software_cursor_only,
1914            self.tab_bar_visible,
1915            self.config.editor.show_vertical_scrollbar,
1916            self.config.editor.show_horizontal_scrollbar,
1917            self.config.editor.diagnostics_inline_text,
1918            self.config.editor.show_tilde,
1919        );
1920
1921        self.cached_layout.view_line_mappings = view_line_mappings;
1922    }
1923
1924    /// Clear the search history
1925    /// Used primarily for testing to ensure test isolation
1926    pub fn clear_search_history(&mut self) {
1927        if let Some(history) = self.prompt_histories.get_mut("search") {
1928            history.clear();
1929        }
1930    }
1931
1932    /// Emit an OSC 2 escape sequence to set the host terminal's window/tab
1933    /// title based on the active buffer's display name. Deduplicated against
1934    /// the last title we wrote so we don't spam stdout every frame.
1935    ///
1936    /// Gated by `editor.set_window_title` (default on). Terminals that
1937    /// don't implement OSC 2 silently drop the sequence.
1938    fn update_terminal_title(&mut self, display_name: &str) {
1939        if !self.config.editor.set_window_title {
1940            return;
1941        }
1942        let new_title = format!("{} \u{2014} Fresh", display_name);
1943        if self.last_window_title.as_deref() == Some(new_title.as_str()) {
1944            return;
1945        }
1946        crate::services::terminal_title::write_terminal_title(&new_title);
1947        self.last_window_title = Some(new_title);
1948    }
1949
1950    /// Save all prompt histories to disk
1951    /// Called on shutdown to persist history across sessions
1952    pub fn save_histories(&self) {
1953        // Ensure data directory exists
1954        if let Err(e) = self
1955            .authority
1956            .filesystem
1957            .create_dir_all(&self.dir_context.data_dir)
1958        {
1959            tracing::warn!("Failed to create data directory: {}", e);
1960            return;
1961        }
1962
1963        // Save all prompt histories
1964        for (key, history) in &self.prompt_histories {
1965            let path = self.dir_context.prompt_history_path(key);
1966            if let Err(e) = history.save_to_file(&path) {
1967                tracing::warn!("Failed to save {} history: {}", key, e);
1968            } else {
1969                tracing::debug!("Saved {} history to {:?}", key, path);
1970            }
1971        }
1972    }
1973}