Skip to main content

fresh/app/
render.rs

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