1use super::lsp_status::compose_lsp_status;
2use super::*;
3
4impl Editor {
5 pub fn render(&mut self, frame: &mut Frame) {
7 let _span = tracing::info_span!("render").entered();
8 let size = frame.area();
9
10 self.cached_layout.last_frame_width = size.width;
12 self.cached_layout.last_frame_height = size.height;
13
14 self.cached_layout.reset_cell_theme_map();
16
17 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 {
30 let _span = tracing::info_span!("sync_scroll_groups").entered();
31 self.sync_scroll_groups();
32 }
33
34 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 }
78 }
79 }
80 }
81 }
82
83 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 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 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 let constraints = vec![
126 Constraint::Length(if self.menu_bar_visible { 1 } else { 0 }), Constraint::Min(0), Constraint::Length(
129 if !self.status_bar_visible || has_suggestions || has_file_browser {
130 0
131 } else {
132 1
133 },
134 ), Constraint::Length(if show_search_options { 1 } else { 0 }), Constraint::Length(if self.prompt_line_visible || self.prompt.is_some() {
137 1
138 } else {
139 0
140 }), ];
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 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 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 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), Constraint::Percentage(editor_percent), ])
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 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 if let Some(ref mut explorer) = self.file_explorer {
192 let is_focused = self.key_context == KeyContext::FileExplorer;
193
194 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 } else {
228 self.cached_layout.file_explorer_area = None;
230 editor_content_area = main_content_area;
231 }
232
233 if self.plugin_manager.is_active() {
240 let hooks_start = std::time::Instant::now();
241 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 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 self.plugin_manager.run_hook(
256 "render_start",
257 crate::services::plugins::hooks::HookArgs::RenderStart { buffer_id },
258 );
259
260 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 if let Some(vs) = self.split_view_states.get_mut(&split_id) {
300 vs.view_transform_stale = false;
301 }
302
303 let visible_count = split_area.height as usize;
305 let top_byte = viewport_top_byte;
306
307 let seen_byte_ranges = self.seen_byte_ranges.entry(buffer_id).or_default();
309
310 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 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 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 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 self.flush_pending_grammars();
384 }
385
386 let lsp_waiting = !self.pending_completion_requests.is_empty()
388 || self.pending_goto_definition_request.is_some();
389
390 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 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 let hovered_close_split = match &self.mouse_state.hover_target {
412 Some(HoverTarget::CloseSplitButton(split_id)) => Some(*split_id),
413 _ => None,
414 };
415
416 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 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 let (changed, previous) = match self.previous_viewports.get(split_id) {
487 Some(previous) => (*previous != current, Some(*previous)),
488 None => (false, None), };
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 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 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 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 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 self.render_hover_highlights(frame);
565
566 self.cached_layout.suggestions_area = None;
568 self.file_browser_layout = None;
569
570 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 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 ¤t_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(); let chord_state_cloned = self.chord_state.clone(); let update_available = self.latest_version().map(|v| v.to_string());
619
620 if self.status_bar_visible && !has_suggestions && !has_file_browser {
622 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 == ¤t_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 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 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 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 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 if show_search_options {
731 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 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 if let Some(prompt) = &prompt {
774 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 self.render_prompt_popups(frame, main_chunks[prompt_line_idx], size.width);
809
810 let theme_clone = self.theme.clone();
813 let hover_target = self.mouse_state.hover_target.clone();
814
815 self.cached_layout.popup_areas.clear();
817
818 let popup_info: Vec<_> = {
820 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 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 let primary_cursor =
846 primary_cursor.unwrap_or_else(|| crate::model::cursor::Cursor::new(0));
847
848 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 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 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 state
890 .popups
891 .all()
892 .iter()
893 .enumerate()
894 .map(|(popup_idx, popup)| {
895 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 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 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 self.cached_layout.popup_areas = popup_info.clone();
960
961 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 self.update_menu_context();
979
980 let settings_visible = self
983 .settings_state
984 .as_ref()
985 .map(|s| s.visible)
986 .unwrap_or(false);
987 if settings_visible {
988 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 if let Some(ref wizard) = self.calibration_wizard {
1006 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 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 if let Some(ref debug) = self.event_debug {
1029 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 if let Some(ref menu) = self.tab_context_menu {
1052 self.render_tab_context_menu(frame, menu);
1053 }
1054
1055 self.record_non_editor_theme_regions();
1057
1058 self.render_theme_info_popup(frame);
1060
1061 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 if self.gpm_active {
1074 if let Some((col, row)) = self.mouse_cursor_position {
1075 use ratatui::style::Modifier;
1076
1077 if col < size.width && row < size.height {
1079 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 if self.keyboard_capture && self.terminal_mode {
1091 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 crate::view::color_support::convert_buffer_colors(
1107 frame.buffer_mut(),
1108 self.color_capability,
1109 );
1110 }
1111
1112 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 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 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 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 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 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 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 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 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 _ => {}
1325 }
1326 }
1327
1328 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; let menu_height = items.len() as u16 + 2; 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 frame.render_widget(Clear, area);
1358
1359 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 let label = item.label();
1376 let content_width = (menu_width as usize).saturating_sub(2); 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 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 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 use super::types::TabDropZone;
1415
1416 let highlight_area = match drop_zone {
1417 TabDropZone::TabBar(_, _) | TabDropZone::SplitCenter(_) => {
1418 content_rect
1421 }
1422 TabDropZone::SplitLeft(_) => {
1423 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 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 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 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 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 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 cell.set_bg(drop_zone_bg);
1469
1470 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 match drop_zone {
1486 TabDropZone::SplitLeft(_) => {
1487 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 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 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 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 }
1527 }
1528 }
1529
1530 pub fn recompute_layout(&mut self, width: u16, height: u16) {
1535 let size = ratatui::layout::Rect::new(0, 0, width, height);
1536
1537 let active_split = self.split_manager.active_split();
1539 self.pre_sync_ensure_visible(active_split);
1540 self.sync_scroll_groups();
1541
1542 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 }), Constraint::Length(0), Constraint::Length(if self.prompt_line_visible { 1 } else { 0 }), ];
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 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 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, 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 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 pub fn save_histories(&self) {
1610 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 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}