Skip to main content

fresh/app/
render.rs

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