1use std::collections::HashMap;
4use std::path::Path;
5
6use crate::app::WarningLevel;
7use crate::config::{StatusBarConfig, StatusBarElement};
8use crate::primitives::display_width::{char_width, str_width};
9use crate::state::EditorState;
10use crate::view::prompt::Prompt;
11use chrono::Timelike;
12use ratatui::layout::Rect;
13use ratatui::style::{Modifier, Style};
14use ratatui::text::{Line, Span};
15use ratatui::widgets::Paragraph;
16use ratatui::Frame;
17use rust_i18n::t;
18
19const SSH_PREFIX: &str = "[SSH:";
23const SSH_PREFIX_TERMINATOR: &str = "] ";
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27enum ElementKind {
28 Normal,
30 LineEnding,
32 Encoding,
34 Language,
36 Lsp,
38 WarningBadge,
40 Update,
42 Palette,
44 Messages,
46 RemoteDisconnected,
48 Clock,
50 RemoteIndicator(RemoteIndicatorState),
52 WorkspaceTrust(crate::services::workspace_trust::TrustLevel),
55 Custom,
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
73pub enum RemoteIndicatorState {
74 #[default]
76 Local,
77 Connecting,
82 Connected,
84 FailedAttach,
88 Disconnected,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
103#[serde(tag = "kind", rename_all = "snake_case")]
104pub enum RemoteIndicatorOverride {
105 Local,
108 Connecting {
111 #[serde(default)]
112 label: Option<String>,
113 },
114 Connected {
117 #[serde(default)]
118 label: Option<String>,
119 },
120 FailedAttach {
123 #[serde(default)]
124 error: Option<String>,
125 },
126 Disconnected {
129 #[serde(default)]
130 label: Option<String>,
131 },
132}
133
134impl RemoteIndicatorOverride {
135 pub fn state(&self) -> RemoteIndicatorState {
137 match self {
138 Self::Local => RemoteIndicatorState::Local,
139 Self::Connecting { .. } => RemoteIndicatorState::Connecting,
140 Self::Connected { .. } => RemoteIndicatorState::Connected,
141 Self::FailedAttach { .. } => RemoteIndicatorState::FailedAttach,
142 Self::Disconnected { .. } => RemoteIndicatorState::Disconnected,
143 }
144 }
145
146 pub fn label(&self) -> String {
150 match self {
151 Self::Local => "Local".to_string(),
152 Self::Connecting { label } => match label {
153 Some(s) if !s.is_empty() => format!("⠿ {}", s),
154 _ => "⠿ Connecting".to_string(),
155 },
156 Self::Connected { label } => label
157 .as_deref()
158 .filter(|s| !s.is_empty())
159 .unwrap_or("Connected")
160 .to_string(),
161 Self::FailedAttach { error } => match error {
162 Some(s) if !s.is_empty() => format!("Attach failed: {}", s),
163 _ => "Attach failed".to_string(),
164 },
165 Self::Disconnected { label } => match label {
166 Some(s) if !s.is_empty() => format!("{} (Disconnected)", s),
167 _ => "Disconnected".to_string(),
168 },
169 }
170 }
171}
172
173struct RenderedElement {
175 text: String,
176 kind: ElementKind,
177 token_key: Option<String>,
182}
183
184#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
200pub enum LspIndicatorState {
201 #[default]
202 None,
203 On,
204 Off,
205 OffDismissed,
206 Error,
207}
208
209pub struct StatusBarContext<'a> {
211 pub state: &'a mut EditorState,
212 pub cursors: &'a crate::model::cursor::Cursors,
213 pub status_message: &'a Option<String>,
214 pub plugin_status_message: &'a Option<String>,
215 pub lsp_status: &'a str,
216 pub lsp_indicator_state: LspIndicatorState,
221 pub theme: &'a crate::view::theme::Theme,
222 pub display_name: &'a str,
223 pub keybindings: &'a crate::input::keybindings::KeybindingResolver,
224 pub chord_state: &'a [(crossterm::event::KeyCode, crossterm::event::KeyModifiers)],
225 pub update_available: Option<&'a str>,
226 pub warning_level: WarningLevel,
227 pub general_warning_count: usize,
228 pub hover: StatusBarHover,
229 pub remote_connection: Option<&'a str>,
230 pub session_name: Option<&'a str>,
231 pub read_only: bool,
232 pub remote_state_override: Option<&'a RemoteIndicatorOverride>,
239 pub is_synthetic_placeholder: bool,
245 pub remote_indicator_on_bar: bool,
254 pub dynamic_status_bar_elements: HashMap<String, String>,
258 pub workspace_trust_level: crate::services::workspace_trust::TrustLevel,
262}
263
264#[derive(Debug, Clone, Default)]
266pub struct StatusBarLayout {
267 pub lsp_indicator: Option<(u16, u16, u16)>,
269 pub warning_badge: Option<(u16, u16, u16)>,
271 pub line_ending_indicator: Option<(u16, u16, u16)>,
273 pub encoding_indicator: Option<(u16, u16, u16)>,
275 pub language_indicator: Option<(u16, u16, u16)>,
277 pub message_area: Option<(u16, u16, u16)>,
279 pub remote_indicator: Option<(u16, u16, u16)>,
282 pub trust_indicator: Option<(u16, u16, u16)>,
285 pub plugin_token_areas: std::collections::HashMap<String, (u16, u16, u16)>,
297}
298
299#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
301pub enum StatusBarHover {
302 #[default]
303 None,
304 LspIndicator,
306 WarningBadge,
308 LineEndingIndicator,
310 EncodingIndicator,
312 LanguageIndicator,
314 MessageArea,
316 RemoteIndicator,
318 WorkspaceTrust,
320}
321
322#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
324pub enum SearchOptionsHover {
325 #[default]
326 None,
327 CaseSensitive,
328 WholeWord,
329 Regex,
330 ConfirmEach,
331}
332
333#[derive(Debug, Clone, Default)]
335pub struct SearchOptionsLayout {
336 pub row: u16,
338 pub case_sensitive: Option<(u16, u16)>,
340 pub whole_word: Option<(u16, u16)>,
342 pub regex: Option<(u16, u16)>,
344 pub confirm_each: Option<(u16, u16)>,
346}
347
348impl SearchOptionsLayout {
349 pub fn checkbox_at(&self, x: u16, y: u16) -> Option<SearchOptionsHover> {
351 if y != self.row {
352 return None;
353 }
354
355 if let Some((start, end)) = self.case_sensitive {
356 if x >= start && x < end {
357 return Some(SearchOptionsHover::CaseSensitive);
358 }
359 }
360 if let Some((start, end)) = self.whole_word {
361 if x >= start && x < end {
362 return Some(SearchOptionsHover::WholeWord);
363 }
364 }
365 if let Some((start, end)) = self.regex {
366 if x >= start && x < end {
367 return Some(SearchOptionsHover::Regex);
368 }
369 }
370 if let Some((start, end)) = self.confirm_each {
371 if x >= start && x < end {
372 return Some(SearchOptionsHover::ConfirmEach);
373 }
374 }
375 None
376 }
377}
378
379#[derive(Debug, Clone)]
381pub struct TruncatedPath {
382 pub prefix: String,
384 pub truncated: bool,
386 pub suffix: String,
388 pub sep: char,
391}
392
393impl TruncatedPath {
394 pub fn to_string_plain(&self) -> String {
396 if self.truncated {
397 format!("{}{}[...]{}", self.prefix, self.sep, self.suffix)
398 } else {
399 format!("{}{}", self.prefix, self.suffix)
400 }
401 }
402
403 pub fn display_len(&self) -> usize {
406 if self.truncated {
407 self.prefix.len() + self.sep.len_utf8() + "[...]".len() + self.suffix.len()
408 } else {
409 self.prefix.len() + self.suffix.len()
410 }
411 }
412}
413
414fn path_display_sep(path_str: &str) -> char {
418 if path_str.contains('\\') {
419 '\\'
420 } else {
421 '/'
422 }
423}
424
425pub fn truncate_path(path: &Path, max_len: usize) -> TruncatedPath {
437 let path_str = path.to_string_lossy();
438 let sep = path_display_sep(&path_str);
443
444 if path_str.len() <= max_len {
446 return TruncatedPath {
447 prefix: String::new(),
448 truncated: false,
449 suffix: path_str.to_string(),
450 sep,
451 };
452 }
453
454 let components: Vec<&str> = path_str
455 .split(['/', '\\'])
456 .filter(|s| !s.is_empty())
457 .collect();
458
459 if components.is_empty() {
460 return TruncatedPath {
461 prefix: sep.to_string(),
462 truncated: false,
463 suffix: String::new(),
464 sep,
465 };
466 }
467
468 let leading_sep = path_str.starts_with('/') || path_str.starts_with('\\');
473 let is_drive = |c: &str| {
474 let b = c.as_bytes();
475 b.len() == 2 && b[1] == b':' && b[0].is_ascii_alphabetic()
476 };
477 let prefix_count = if !leading_sep && is_drive(components[0]) {
478 2
479 } else {
480 1
481 }
482 .min(components.len());
483 let sep_str = sep.to_string();
484 let prefix = {
485 let joined = components[..prefix_count].join(&sep_str);
486 if leading_sep {
487 format!("{}{}", sep, joined)
488 } else {
489 joined
490 }
491 };
492
493 let ellipsis_len = sep.len_utf8() + "[...]".len();
495
496 let available_for_suffix = max_len.saturating_sub(prefix.len() + ellipsis_len);
498
499 if available_for_suffix < 5 || components.len() <= prefix_count {
500 let truncated_path = if path_str.len() > max_len.saturating_sub(3) {
506 let cut = path_str.floor_char_boundary(max_len.saturating_sub(3));
507 format!("{}...", &path_str[..cut])
508 } else {
509 path_str.to_string()
510 };
511 return TruncatedPath {
512 prefix: String::new(),
513 truncated: false,
514 suffix: truncated_path,
515 sep,
516 };
517 }
518
519 let mut suffix_parts: Vec<&str> = Vec::new();
521 let mut suffix_len = 0;
522
523 for component in components.iter().skip(prefix_count).rev() {
524 let component_len = component.len() + 1; if suffix_len + component_len <= available_for_suffix {
526 suffix_parts.push(component);
527 suffix_len += component_len;
528 } else {
529 break;
530 }
531 }
532
533 suffix_parts.reverse();
534
535 if suffix_parts.len() == components.len() - prefix_count {
537 return TruncatedPath {
538 prefix: String::new(),
539 truncated: false,
540 suffix: path_str.to_string(),
541 sep,
542 };
543 }
544
545 let suffix = if suffix_parts.is_empty() {
546 let last = components.last().unwrap_or(&"");
550 let truncate_to = available_for_suffix.saturating_sub(4); if truncate_to > 0 && last.len() > truncate_to {
552 let cut = last.floor_char_boundary(truncate_to);
553 format!("{}{}...", sep, &last[..cut])
554 } else {
555 format!("{}{}", sep, last)
556 }
557 } else {
558 format!("{}{}", sep, suffix_parts.join(&sep_str))
559 };
560
561 TruncatedPath {
562 prefix,
563 truncated: true,
564 suffix,
565 sep,
566 }
567}
568
569fn truncate_to_width(s: &str, max_width: usize) -> String {
571 let width = str_width(s);
572 if width <= max_width {
573 return s.to_string();
574 }
575 let truncate_at = max_width.saturating_sub(3);
576 if truncate_at == 0 {
577 return if max_width >= 3 {
578 "...".to_string()
579 } else {
580 s.chars().take(max_width).collect()
581 };
582 }
583 let mut w = 0;
584 let truncated: String = s
585 .chars()
586 .take_while(|ch| {
587 let cw = char_width(*ch);
588 if w + cw <= truncate_at {
589 w += cw;
590 true
591 } else {
592 false
593 }
594 })
595 .collect();
596 format!("{}...", truncated)
597}
598
599const CURSOR_COL_RESERVE: usize = 3;
604
605fn cursor_column(buffer: &mut crate::model::buffer::TextBuffer, cursor_position: usize) -> usize {
612 let mut iter = buffer.line_iterator(cursor_position, 80);
613 let line_start = iter.current_position();
614 let byte_col = cursor_position.saturating_sub(line_start);
615 if byte_col == 0 {
616 return 0;
617 }
618 match iter.next_line() {
624 Some((_, text)) if text.len() >= byte_col => {
625 let mut end = byte_col;
626 while end > 0 && !text.is_char_boundary(end) {
627 end -= 1;
628 }
629 crate::primitives::grapheme::grapheme_count(&text[..end])
630 }
631 _ => byte_col,
632 }
633}
634
635fn format_cursor_position(line: usize, col: usize, line_count: usize) -> String {
643 let text = format!("Ln {line}, Col {col}");
644 let line_digits = line_count.max(1).to_string().len();
645 let min_width = 9 + line_digits + CURSOR_COL_RESERVE;
647 if text.len() < min_width {
648 format!("{text:<min_width$}")
649 } else {
650 text
651 }
652}
653
654fn format_cursor_position_compact(line: usize, col: usize, line_count: usize) -> String {
658 let text = format!("{line}:{col}");
659 let line_digits = line_count.max(1).to_string().len();
660 let min_width = 1 + line_digits + CURSOR_COL_RESERVE;
662 if text.len() < min_width {
663 format!("{text:<min_width$}")
664 } else {
665 text
666 }
667}
668
669pub struct StatusBarRenderer;
671
672impl StatusBarRenderer {
673 pub fn render_status_bar(
677 frame: &mut Frame,
678 area: Rect,
679 ctx: &mut StatusBarContext<'_>,
680 config: &StatusBarConfig,
681 ) -> StatusBarLayout {
682 Self::render_status(frame, area, ctx, config)
683 }
684
685 pub fn render_prompt(
687 frame: &mut Frame,
688 area: Rect,
689 prompt: &Prompt,
690 theme: &crate::view::theme::Theme,
691 ) {
692 let base_style = Style::default().fg(theme.prompt_fg).bg(theme.prompt_bg);
693
694 let mut spans = vec![Span::styled(prompt.message.clone(), base_style)];
696
697 if let Some((sel_start, sel_end)) = prompt.selection_range() {
699 let input = &prompt.input;
700
701 if sel_start > 0 {
703 spans.push(Span::styled(input[..sel_start].to_string(), base_style));
704 }
705
706 if sel_start < sel_end {
708 let selection_style = Style::default()
710 .fg(theme.prompt_selection_fg)
711 .bg(theme.prompt_selection_bg);
712 spans.push(Span::styled(
713 input[sel_start..sel_end].to_string(),
714 selection_style,
715 ));
716 }
717
718 if sel_end < input.len() {
720 spans.push(Span::styled(input[sel_end..].to_string(), base_style));
721 }
722 } else {
723 spans.push(Span::styled(prompt.input.clone(), base_style));
725 }
726
727 let line = Line::from(spans);
728 let prompt_line = Paragraph::new(line).style(base_style);
729
730 frame.render_widget(prompt_line, area);
731
732 let message_width = str_width(&prompt.message);
737 let input_width_before_cursor = str_width(&prompt.input[..prompt.cursor_pos]);
738 let cursor_x = (message_width + input_width_before_cursor) as u16;
739 if cursor_x < area.width {
740 frame.set_cursor_position((area.x + cursor_x, area.y));
741 }
742 }
743
744 pub fn render_file_open_prompt(
748 frame: &mut Frame,
749 area: Rect,
750 prompt: &Prompt,
751 file_open_state: &crate::app::file_open::FileOpenState,
752 theme: &crate::view::theme::Theme,
753 ) {
754 let base_style = Style::default().fg(theme.prompt_fg).bg(theme.prompt_bg);
755 let dir_style = Style::default()
756 .fg(theme.help_separator_fg)
757 .bg(theme.prompt_bg);
758 let ellipsis_style = Style::default()
760 .fg(theme.menu_highlight_fg)
761 .bg(theme.prompt_bg);
762
763 let mut spans = Vec::new();
764
765 let open_prompt = t!("file.open_prompt").to_string();
767 spans.push(Span::styled(open_prompt.clone(), base_style));
768
769 let prefix_len = str_width(&open_prompt);
772 let dir_path = file_open_state.current_dir.to_string_lossy();
773 let dir_path_len = dir_path.len() + 1; let input_len = prompt.input.len();
775 let total_len = prefix_len + dir_path_len + input_len;
776 let threshold = (area.width as usize * 90) / 100;
777
778 let truncated = if total_len > threshold {
780 let available_for_path = threshold
782 .saturating_sub(prefix_len)
783 .saturating_sub(input_len);
784 truncate_path(&file_open_state.current_dir, available_for_path)
785 } else {
786 TruncatedPath {
788 prefix: String::new(),
789 truncated: false,
790 suffix: dir_path.to_string(),
791 sep: path_display_sep(&dir_path),
792 }
793 };
794
795 if truncated.truncated {
797 spans.push(Span::styled(truncated.prefix.clone(), dir_style));
799 spans.push(Span::styled(
801 format!("{}[...]", truncated.sep),
802 ellipsis_style,
803 ));
804 let suffix_with_slash = if truncated.suffix.ends_with('/') {
806 truncated.suffix.clone()
807 } else {
808 format!("{}/", truncated.suffix)
809 };
810 spans.push(Span::styled(suffix_with_slash, dir_style));
811 } else {
812 let path_display = if truncated.suffix.ends_with('/') {
814 truncated.suffix.clone()
815 } else {
816 format!("{}/", truncated.suffix)
817 };
818 spans.push(Span::styled(path_display, dir_style));
819 }
820
821 spans.push(Span::styled(prompt.input.clone(), base_style));
823
824 let line = Line::from(spans);
825 let prompt_line = Paragraph::new(line).style(base_style);
826
827 frame.render_widget(prompt_line, area);
828
829 let prefix_width = str_width(&open_prompt);
833 let dir_display_width = if truncated.truncated {
834 let suffix_with_slash = if truncated.suffix.ends_with('/') {
835 &truncated.suffix
836 } else {
837 &truncated.suffix
839 };
840 str_width(&truncated.prefix) + str_width("/[...]") + str_width(suffix_with_slash) + 1
841 } else {
842 str_width(&truncated.suffix) + 1 };
844 let input_width_before_cursor = str_width(&prompt.input[..prompt.cursor_pos]);
845 let cursor_x = (prefix_width + dir_display_width + input_width_before_cursor) as u16;
846 if cursor_x < area.width {
847 frame.set_cursor_position((area.x + cursor_x, area.y));
848 }
849 }
850
851 fn render_element(
854 element: &StatusBarElement,
855 ctx: &mut StatusBarContext<'_>,
856 ) -> Option<RenderedElement> {
857 if ctx.is_synthetic_placeholder
862 && matches!(
863 element,
864 StatusBarElement::Filename
865 | StatusBarElement::Cursor
866 | StatusBarElement::CursorCompact
867 | StatusBarElement::CursorCount
868 | StatusBarElement::Diagnostics
869 | StatusBarElement::LineEnding
870 | StatusBarElement::Encoding
871 | StatusBarElement::Language
872 )
873 {
874 return None;
875 }
876 match element {
877 StatusBarElement::Filename => {
878 let modified = if ctx.state.buffer.is_modified() {
879 " [+]"
880 } else {
881 ""
882 };
883 let read_only_indicator = if ctx.read_only { " [RO]" } else { "" };
884 let remote_disconnected = ctx
885 .remote_connection
886 .map(|conn| conn.contains("(Disconnected)"))
887 .unwrap_or(false);
888 let remote_prefix = if ctx.remote_indicator_on_bar {
895 String::new()
896 } else {
897 ctx.remote_connection
898 .map(|conn| {
899 if conn.starts_with("Container:") {
900 format!("[{}] ", conn)
901 } else {
902 format!("{SSH_PREFIX}{conn}{SSH_PREFIX_TERMINATOR}")
903 }
904 })
905 .unwrap_or_default()
906 };
907 let session_prefix = ctx
908 .session_name
909 .map(|name| format!("[{}] ", name))
910 .unwrap_or_default();
911 let display_name = ctx.display_name;
912 let text = format!(
913 "{session_prefix}{remote_prefix}{display_name}{modified}{read_only_indicator}"
914 );
915 let kind = if remote_disconnected {
916 ElementKind::RemoteDisconnected
917 } else {
918 ElementKind::Normal
919 };
920 Some(RenderedElement {
921 text,
922 kind,
923 token_key: None,
924 })
925 }
926 StatusBarElement::Cursor => {
927 if !ctx.state.show_cursors {
928 return None;
929 }
930 let cursor = *ctx.cursors.primary();
931 let line_count = ctx.state.buffer.line_count();
932 let text = if let Some(lc) = line_count {
933 let line = ctx.state.primary_cursor_line_number.value();
934 let col = cursor_column(&mut ctx.state.buffer, cursor.position);
935 format_cursor_position(line + 1, col + 1, lc)
936 } else {
937 format!("Byte {}", cursor.position)
938 };
939 Some(RenderedElement {
940 text,
941 kind: ElementKind::Normal,
942 token_key: None,
943 })
944 }
945 StatusBarElement::CursorCompact => {
946 if !ctx.state.show_cursors {
947 return None;
948 }
949 let cursor = *ctx.cursors.primary();
950 let line_count = ctx.state.buffer.line_count();
951 let text = if let Some(lc) = line_count {
952 let line = ctx.state.primary_cursor_line_number.value();
953 let col = cursor_column(&mut ctx.state.buffer, cursor.position);
954 format_cursor_position_compact(line + 1, col + 1, lc)
955 } else {
956 format!("{}", cursor.position)
957 };
958 Some(RenderedElement {
959 text,
960 kind: ElementKind::Normal,
961 token_key: None,
962 })
963 }
964 StatusBarElement::Diagnostics => {
965 let diagnostics = ctx.state.overlays.all();
966 let mut error_count = 0usize;
967 let mut warning_count = 0usize;
968 let mut info_count = 0usize;
969 let diagnostic_ns = crate::services::lsp::diagnostics::lsp_diagnostic_namespace();
970 for overlay in diagnostics {
971 if overlay.namespace.as_ref() == Some(&diagnostic_ns) {
972 match overlay.priority {
973 100 => error_count += 1,
974 50 => warning_count += 1,
975 _ => info_count += 1,
976 }
977 }
978 }
979 if error_count + warning_count + info_count == 0 {
980 return None;
981 }
982 let mut parts = Vec::new();
983 if error_count > 0 {
984 parts.push(format!("E:{}", error_count));
985 }
986 if warning_count > 0 {
987 parts.push(format!("W:{}", warning_count));
988 }
989 if info_count > 0 {
990 parts.push(format!("I:{}", info_count));
991 }
992 Some(RenderedElement {
993 text: parts.join(" "),
994 kind: ElementKind::Normal,
995 token_key: None,
996 })
997 }
998 StatusBarElement::CursorCount => {
999 if ctx.cursors.count() <= 1 {
1000 return None;
1001 }
1002 Some(RenderedElement {
1003 text: t!("status.cursors", count = ctx.cursors.count()).to_string(),
1004 kind: ElementKind::Normal,
1005 token_key: None,
1006 })
1007 }
1008 StatusBarElement::Messages => {
1009 let mut parts: Vec<&str> = Vec::new();
1010 if let Some(msg) = ctx.status_message {
1011 if !msg.is_empty() {
1012 parts.push(msg);
1013 }
1014 }
1015 if let Some(msg) = ctx.plugin_status_message {
1016 if !msg.is_empty() {
1017 parts.push(msg);
1018 }
1019 }
1020 if parts.is_empty() {
1021 return None;
1022 }
1023 Some(RenderedElement {
1024 text: parts.join(" | "),
1025 kind: ElementKind::Messages,
1026 token_key: None,
1027 })
1028 }
1029 StatusBarElement::Chord => {
1030 if ctx.chord_state.is_empty() {
1031 return None;
1032 }
1033 let chord_str = ctx
1034 .chord_state
1035 .iter()
1036 .map(|(code, modifiers)| {
1037 crate::input::keybindings::format_keybinding(code, modifiers)
1038 })
1039 .collect::<Vec<_>>()
1040 .join(" ");
1041 Some(RenderedElement {
1042 text: format!("[{}]", chord_str),
1043 kind: ElementKind::Normal,
1044 token_key: None,
1045 })
1046 }
1047 StatusBarElement::LineEnding => Some(RenderedElement {
1048 text: ctx.state.buffer.line_ending().display_name().to_string(),
1049 kind: ElementKind::LineEnding,
1050 token_key: None,
1051 }),
1052 StatusBarElement::Encoding => Some(RenderedElement {
1053 text: ctx.state.buffer.encoding().display_name().to_string(),
1054 kind: ElementKind::Encoding,
1055 token_key: None,
1056 }),
1057 StatusBarElement::Language => {
1058 let text = if ctx.state.language == "text"
1059 && ctx.state.display_name != "Text"
1060 && ctx.state.display_name != "Plain Text"
1061 && ctx.state.display_name != "text"
1062 {
1063 format!("{} [syntax only]", &ctx.state.display_name)
1064 } else {
1065 ctx.state.display_name.to_string()
1066 };
1067 Some(RenderedElement {
1068 text,
1069 kind: ElementKind::Language,
1070 token_key: None,
1071 })
1072 }
1073 StatusBarElement::Lsp => {
1074 if ctx.lsp_status.is_empty() {
1075 return None;
1076 }
1077 Some(RenderedElement {
1078 text: ctx.lsp_status.to_string(),
1079 kind: ElementKind::Lsp,
1080 token_key: None,
1081 })
1082 }
1083 StatusBarElement::Warnings => {
1084 if ctx.general_warning_count == 0 {
1085 return None;
1086 }
1087 Some(RenderedElement {
1088 text: format!("[\u{26a0} {}]", ctx.general_warning_count),
1089 kind: ElementKind::WarningBadge,
1090 token_key: None,
1091 })
1092 }
1093 StatusBarElement::Update => {
1094 let version = ctx.update_available?;
1095 Some(RenderedElement {
1096 text: t!("status.update_available", version = version).to_string(),
1097 kind: ElementKind::Update,
1098 token_key: None,
1099 })
1100 }
1101 StatusBarElement::Palette => {
1102 let shortcut = ctx
1103 .keybindings
1104 .get_keybinding_for_action(
1105 &crate::input::keybindings::Action::QuickOpen,
1106 crate::input::keybindings::KeyContext::Global,
1107 )
1108 .unwrap_or_else(|| "?".to_string());
1109 Some(RenderedElement {
1110 text: t!("status.palette", shortcut = shortcut).to_string(),
1111 kind: ElementKind::Palette,
1112 token_key: None,
1113 })
1114 }
1115 StatusBarElement::Clock => {
1116 let now = chrono::Local::now();
1117 let text = format!("{:02}:{:02}", now.hour(), now.minute());
1118 Some(RenderedElement {
1119 text,
1120 kind: ElementKind::Clock,
1121 token_key: None,
1122 })
1123 }
1124 StatusBarElement::RemoteIndicator => {
1125 let (text, state) = if let Some(over) = ctx.remote_state_override {
1135 (over.label(), over.state())
1136 } else {
1137 match ctx.remote_connection {
1138 None => ("Local".to_string(), RemoteIndicatorState::Local),
1139 Some(conn) if conn.contains("(Disconnected)") => {
1140 (conn.to_string(), RemoteIndicatorState::Disconnected)
1141 }
1142 Some(conn) => (conn.to_string(), RemoteIndicatorState::Connected),
1143 }
1144 };
1145 Some(RenderedElement {
1146 text,
1147 kind: ElementKind::RemoteIndicator(state),
1148 token_key: None,
1149 })
1150 }
1151 StatusBarElement::WorkspaceTrust => {
1152 use crate::services::workspace_trust::TrustLevel;
1157 let level = ctx.workspace_trust_level;
1158 let text = match level {
1159 TrustLevel::Trusted => t!("statusbar.trust.trusted"),
1160 TrustLevel::Restricted => t!("statusbar.trust.restricted"),
1161 TrustLevel::Blocked => t!("statusbar.trust.blocked"),
1162 }
1163 .to_string();
1164 Some(RenderedElement {
1165 text,
1166 kind: ElementKind::WorkspaceTrust(level),
1167 token_key: None,
1168 })
1169 }
1170 StatusBarElement::CustomToken(key) => {
1171 if let Some(value) = ctx.dynamic_status_bar_elements.get(key) {
1172 Some(RenderedElement {
1173 text: value.clone(),
1174 kind: ElementKind::Custom,
1175 token_key: Some(key.clone()),
1176 })
1177 } else {
1178 None }
1180 }
1181 }
1182 }
1183
1184 fn element_style(
1186 kind: ElementKind,
1187 theme: &crate::view::theme::Theme,
1188 hover: StatusBarHover,
1189 _warning_level: WarningLevel,
1190 lsp_state: LspIndicatorState,
1191 ) -> Style {
1192 match kind {
1193 ElementKind::Normal | ElementKind::Messages | ElementKind::Clock => Style::default()
1194 .fg(theme.status_bar_fg)
1195 .bg(theme.status_bar_bg),
1196 ElementKind::RemoteDisconnected => Style::default()
1197 .fg(theme.status_error_indicator_fg)
1198 .bg(theme.status_error_indicator_bg),
1199 ElementKind::LineEnding => {
1200 let is_hovering = hover == StatusBarHover::LineEndingIndicator;
1201 let (fg, bg) = if is_hovering {
1202 (theme.menu_hover_fg, theme.menu_hover_bg)
1203 } else {
1204 (theme.status_bar_fg, theme.status_bar_bg)
1205 };
1206 let mut style = Style::default().fg(fg).bg(bg);
1207 if is_hovering {
1208 style = style.add_modifier(Modifier::UNDERLINED);
1209 }
1210 style
1211 }
1212 ElementKind::Encoding => {
1213 let is_hovering = hover == StatusBarHover::EncodingIndicator;
1214 let (fg, bg) = if is_hovering {
1215 (theme.menu_hover_fg, theme.menu_hover_bg)
1216 } else {
1217 (theme.status_bar_fg, theme.status_bar_bg)
1218 };
1219 let mut style = Style::default().fg(fg).bg(bg);
1220 if is_hovering {
1221 style = style.add_modifier(Modifier::UNDERLINED);
1222 }
1223 style
1224 }
1225 ElementKind::Language => {
1226 let is_hovering = hover == StatusBarHover::LanguageIndicator;
1227 let (fg, bg) = if is_hovering {
1228 (theme.menu_hover_fg, theme.menu_hover_bg)
1229 } else {
1230 (theme.status_bar_fg, theme.status_bar_bg)
1231 };
1232 let mut style = Style::default().fg(fg).bg(bg);
1233 if is_hovering {
1234 style = style.add_modifier(Modifier::UNDERLINED);
1235 }
1236 style
1237 }
1238 ElementKind::Lsp => {
1239 let is_hovering = hover == StatusBarHover::LspIndicator;
1240 let (fg, bg) = match lsp_state {
1251 LspIndicatorState::Error => {
1252 (theme.diagnostic_error_fg, theme.diagnostic_error_bg)
1253 }
1254 LspIndicatorState::Off => (
1255 theme.status_lsp_actionable_fg,
1256 theme.status_lsp_actionable_bg,
1257 ),
1258 LspIndicatorState::On => (theme.status_lsp_on_fg, theme.status_lsp_on_bg),
1259 LspIndicatorState::OffDismissed => (theme.status_bar_fg, theme.status_bar_bg),
1260 LspIndicatorState::None => (theme.status_bar_fg, theme.status_bar_bg),
1261 };
1262 let mut style = Style::default().fg(fg).bg(bg);
1263 if is_hovering && lsp_state != LspIndicatorState::None {
1268 style = style.add_modifier(Modifier::UNDERLINED);
1269 }
1270 style
1271 }
1272 ElementKind::WarningBadge => {
1273 let is_hovering = hover == StatusBarHover::WarningBadge;
1274 let (fg, bg) = if is_hovering {
1275 (
1276 theme.status_warning_indicator_hover_fg,
1277 theme.status_warning_indicator_hover_bg,
1278 )
1279 } else {
1280 (
1281 theme.status_warning_indicator_fg,
1282 theme.status_warning_indicator_bg,
1283 )
1284 };
1285 let mut style = Style::default().fg(fg).bg(bg);
1286 if is_hovering {
1287 style = style.add_modifier(Modifier::UNDERLINED);
1288 }
1289 style
1290 }
1291 ElementKind::Update => Style::default()
1292 .fg(theme.menu_highlight_fg)
1293 .bg(theme.menu_dropdown_bg),
1294 ElementKind::Palette => Style::default()
1299 .fg(theme.status_palette_fg)
1300 .bg(theme.status_palette_bg),
1301 ElementKind::Custom => Style::default()
1302 .fg(theme.status_bar_fg)
1303 .bg(theme.status_bar_bg),
1304 ElementKind::RemoteIndicator(state) => {
1305 let is_hovering = hover == StatusBarHover::RemoteIndicator;
1306 let (fg, bg) = match state {
1307 RemoteIndicatorState::Connecting | RemoteIndicatorState::Connected => {
1313 (theme.help_indicator_fg, theme.help_indicator_bg)
1314 }
1315 RemoteIndicatorState::FailedAttach | RemoteIndicatorState::Disconnected => (
1319 theme.status_error_indicator_fg,
1320 theme.status_error_indicator_bg,
1321 ),
1322 RemoteIndicatorState::Local => (theme.status_bar_fg, theme.status_bar_bg),
1324 };
1325 let mut style = Style::default().fg(fg).bg(bg);
1326 if is_hovering {
1327 style = style.add_modifier(Modifier::UNDERLINED);
1328 }
1329 style
1330 }
1331 ElementKind::WorkspaceTrust(level) => {
1332 use crate::services::workspace_trust::TrustLevel;
1333 let is_hovering = hover == StatusBarHover::WorkspaceTrust;
1334 let (fg, bg) = match level {
1335 TrustLevel::Restricted | TrustLevel::Blocked => (
1338 theme.status_warning_indicator_fg,
1339 theme.status_warning_indicator_bg,
1340 ),
1341 TrustLevel::Trusted => (theme.status_bar_fg, theme.status_bar_bg),
1343 };
1344 let mut style = Style::default().fg(fg).bg(bg);
1345 if is_hovering {
1346 style = style.add_modifier(Modifier::UNDERLINED);
1347 }
1348 style
1349 }
1350 }
1351 }
1352
1353 fn update_layout_for_element(
1360 layout: &mut StatusBarLayout,
1361 kind: ElementKind,
1362 token_key: Option<&str>,
1363 row: u16,
1364 start_col: u16,
1365 end_col: u16,
1366 ) {
1367 match kind {
1368 ElementKind::LineEnding => {
1369 layout.line_ending_indicator = Some((row, start_col, end_col))
1370 }
1371 ElementKind::Encoding => layout.encoding_indicator = Some((row, start_col, end_col)),
1372 ElementKind::Language => layout.language_indicator = Some((row, start_col, end_col)),
1373 ElementKind::Lsp => layout.lsp_indicator = Some((row, start_col, end_col)),
1374 ElementKind::WarningBadge => layout.warning_badge = Some((row, start_col, end_col)),
1375 ElementKind::Messages => layout.message_area = Some((row, start_col, end_col)),
1376 ElementKind::RemoteIndicator(_) => {
1377 layout.remote_indicator = Some((row, start_col, end_col))
1378 }
1379 ElementKind::WorkspaceTrust(_) => {
1380 layout.trust_indicator = Some((row, start_col, end_col))
1381 }
1382 ElementKind::Custom => {
1383 if let Some(key) = token_key {
1384 layout
1385 .plugin_token_areas
1386 .insert(key.to_string(), (row, start_col, end_col));
1387 }
1388 }
1389 _ => {}
1390 }
1391 }
1392
1393 fn element_spans(
1398 rendered: &RenderedElement,
1399 theme: &crate::view::theme::Theme,
1400 hover: StatusBarHover,
1401 warning_level: WarningLevel,
1402 lsp_state: LspIndicatorState,
1403 ) -> (Vec<Span<'static>>, usize) {
1404 let base_style = Style::default()
1405 .fg(theme.status_bar_fg)
1406 .bg(theme.status_bar_bg);
1407 let width = str_width(&rendered.text) + 2;
1412
1413 if rendered.kind == ElementKind::RemoteDisconnected && rendered.text.starts_with(SSH_PREFIX)
1414 {
1415 let error_style = Style::default()
1416 .fg(theme.status_error_indicator_fg)
1417 .bg(theme.status_error_indicator_bg);
1418 if let Some(term_off) = rendered.text.find(SSH_PREFIX_TERMINATOR) {
1419 let split_at = term_off + SSH_PREFIX_TERMINATOR.len();
1420 let prefix = rendered.text[..split_at].to_string();
1421 let rest = rendered.text[split_at..].to_string();
1422 return (
1423 vec![
1424 Span::styled(" ", error_style),
1425 Span::styled(prefix, error_style),
1426 Span::styled(rest, base_style),
1427 Span::styled(" ", base_style),
1428 ],
1429 width,
1430 );
1431 }
1432 return (
1433 vec![
1434 Span::styled(" ", error_style),
1435 Span::styled(rendered.text.clone(), error_style),
1436 Span::styled(" ", error_style),
1437 ],
1438 width,
1439 );
1440 }
1441
1442 let style = Self::element_style(rendered.kind, theme, hover, warning_level, lsp_state);
1443 let mut spans = vec![Span::styled(" ", style)];
1444 if rendered.kind == ElementKind::Clock {
1445 spans.push(Span::styled(rendered.text[..2].to_string(), style));
1447 spans.push(Span::styled(
1448 ":".to_string(),
1449 style.add_modifier(Modifier::SLOW_BLINK),
1450 ));
1451 spans.push(Span::styled(rendered.text[3..].to_string(), style));
1452 } else {
1453 spans.push(Span::styled(rendered.text.clone(), style));
1454 }
1455 spans.push(Span::styled(" ", style));
1456 (spans, width)
1457 }
1458
1459 fn render_side(
1466 config_side: &[StatusBarElement],
1467 ctx: &mut StatusBarContext<'_>,
1468 ) -> Vec<(Vec<Span<'static>>, usize, ElementKind, Option<String>)> {
1469 let rendered: Vec<RenderedElement> = config_side
1470 .iter()
1471 .filter_map(|elem| Self::render_element(elem, ctx))
1472 .filter(|e| !e.text.is_empty())
1473 .collect();
1474
1475 let theme = ctx.theme;
1476 let hover = ctx.hover;
1477 let warning_level = ctx.warning_level;
1478 let lsp_state = ctx.lsp_indicator_state;
1479 rendered
1480 .into_iter()
1481 .map(|r| {
1482 let kind = r.kind;
1483 let token_key = r.token_key.clone();
1484 let (spans, width) =
1485 Self::element_spans(&r, theme, hover, warning_level, lsp_state);
1486 (spans, width, kind, token_key)
1487 })
1488 .collect()
1489 }
1490
1491 fn render_status(
1493 frame: &mut Frame,
1494 area: Rect,
1495 ctx: &mut StatusBarContext<'_>,
1496 config: &StatusBarConfig,
1497 ) -> StatusBarLayout {
1498 let mut layout = StatusBarLayout::default();
1499 let base_style = Style::default()
1500 .fg(ctx.theme.status_bar_fg)
1501 .bg(ctx.theme.status_bar_bg);
1502 let available_width = area.width as usize;
1503
1504 if available_width == 0 || area.height == 0 {
1505 return layout;
1506 }
1507
1508 ctx.remote_indicator_on_bar = config
1513 .left
1514 .iter()
1515 .chain(config.right.iter())
1516 .any(|e| matches!(e, StatusBarElement::RemoteIndicator));
1517
1518 let left_items = Self::render_side(&config.left, ctx);
1519 let mut right_items = Self::render_side(&config.right, ctx);
1520
1521 let separator: &str = &config.separator;
1524 let separator_width = str_width(separator);
1525 let separator_style = Style::default()
1528 .fg(ctx.theme.status_separator_fg)
1529 .bg(ctx.theme.status_separator_bg);
1530
1531 let total_right_width: usize = right_items.iter().map(|(_, w, _, _)| *w).sum::<usize>()
1540 + separator_width * right_items.len().saturating_sub(1);
1541 let left_min_target = available_width
1542 .saturating_mul(2)
1543 .saturating_div(5) .min(40); let right_budget = available_width.saturating_sub(left_min_target + 1);
1546 if total_right_width > right_budget && right_items.len() > 1 {
1547 let mut current = total_right_width;
1548 while current > right_budget && right_items.len() > 1 {
1549 if let Some(dropped) = right_items.pop() {
1550 current = current.saturating_sub(dropped.1);
1551 current = current.saturating_sub(separator_width);
1554 } else {
1555 break;
1556 }
1557 }
1558 }
1559
1560 let right_width: usize = right_items.iter().map(|(_, w, _, _)| *w).sum::<usize>()
1561 + separator_width * right_items.len().saturating_sub(1);
1562
1563 let narrow = available_width < 15;
1564 let left_max_width = if narrow {
1565 available_width
1566 } else if available_width > right_width + 1 {
1567 available_width - right_width - 1
1568 } else {
1569 1
1570 };
1571
1572 let mut spans: Vec<Span<'static>> = Vec::new();
1576 let mut used_left: usize = 0;
1577
1578 for (idx, (item_spans, width, kind, token_key)) in left_items.into_iter().enumerate() {
1579 let sep_width = if idx == 0 { 0 } else { separator_width };
1580 if used_left + sep_width >= left_max_width {
1581 break;
1582 }
1583 if sep_width > 0 {
1584 spans.push(Span::styled(separator.to_string(), separator_style));
1585 used_left += sep_width;
1586 }
1587
1588 let remaining = left_max_width - used_left;
1589 let start_col = used_left;
1590
1591 if width <= remaining {
1592 spans.extend(item_spans);
1593 used_left += width;
1594
1595 Self::update_layout_for_element(
1596 &mut layout,
1597 kind,
1598 token_key.as_deref(),
1599 area.y,
1600 area.x + start_col as u16,
1601 area.x + (start_col + width) as u16,
1602 );
1603 } else {
1604 let group_text: String = item_spans.iter().map(|s| s.content.as_ref()).collect();
1608 let truncated = truncate_to_width(&group_text, remaining);
1609 let truncated_width = str_width(&truncated);
1610 let overflow_style = Self::element_style(
1611 kind,
1612 ctx.theme,
1613 ctx.hover,
1614 ctx.warning_level,
1615 ctx.lsp_indicator_state,
1616 );
1617 spans.push(Span::styled(truncated, overflow_style));
1618 used_left += truncated_width;
1619
1620 Self::update_layout_for_element(
1621 &mut layout,
1622 kind,
1623 token_key.as_deref(),
1624 area.y,
1625 area.x + start_col as u16,
1626 area.x + (start_col + truncated_width) as u16,
1627 );
1628 break;
1629 }
1630 }
1631
1632 if narrow {
1633 if used_left < available_width {
1634 spans.push(Span::styled(
1635 " ".repeat(available_width - used_left),
1636 base_style,
1637 ));
1638 }
1639 frame.render_widget(Paragraph::new(Line::from(spans)), area);
1640 return layout;
1641 }
1642
1643 let mut col_offset = used_left;
1644 if col_offset + right_width < available_width {
1645 let padding = available_width - col_offset - right_width;
1646 spans.push(Span::styled(" ".repeat(padding), base_style));
1647 col_offset = available_width - right_width;
1648 } else if col_offset < available_width {
1649 spans.push(Span::styled(" ", base_style));
1650 col_offset += 1;
1651 }
1652
1653 let mut current_col = area.x + col_offset as u16;
1654 for (idx, (item_spans, width, kind, token_key)) in right_items.into_iter().enumerate() {
1655 if idx > 0 && separator_width > 0 {
1656 spans.push(Span::styled(separator.to_string(), separator_style));
1657 current_col += separator_width as u16;
1658 }
1659 Self::update_layout_for_element(
1660 &mut layout,
1661 kind,
1662 token_key.as_deref(),
1663 area.y,
1664 current_col,
1665 current_col + width as u16,
1666 );
1667 spans.extend(item_spans);
1668 current_col += width as u16;
1669 }
1670
1671 frame.render_widget(Paragraph::new(Line::from(spans)), area);
1672 layout
1673 }
1674
1675 #[allow(clippy::too_many_arguments)]
1686 pub fn render_search_options(
1687 frame: &mut Frame,
1688 area: Rect,
1689 case_sensitive: bool,
1690 whole_word: bool,
1691 use_regex: bool,
1692 confirm_each: Option<bool>, theme: &crate::view::theme::Theme,
1694 keybindings: &crate::input::keybindings::KeybindingResolver,
1695 hover: SearchOptionsHover,
1696 ) -> SearchOptionsLayout {
1697 use crate::primitives::display_width::str_width;
1698
1699 let mut layout = SearchOptionsLayout {
1700 row: area.y,
1701 ..Default::default()
1702 };
1703
1704 let base_style = Style::default()
1706 .fg(theme.menu_dropdown_fg)
1707 .bg(theme.menu_dropdown_bg);
1708
1709 let hover_style = Style::default()
1711 .fg(theme.menu_hover_fg)
1712 .bg(theme.menu_hover_bg);
1713
1714 let get_shortcut = |action: &crate::input::keybindings::Action| -> Option<String> {
1716 keybindings
1717 .get_keybinding_for_action(action, crate::input::keybindings::KeyContext::Prompt)
1718 .or_else(|| {
1719 keybindings.get_keybinding_for_action(
1720 action,
1721 crate::input::keybindings::KeyContext::Global,
1722 )
1723 })
1724 };
1725
1726 let case_shortcut =
1728 get_shortcut(&crate::input::keybindings::Action::ToggleSearchCaseSensitive);
1729 let word_shortcut = get_shortcut(&crate::input::keybindings::Action::ToggleSearchWholeWord);
1730 let regex_shortcut = get_shortcut(&crate::input::keybindings::Action::ToggleSearchRegex);
1731
1732 let case_checkbox = if case_sensitive { "[x]" } else { "[ ]" };
1734 let word_checkbox = if whole_word { "[x]" } else { "[ ]" };
1735 let regex_checkbox = if use_regex { "[x]" } else { "[ ]" };
1736
1737 let active_style = Style::default()
1739 .fg(theme.menu_highlight_fg)
1740 .bg(theme.menu_dropdown_bg);
1741
1742 let shortcut_style = Style::default()
1744 .fg(theme.help_separator_fg)
1745 .bg(theme.menu_dropdown_bg);
1746
1747 let hover_shortcut_style = Style::default()
1749 .fg(theme.menu_hover_fg)
1750 .bg(theme.menu_hover_bg);
1751
1752 let mut spans = Vec::new();
1753 let mut current_col = area.x;
1754
1755 spans.push(Span::styled(" ", base_style));
1757 current_col += 1;
1758
1759 let get_checkbox_style = |is_hovered: bool, is_checked: bool| -> Style {
1761 if is_hovered {
1762 hover_style
1763 } else if is_checked {
1764 active_style
1765 } else {
1766 base_style
1767 }
1768 };
1769
1770 let case_hovered = hover == SearchOptionsHover::CaseSensitive;
1772 let case_start = current_col;
1773 let case_label = format!("{} {}", case_checkbox, t!("search.case_sensitive"));
1774 let case_shortcut_text = case_shortcut
1775 .as_ref()
1776 .map(|s| format!(" ({})", s))
1777 .unwrap_or_default();
1778 let case_full_width = str_width(&case_label) + str_width(&case_shortcut_text);
1779
1780 spans.push(Span::styled(
1781 case_label,
1782 get_checkbox_style(case_hovered, case_sensitive),
1783 ));
1784 if !case_shortcut_text.is_empty() {
1785 spans.push(Span::styled(
1786 case_shortcut_text,
1787 if case_hovered {
1788 hover_shortcut_style
1789 } else {
1790 shortcut_style
1791 },
1792 ));
1793 }
1794 current_col += case_full_width as u16;
1795 layout.case_sensitive = Some((case_start, current_col));
1796
1797 spans.push(Span::styled(" ", base_style));
1799 current_col += 3;
1800
1801 let word_hovered = hover == SearchOptionsHover::WholeWord;
1803 let word_start = current_col;
1804 let word_label = format!("{} {}", word_checkbox, t!("search.whole_word"));
1805 let word_shortcut_text = word_shortcut
1806 .as_ref()
1807 .map(|s| format!(" ({})", s))
1808 .unwrap_or_default();
1809 let word_full_width = str_width(&word_label) + str_width(&word_shortcut_text);
1810
1811 spans.push(Span::styled(
1812 word_label,
1813 get_checkbox_style(word_hovered, whole_word),
1814 ));
1815 if !word_shortcut_text.is_empty() {
1816 spans.push(Span::styled(
1817 word_shortcut_text,
1818 if word_hovered {
1819 hover_shortcut_style
1820 } else {
1821 shortcut_style
1822 },
1823 ));
1824 }
1825 current_col += word_full_width as u16;
1826 layout.whole_word = Some((word_start, current_col));
1827
1828 spans.push(Span::styled(" ", base_style));
1830 current_col += 3;
1831
1832 let regex_hovered = hover == SearchOptionsHover::Regex;
1834 let regex_start = current_col;
1835 let regex_label = format!("{} {}", regex_checkbox, t!("search.regex"));
1836 let regex_shortcut_text = regex_shortcut
1837 .as_ref()
1838 .map(|s| format!(" ({})", s))
1839 .unwrap_or_default();
1840 let regex_full_width = str_width(®ex_label) + str_width(®ex_shortcut_text);
1841
1842 spans.push(Span::styled(
1843 regex_label,
1844 get_checkbox_style(regex_hovered, use_regex),
1845 ));
1846 if !regex_shortcut_text.is_empty() {
1847 spans.push(Span::styled(
1848 regex_shortcut_text,
1849 if regex_hovered {
1850 hover_shortcut_style
1851 } else {
1852 shortcut_style
1853 },
1854 ));
1855 }
1856 current_col += regex_full_width as u16;
1857 layout.regex = Some((regex_start, current_col));
1858
1859 if use_regex && confirm_each.is_some() {
1861 let hint = " \u{2502} $1,$2,…";
1862 spans.push(Span::styled(hint, shortcut_style));
1863 current_col += str_width(hint) as u16;
1864 }
1865
1866 if let Some(confirm_value) = confirm_each {
1868 let confirm_shortcut =
1869 get_shortcut(&crate::input::keybindings::Action::ToggleSearchConfirmEach);
1870 let confirm_checkbox = if confirm_value { "[x]" } else { "[ ]" };
1871
1872 spans.push(Span::styled(" ", base_style));
1874 current_col += 3;
1875
1876 let confirm_hovered = hover == SearchOptionsHover::ConfirmEach;
1877 let confirm_start = current_col;
1878 let confirm_label = format!("{} {}", confirm_checkbox, t!("search.confirm_each"));
1879 let confirm_shortcut_text = confirm_shortcut
1880 .as_ref()
1881 .map(|s| format!(" ({})", s))
1882 .unwrap_or_default();
1883 let confirm_full_width = str_width(&confirm_label) + str_width(&confirm_shortcut_text);
1884
1885 spans.push(Span::styled(
1886 confirm_label,
1887 get_checkbox_style(confirm_hovered, confirm_value),
1888 ));
1889 if !confirm_shortcut_text.is_empty() {
1890 spans.push(Span::styled(
1891 confirm_shortcut_text,
1892 if confirm_hovered {
1893 hover_shortcut_style
1894 } else {
1895 shortcut_style
1896 },
1897 ));
1898 }
1899 current_col += confirm_full_width as u16;
1900 layout.confirm_each = Some((confirm_start, current_col));
1901 }
1902
1903 let current_width = (current_col - area.x) as usize;
1905 let available_width = area.width as usize;
1906 if current_width < available_width {
1907 spans.push(Span::styled(
1908 " ".repeat(available_width.saturating_sub(current_width)),
1909 base_style,
1910 ));
1911 }
1912
1913 let options_line = Paragraph::new(Line::from(spans));
1914 frame.render_widget(options_line, area);
1915
1916 layout
1917 }
1918}
1919
1920#[cfg(test)]
1921mod tests {
1922 use super::*;
1923 use std::path::PathBuf;
1924
1925 #[test]
1926 fn test_truncate_path_short_path() {
1927 let path = PathBuf::from("/home/user/project");
1928 let result = truncate_path(&path, 50);
1929
1930 assert!(!result.truncated);
1931 assert_eq!(result.suffix, "/home/user/project");
1932 assert!(result.prefix.is_empty());
1933 }
1934
1935 #[test]
1936 fn test_truncate_path_long_path() {
1937 let path = PathBuf::from(
1938 "/private/var/folders/p6/nlmq3k8146990kpkxl73mq340000gn/T/.tmpNYt4Fc/project_root",
1939 );
1940 let result = truncate_path(&path, 40);
1941
1942 assert!(result.truncated, "Path should be truncated");
1943 assert_eq!(result.prefix, "/private");
1944 assert!(
1945 result.suffix.contains("project_root"),
1946 "Suffix should contain project_root"
1947 );
1948 }
1949
1950 #[test]
1951 fn test_truncate_path_preserves_last_components() {
1952 let path = PathBuf::from("/a/b/c/d/e/f/g/h/i/j/project/src");
1953 let result = truncate_path(&path, 30);
1954
1955 assert!(result.truncated);
1956 assert!(
1958 result.suffix.contains("src"),
1959 "Should preserve last component 'src', got: {}",
1960 result.suffix
1961 );
1962 }
1963
1964 #[test]
1965 fn test_truncate_path_display_len() {
1966 let path = PathBuf::from("/private/var/folders/deep/nested/path/here");
1967 let result = truncate_path(&path, 30);
1968
1969 let display = result.to_string_plain();
1971 assert!(
1972 display.len() <= 35, "Display should be truncated to around 30 chars, got {} chars: {}",
1974 display.len(),
1975 display
1976 );
1977 }
1978
1979 #[test]
1980 fn test_truncate_path_root_only() {
1981 let path = PathBuf::from("/");
1982 let result = truncate_path(&path, 50);
1983
1984 assert!(!result.truncated);
1985 assert_eq!(result.suffix, "/");
1986 }
1987
1988 #[test]
1989 fn test_truncate_path_multibyte_single_component_does_not_panic() {
1990 let path = PathBuf::from("/ユーザーのプロジェクト名前/file");
1996 let result = truncate_path(&path, 5);
1997 let display = result.to_string_plain();
1998 assert!(display.is_char_boundary(display.len()));
1999 assert!(display.ends_with("..."));
2000 }
2001
2002 #[test]
2003 fn test_truncate_path_multibyte_last_component_does_not_panic() {
2004 let path = PathBuf::from("/a/ユーザーのプロジェクト名前");
2011 let result = truncate_path(&path, 13);
2012 let display = result.to_string_plain();
2013 assert!(display.is_char_boundary(display.len()));
2014 }
2015
2016 #[test]
2017 fn test_truncated_path_to_string_plain() {
2018 let truncated = TruncatedPath {
2019 prefix: "/home".to_string(),
2020 truncated: true,
2021 suffix: "/project/src".to_string(),
2022 sep: '/',
2023 };
2024
2025 assert_eq!(truncated.to_string_plain(), "/home/[...]/project/src");
2026 }
2027
2028 #[test]
2029 fn test_truncated_path_to_string_plain_no_truncation() {
2030 let truncated = TruncatedPath {
2031 prefix: String::new(),
2032 truncated: false,
2033 suffix: "/home/user/project".to_string(),
2034 sep: '/',
2035 };
2036
2037 assert_eq!(truncated.to_string_plain(), "/home/user/project");
2038 }
2039
2040 #[test]
2046 fn test_truncate_path_windows_backslashes() {
2047 let path = Path::new(r"C:\Users\me\projects\fresh\crates\editor\src\main.rs");
2048 let t = truncate_path(path, 34);
2049 assert!(t.truncated, "long backslash path should middle-truncate");
2050 assert_eq!(t.sep, '\\', "should re-join with backslashes");
2051 let shown = t.to_string_plain();
2052 assert!(
2053 shown.starts_with(r"C:\Users"),
2054 "keeps drive + first dir: {shown}"
2055 );
2056 assert!(
2057 shown.contains(r"\[...]\"),
2058 "uses a backslash ellipsis: {shown}"
2059 );
2060 assert!(shown.ends_with("main.rs"), "keeps the tail: {shown}");
2061 assert!(!shown.contains('/'), "no forward slashes leak in: {shown}");
2062 assert!(shown.len() <= 34, "respects max_len: {shown}");
2063 }
2064
2065 #[test]
2067 fn test_truncate_path_windows_short_unchanged() {
2068 let path = Path::new(r"C:\a\b");
2069 let t = truncate_path(path, 80);
2070 assert!(!t.truncated);
2071 assert_eq!(t.to_string_plain(), r"C:\a\b");
2072 }
2073
2074 #[test]
2075 fn test_remote_indicator_element_kind_equality() {
2076 assert_eq!(
2080 ElementKind::RemoteIndicator(RemoteIndicatorState::Local),
2081 ElementKind::RemoteIndicator(RemoteIndicatorState::Local)
2082 );
2083 let distinct = [
2084 RemoteIndicatorState::Local,
2085 RemoteIndicatorState::Connecting,
2086 RemoteIndicatorState::Connected,
2087 RemoteIndicatorState::FailedAttach,
2088 RemoteIndicatorState::Disconnected,
2089 ];
2090 for (i, a) in distinct.iter().enumerate() {
2091 for (j, b) in distinct.iter().enumerate() {
2092 if i == j {
2093 continue;
2094 }
2095 assert_ne!(
2096 ElementKind::RemoteIndicator(*a),
2097 ElementKind::RemoteIndicator(*b),
2098 "expected {:?} != {:?}",
2099 a,
2100 b
2101 );
2102 }
2103 }
2104 }
2105
2106 #[test]
2107 fn test_remote_indicator_state_default_is_local() {
2108 assert_eq!(RemoteIndicatorState::default(), RemoteIndicatorState::Local);
2111 }
2112
2113 #[test]
2114 fn test_remote_indicator_override_deserializes_kind_tags() {
2115 let cases: &[(&str, RemoteIndicatorOverride)] = &[
2119 (r#"{"kind":"local"}"#, RemoteIndicatorOverride::Local),
2120 (
2121 r#"{"kind":"connecting","label":"Building"}"#,
2122 RemoteIndicatorOverride::Connecting {
2123 label: Some("Building".into()),
2124 },
2125 ),
2126 (
2127 r#"{"kind":"connecting"}"#,
2128 RemoteIndicatorOverride::Connecting { label: None },
2129 ),
2130 (
2131 r#"{"kind":"connected","label":"Container:abc"}"#,
2132 RemoteIndicatorOverride::Connected {
2133 label: Some("Container:abc".into()),
2134 },
2135 ),
2136 (
2137 r#"{"kind":"failed_attach","error":"exit 1"}"#,
2138 RemoteIndicatorOverride::FailedAttach {
2139 error: Some("exit 1".into()),
2140 },
2141 ),
2142 (
2143 r#"{"kind":"disconnected","label":"Container:abc"}"#,
2144 RemoteIndicatorOverride::Disconnected {
2145 label: Some("Container:abc".into()),
2146 },
2147 ),
2148 ];
2149 for (json, expected) in cases {
2150 let parsed: RemoteIndicatorOverride = serde_json::from_str(json)
2151 .unwrap_or_else(|e| panic!("failed to parse {}: {}", json, e));
2152 assert_eq!(&parsed, expected, "wire shape mismatch for {}", json);
2153 }
2154 }
2155
2156 #[test]
2157 fn test_remote_indicator_override_labels() {
2158 let connecting = RemoteIndicatorOverride::Connecting { label: None };
2162 assert!(
2163 connecting.label().contains("Connecting"),
2164 "connecting default label should mention Connecting, got {:?}",
2165 connecting.label()
2166 );
2167
2168 let connecting_labeled = RemoteIndicatorOverride::Connecting {
2169 label: Some("Building".into()),
2170 };
2171 assert!(
2172 connecting_labeled.label().contains("Building"),
2173 "labeled connecting should include the label, got {:?}",
2174 connecting_labeled.label()
2175 );
2176
2177 let failed_bare = RemoteIndicatorOverride::FailedAttach { error: None };
2178 assert_eq!(failed_bare.label(), "Attach failed");
2179
2180 let failed_detail = RemoteIndicatorOverride::FailedAttach {
2181 error: Some("exit 1".into()),
2182 };
2183 assert!(
2184 failed_detail.label().contains("exit 1"),
2185 "failed with error should include the error, got {:?}",
2186 failed_detail.label()
2187 );
2188 }
2189
2190 #[test]
2191 fn test_palette_and_lsp_on_use_dedicated_theme_keys() {
2192 let theme = crate::view::theme::Theme::from_json(
2204 r#"{"name":"t","editor":{},"ui":{},"search":{},"diagnostic":{},"syntax":{}}"#,
2205 )
2206 .expect("minimal theme should parse");
2207
2208 assert_eq!(theme.status_palette_fg, theme.status_bar_fg);
2210 assert_eq!(theme.status_palette_bg, theme.status_bar_bg);
2211 assert_eq!(theme.status_lsp_on_fg, theme.status_bar_fg);
2212 assert_eq!(theme.status_lsp_on_bg, theme.status_bar_bg);
2213
2214 let palette_style = StatusBarRenderer::element_style(
2215 ElementKind::Palette,
2216 &theme,
2217 StatusBarHover::None,
2218 WarningLevel::None,
2219 LspIndicatorState::None,
2220 );
2221 assert_eq!(palette_style.fg, Some(theme.status_palette_fg));
2222 assert_eq!(palette_style.bg, Some(theme.status_palette_bg));
2223
2224 let lsp_on_style = StatusBarRenderer::element_style(
2225 ElementKind::Lsp,
2226 &theme,
2227 StatusBarHover::None,
2228 WarningLevel::None,
2229 LspIndicatorState::On,
2230 );
2231 assert_eq!(lsp_on_style.fg, Some(theme.status_lsp_on_fg));
2232 assert_eq!(lsp_on_style.bg, Some(theme.status_lsp_on_bg));
2233
2234 let lsp_off_style = StatusBarRenderer::element_style(
2237 ElementKind::Lsp,
2238 &theme,
2239 StatusBarHover::None,
2240 WarningLevel::None,
2241 LspIndicatorState::Off,
2242 );
2243 assert_eq!(lsp_off_style.fg, Some(theme.status_lsp_actionable_fg));
2244 assert_eq!(lsp_off_style.bg, Some(theme.status_lsp_actionable_bg));
2245
2246 let lsp_error_style = StatusBarRenderer::element_style(
2247 ElementKind::Lsp,
2248 &theme,
2249 StatusBarHover::None,
2250 WarningLevel::None,
2251 LspIndicatorState::Error,
2252 );
2253 assert_eq!(lsp_error_style.fg, Some(theme.diagnostic_error_fg));
2254 assert_eq!(lsp_error_style.bg, Some(theme.diagnostic_error_bg));
2255 }
2256
2257 #[test]
2258 fn test_status_palette_and_lsp_on_keys_override_independently() {
2259 let theme_json = r#"{
2265 "name":"t",
2266 "editor":{},
2267 "ui":{
2268 "status_bar_fg":"White",
2269 "status_bar_bg":"DarkGray",
2270 "status_palette_fg":"Black",
2271 "status_palette_bg":"Yellow",
2272 "status_lsp_on_fg":"Black",
2273 "status_lsp_on_bg":"Cyan"
2274 },
2275 "search":{},
2276 "diagnostic":{},
2277 "syntax":{}
2278 }"#;
2279 let theme = crate::view::theme::Theme::from_json(theme_json).expect("theme should parse");
2280 assert_ne!(theme.status_palette_fg, theme.status_bar_fg);
2281 assert_ne!(theme.status_palette_bg, theme.status_bar_bg);
2282 assert_ne!(theme.status_lsp_on_fg, theme.status_bar_fg);
2283 assert_ne!(theme.status_lsp_on_bg, theme.status_bar_bg);
2284 }
2285
2286 #[test]
2287 fn test_status_separator_keys_default_and_override() {
2288 let theme = crate::view::theme::Theme::from_json(
2292 r#"{"name":"t","editor":{},"ui":{},"search":{},"diagnostic":{},"syntax":{}}"#,
2293 )
2294 .expect("minimal theme should parse");
2295 assert_eq!(theme.status_separator_fg, theme.status_bar_fg);
2296 assert_eq!(theme.status_separator_bg, theme.status_bar_bg);
2297
2298 let theme = crate::view::theme::Theme::from_json(
2301 r#"{
2302 "name":"t",
2303 "editor":{},
2304 "ui":{
2305 "status_bar_fg":"White",
2306 "status_bar_bg":"DarkGray",
2307 "status_separator_fg":"Gray",
2308 "status_separator_bg":"Black"
2309 },
2310 "search":{},
2311 "diagnostic":{},
2312 "syntax":{}
2313 }"#,
2314 )
2315 .expect("theme should parse");
2316 assert_ne!(theme.status_separator_fg, theme.status_bar_fg);
2317 assert_ne!(theme.status_separator_bg, theme.status_bar_bg);
2318 }
2319
2320 #[test]
2321 fn test_remote_indicator_override_state_projection() {
2322 assert_eq!(
2323 RemoteIndicatorOverride::Local.state(),
2324 RemoteIndicatorState::Local
2325 );
2326 assert_eq!(
2327 RemoteIndicatorOverride::Connecting { label: None }.state(),
2328 RemoteIndicatorState::Connecting
2329 );
2330 assert_eq!(
2331 RemoteIndicatorOverride::Connected { label: None }.state(),
2332 RemoteIndicatorState::Connected
2333 );
2334 assert_eq!(
2335 RemoteIndicatorOverride::FailedAttach { error: None }.state(),
2336 RemoteIndicatorState::FailedAttach
2337 );
2338 assert_eq!(
2339 RemoteIndicatorOverride::Disconnected { label: None }.state(),
2340 RemoteIndicatorState::Disconnected
2341 );
2342 }
2343
2344 #[test]
2353 fn test_cursor_position_widths_stable_across_cursor_movement() {
2354 let line_count = 50;
2355 let widths: Vec<usize> = [(1, 1), (5, 12), (12, 5), (50, 100), (1, 1)]
2358 .into_iter()
2359 .map(|(ln, col)| format_cursor_position(ln, col, line_count).len())
2360 .collect();
2361 assert!(
2362 widths.windows(2).all(|w| w[0] == w[1]),
2363 "rendered widths drift across cursor movements: {widths:?}"
2364 );
2365 }
2366
2367 #[test]
2368 fn test_cursor_position_preserves_natural_number_text() {
2369 let text = format_cursor_position(1, 1, 50);
2373 assert!(
2374 text.starts_with("Ln 1, Col 1"),
2375 "expected text to start with natural numbers, got {text:?}"
2376 );
2377 assert!(
2378 text.ends_with(' '),
2379 "expected trailing padding, got {text:?}"
2380 );
2381 }
2382
2383 #[test]
2384 fn test_cursor_position_no_padding_for_single_line_buffer() {
2385 let text = format_cursor_position(1, 1, 1);
2389 assert_eq!(text.len(), 13);
2391 assert!(text.starts_with("Ln 1, Col 1"));
2392 }
2393
2394 #[test]
2395 fn test_cursor_position_does_not_shrink_below_actual() {
2396 let text = format_cursor_position(99, 99999, 50);
2399 assert_eq!(text, "Ln 99, Col 99999");
2400 }
2401
2402 #[test]
2403 fn test_cursor_position_compact_widths_stable() {
2404 let line_count = 50;
2405 let widths: Vec<usize> = [(1, 1), (5, 12), (12, 5), (50, 100), (1, 1)]
2406 .into_iter()
2407 .map(|(ln, col)| format_cursor_position_compact(ln, col, line_count).len())
2408 .collect();
2409 assert!(
2410 widths.windows(2).all(|w| w[0] == w[1]),
2411 "compact widths drift across cursor movements: {widths:?}"
2412 );
2413 }
2414
2415 #[test]
2416 fn test_cursor_position_compact_preserves_natural_text() {
2417 let text = format_cursor_position_compact(1, 1, 50);
2418 assert!(
2419 text.starts_with("1:1"),
2420 "expected text to start with natural numbers, got {text:?}"
2421 );
2422 }
2423
2424 #[test]
2425 fn test_cursor_position_scales_with_line_count() {
2426 let short = format_cursor_position(1, 1, 9);
2429 let long = format_cursor_position(1, 1, 10_000);
2430 assert!(
2431 long.len() > short.len(),
2432 "wider buffers should reserve more width: {short:?} vs {long:?}"
2433 );
2434 let top = format_cursor_position(1, 1, 10_000);
2437 let high = format_cursor_position(9_999, 999, 10_000);
2438 assert_eq!(top.len(), high.len());
2439 }
2440
2441 #[test]
2442 fn test_cursor_column_counts_chars_not_bytes() {
2443 let mut buf =
2444 crate::model::buffer::TextBuffer::from_str_test("hello\ncafé résumé\nworld\n");
2445 let line_start = buf.line_start_offset(1).unwrap();
2446
2447 let col = cursor_column(&mut buf, line_start + 6);
2449 assert_eq!(
2450 col, 5,
2451 "cursor at 'r' should be column 5, not byte offset 6"
2452 );
2453
2454 let col = cursor_column(&mut buf, line_start + 3);
2456 assert_eq!(col, 3, "cursor at 'é' should be column 3");
2457
2458 let col = cursor_column(&mut buf, line_start + 10);
2460 assert_eq!(col, 8, "cursor at 'u' should be column 8");
2461 }
2462
2463 #[test]
2464 fn test_cursor_column_counts_grapheme_clusters() {
2465 let mut buf = crate::model::buffer::TextBuffer::from_str_test("ab\ne\u{0301}x\n");
2469 let line_start = buf.line_start_offset(1).unwrap();
2470
2471 let col = cursor_column(&mut buf, line_start + 3);
2474 assert_eq!(
2475 col, 1,
2476 "accented 'e' is one grapheme; 'x' should be column 1, not 2"
2477 );
2478 }
2479
2480 #[test]
2481 fn test_cursor_column_zwj_emoji_is_one_grapheme() {
2482 let mut buf = crate::model::buffer::TextBuffer::from_str_test("👨\u{200D}👩\u{200D}👧z\n");
2485 let line_start = buf.line_start_offset(0).unwrap();
2486
2487 let col = cursor_column(&mut buf, line_start + 18);
2488 assert_eq!(col, 1, "ZWJ family emoji should count as one column");
2489 }
2490
2491 #[test]
2492 fn test_cursor_column_at_line_start_is_zero() {
2493 let mut buf = crate::model::buffer::TextBuffer::from_str_test("hello\nworld\n");
2494 let line_start = buf.line_start_offset(1).unwrap();
2495 assert_eq!(cursor_column(&mut buf, line_start), 0);
2496 }
2497}