1use super::lsp_status::compose_lsp_status;
2use super::*;
3use rust_i18n::t;
4
5impl Editor {
6 pub fn render(&mut self, frame: &mut Frame) {
8 let _span = tracing::info_span!("render").entered();
9 let size = frame.area();
10
11 self.cached_layout.last_frame_width = size.width;
13 self.cached_layout.last_frame_height = size.height;
14
15 self.cached_layout.reset_cell_theme_map();
17
18 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 {
31 let _span = tracing::info_span!("sync_scroll_groups").entered();
32 self.sync_scroll_groups();
33 }
34
35 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 }
79 }
80 }
81 }
82 }
83
84 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 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 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 let constraints = vec![
127 Constraint::Length(if self.menu_bar_visible { 1 } else { 0 }), Constraint::Min(0), Constraint::Length(
130 if !self.status_bar_visible || has_suggestions || has_file_browser {
131 0
132 } else {
133 1
134 },
135 ), Constraint::Length(if show_search_options { 1 } else { 0 }), Constraint::Length(if self.prompt_line_visible || self.prompt.is_some() {
138 1
139 } else {
140 0
141 }), ];
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 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 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 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), Constraint::Percentage(editor_percent), ])
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 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 if let Some(ref mut explorer) = self.file_explorer {
193 let is_focused = self.key_context == KeyContext::FileExplorer;
194
195 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 } else {
229 self.cached_layout.file_explorer_area = None;
231 editor_content_area = main_content_area;
232 }
233
234 if self.plugin_manager.is_active() {
241 let hooks_start = std::time::Instant::now();
242 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 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 self.plugin_manager.run_hook(
257 "render_start",
258 crate::services::plugins::hooks::HookArgs::RenderStart { buffer_id },
259 );
260
261 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 if let Some(vs) = self.split_view_states.get_mut(&split_id) {
301 vs.view_transform_stale = false;
302 }
303
304 let visible_count = split_area.height as usize;
306 let top_byte = viewport_top_byte;
307
308 let seen_byte_ranges = self.seen_byte_ranges.entry(buffer_id).or_default();
310
311 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 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 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 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 self.flush_pending_grammars();
385 }
386
387 let lsp_waiting = !self.pending_completion_requests.is_empty()
389 || self.pending_goto_definition_request.is_some();
390
391 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 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 let hovered_close_split = match &self.mouse_state.hover_target {
413 Some(HoverTarget::CloseSplitButton(split_id)) => Some(*split_id),
414 _ => None,
415 };
416
417 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 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 let (changed, previous) = match self.previous_viewports.get(split_id) {
488 Some(previous) => (*previous != current, Some(*previous)),
489 None => (false, None), };
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 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 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 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 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 self.render_hover_highlights(frame);
566
567 self.cached_layout.suggestions_area = None;
569 self.file_browser_layout = None;
570
571 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 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 ¤t_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(); let chord_state_cloned = self.chord_state.clone(); let update_available = self.latest_version().map(|v| v.to_string());
620
621 if self.status_bar_visible && !has_suggestions && !has_file_browser {
623 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 == ¤t_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 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 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 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 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 if show_search_options {
732 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 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 if let Some(prompt) = &prompt {
775 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 self.render_prompt_popups(frame, main_chunks[prompt_line_idx], size.width);
810
811 let theme_clone = self.theme.clone();
814 let hover_target = self.mouse_state.hover_target.clone();
815
816 self.cached_layout.popup_areas.clear();
818
819 let popup_info: Vec<_> = {
821 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 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 let primary_cursor =
847 primary_cursor.unwrap_or_else(|| crate::model::cursor::Cursor::new(0));
848
849 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 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 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 state
891 .popups
892 .all()
893 .iter()
894 .enumerate()
895 .map(|(popup_idx, popup)| {
896 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 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 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 self.cached_layout.popup_areas = popup_info.clone();
961
962 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 self.update_menu_context();
980
981 let settings_visible = self
984 .settings_state
985 .as_ref()
986 .map(|s| s.visible)
987 .unwrap_or(false);
988 if settings_visible {
989 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 if let Some(ref wizard) = self.calibration_wizard {
1007 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 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 if let Some(ref debug) = self.event_debug {
1030 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 if let Some(ref menu) = self.tab_context_menu {
1053 self.render_tab_context_menu(frame, menu);
1054 }
1055
1056 self.record_non_editor_theme_regions();
1058
1059 self.render_theme_info_popup(frame);
1061
1062 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 if self.gpm_active {
1075 if let Some((col, row)) = self.mouse_cursor_position {
1076 use ratatui::style::Modifier;
1077
1078 if col < size.width && row < size.height {
1080 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 if self.keyboard_capture && self.terminal_mode {
1092 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 crate::view::color_support::convert_buffer_colors(
1108 frame.buffer_mut(),
1109 self.color_capability,
1110 );
1111 }
1112
1113 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 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 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 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 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 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 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 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 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 _ => {}
1326 }
1327 }
1328
1329 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; let menu_height = items.len() as u16 + 2; 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 frame.render_widget(Clear, area);
1359
1360 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 let label = item.label();
1377 let content_width = (menu_width as usize).saturating_sub(2); 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 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 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 use super::types::TabDropZone;
1416
1417 let highlight_area = match drop_zone {
1418 TabDropZone::TabBar(_, _) | TabDropZone::SplitCenter(_) => {
1419 content_rect
1422 }
1423 TabDropZone::SplitLeft(_) => {
1424 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 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 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 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 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 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 cell.set_bg(drop_zone_bg);
1470
1471 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 match drop_zone {
1487 TabDropZone::SplitLeft(_) => {
1488 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 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 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 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 }
1528 }
1529 }
1530
1531 pub fn recompute_layout(&mut self, width: u16, height: u16) {
1536 let size = ratatui::layout::Rect::new(0, 0, width, height);
1537
1538 let active_split = self.split_manager.active_split();
1540 self.pre_sync_ensure_visible(active_split);
1541 self.sync_scroll_groups();
1542
1543 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 }), Constraint::Length(0), Constraint::Length(if self.prompt_line_visible { 1 } else { 0 }), ];
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 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 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, 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 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 pub fn save_histories(&self) {
1611 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 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}