1use std::path::Path;
4
5use crate::app::WarningLevel;
6use crate::config::{StatusBarConfig, StatusBarElement};
7use crate::primitives::display_width::{char_width, str_width};
8use crate::state::EditorState;
9use crate::view::prompt::Prompt;
10use chrono::Timelike;
11use ratatui::layout::Rect;
12use ratatui::style::{Modifier, Style};
13use ratatui::text::{Line, Span};
14use ratatui::widgets::Paragraph;
15use ratatui::Frame;
16use rust_i18n::t;
17
18const SSH_PREFIX: &str = "[SSH:";
22const SSH_PREFIX_TERMINATOR: &str = "] ";
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26enum ElementKind {
27 Normal,
29 LineEnding,
31 Encoding,
33 Language,
35 Lsp,
37 WarningBadge,
39 Update,
41 Palette,
43 Messages,
45 RemoteDisconnected,
47 Clock,
49 RemoteIndicator(RemoteIndicatorState),
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
67pub enum RemoteIndicatorState {
68 #[default]
70 Local,
71 Connecting,
76 Connected,
78 FailedAttach,
82 Disconnected,
85}
86
87#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
97#[serde(tag = "kind", rename_all = "snake_case")]
98pub enum RemoteIndicatorOverride {
99 Local,
102 Connecting {
105 #[serde(default)]
106 label: Option<String>,
107 },
108 Connected {
111 #[serde(default)]
112 label: Option<String>,
113 },
114 FailedAttach {
117 #[serde(default)]
118 error: Option<String>,
119 },
120 Disconnected {
123 #[serde(default)]
124 label: Option<String>,
125 },
126}
127
128impl RemoteIndicatorOverride {
129 pub fn state(&self) -> RemoteIndicatorState {
131 match self {
132 Self::Local => RemoteIndicatorState::Local,
133 Self::Connecting { .. } => RemoteIndicatorState::Connecting,
134 Self::Connected { .. } => RemoteIndicatorState::Connected,
135 Self::FailedAttach { .. } => RemoteIndicatorState::FailedAttach,
136 Self::Disconnected { .. } => RemoteIndicatorState::Disconnected,
137 }
138 }
139
140 pub fn label(&self) -> String {
144 match self {
145 Self::Local => "Local".to_string(),
146 Self::Connecting { label } => match label {
147 Some(s) if !s.is_empty() => format!("⠿ {}", s),
148 _ => "⠿ Connecting".to_string(),
149 },
150 Self::Connected { label } => label
151 .as_deref()
152 .filter(|s| !s.is_empty())
153 .unwrap_or("Connected")
154 .to_string(),
155 Self::FailedAttach { error } => match error {
156 Some(s) if !s.is_empty() => format!("Attach failed: {}", s),
157 _ => "Attach failed".to_string(),
158 },
159 Self::Disconnected { label } => match label {
160 Some(s) if !s.is_empty() => format!("{} (Disconnected)", s),
161 _ => "Disconnected".to_string(),
162 },
163 }
164 }
165}
166
167struct RenderedElement {
169 text: String,
170 kind: ElementKind,
171}
172
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
189pub enum LspIndicatorState {
190 #[default]
191 None,
192 On,
193 Off,
194 OffDismissed,
195 Error,
196}
197
198pub struct StatusBarContext<'a> {
200 pub state: &'a mut EditorState,
201 pub cursors: &'a crate::model::cursor::Cursors,
202 pub status_message: &'a Option<String>,
203 pub plugin_status_message: &'a Option<String>,
204 pub lsp_status: &'a str,
205 pub lsp_indicator_state: LspIndicatorState,
210 pub theme: &'a crate::view::theme::Theme,
211 pub display_name: &'a str,
212 pub keybindings: &'a crate::input::keybindings::KeybindingResolver,
213 pub chord_state: &'a [(crossterm::event::KeyCode, crossterm::event::KeyModifiers)],
214 pub update_available: Option<&'a str>,
215 pub warning_level: WarningLevel,
216 pub general_warning_count: usize,
217 pub hover: StatusBarHover,
218 pub remote_connection: Option<&'a str>,
219 pub session_name: Option<&'a str>,
220 pub read_only: bool,
221 pub remote_state_override: Option<&'a RemoteIndicatorOverride>,
228 pub is_synthetic_placeholder: bool,
234 pub remote_indicator_on_bar: bool,
243}
244
245#[derive(Debug, Clone, Default)]
247pub struct StatusBarLayout {
248 pub lsp_indicator: Option<(u16, u16, u16)>,
250 pub warning_badge: Option<(u16, u16, u16)>,
252 pub line_ending_indicator: Option<(u16, u16, u16)>,
254 pub encoding_indicator: Option<(u16, u16, u16)>,
256 pub language_indicator: Option<(u16, u16, u16)>,
258 pub message_area: Option<(u16, u16, u16)>,
260 pub remote_indicator: Option<(u16, u16, u16)>,
263}
264
265#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
267pub enum StatusBarHover {
268 #[default]
269 None,
270 LspIndicator,
272 WarningBadge,
274 LineEndingIndicator,
276 EncodingIndicator,
278 LanguageIndicator,
280 MessageArea,
282 RemoteIndicator,
284}
285
286#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
288pub enum SearchOptionsHover {
289 #[default]
290 None,
291 CaseSensitive,
292 WholeWord,
293 Regex,
294 ConfirmEach,
295}
296
297#[derive(Debug, Clone, Default)]
299pub struct SearchOptionsLayout {
300 pub row: u16,
302 pub case_sensitive: Option<(u16, u16)>,
304 pub whole_word: Option<(u16, u16)>,
306 pub regex: Option<(u16, u16)>,
308 pub confirm_each: Option<(u16, u16)>,
310}
311
312impl SearchOptionsLayout {
313 pub fn checkbox_at(&self, x: u16, y: u16) -> Option<SearchOptionsHover> {
315 if y != self.row {
316 return None;
317 }
318
319 if let Some((start, end)) = self.case_sensitive {
320 if x >= start && x < end {
321 return Some(SearchOptionsHover::CaseSensitive);
322 }
323 }
324 if let Some((start, end)) = self.whole_word {
325 if x >= start && x < end {
326 return Some(SearchOptionsHover::WholeWord);
327 }
328 }
329 if let Some((start, end)) = self.regex {
330 if x >= start && x < end {
331 return Some(SearchOptionsHover::Regex);
332 }
333 }
334 if let Some((start, end)) = self.confirm_each {
335 if x >= start && x < end {
336 return Some(SearchOptionsHover::ConfirmEach);
337 }
338 }
339 None
340 }
341}
342
343#[derive(Debug, Clone)]
345pub struct TruncatedPath {
346 pub prefix: String,
348 pub truncated: bool,
350 pub suffix: String,
352}
353
354impl TruncatedPath {
355 pub fn to_string_plain(&self) -> String {
357 if self.truncated {
358 format!("{}/[...]{}", self.prefix, self.suffix)
359 } else {
360 format!("{}{}", self.prefix, self.suffix)
361 }
362 }
363
364 pub fn display_len(&self) -> usize {
366 if self.truncated {
367 self.prefix.len() + "/[...]".len() + self.suffix.len()
368 } else {
369 self.prefix.len() + self.suffix.len()
370 }
371 }
372}
373
374pub fn truncate_path(path: &Path, max_len: usize) -> TruncatedPath {
386 let path_str = path.to_string_lossy();
387
388 if path_str.len() <= max_len {
390 return TruncatedPath {
391 prefix: String::new(),
392 truncated: false,
393 suffix: path_str.to_string(),
394 };
395 }
396
397 let components: Vec<&str> = path_str.split('/').filter(|s| !s.is_empty()).collect();
398
399 if components.is_empty() {
400 return TruncatedPath {
401 prefix: "/".to_string(),
402 truncated: false,
403 suffix: String::new(),
404 };
405 }
406
407 let prefix = if path_str.starts_with('/') {
409 format!("/{}", components.first().unwrap_or(&""))
410 } else {
411 components.first().unwrap_or(&"").to_string()
412 };
413
414 let ellipsis_len = "/[...]".len();
416
417 let available_for_suffix = max_len.saturating_sub(prefix.len() + ellipsis_len);
419
420 if available_for_suffix < 5 || components.len() <= 1 {
421 let truncated_path = if path_str.len() > max_len.saturating_sub(3) {
426 let cut = path_str.floor_char_boundary(max_len.saturating_sub(3));
427 format!("{}...", &path_str[..cut])
428 } else {
429 path_str.to_string()
430 };
431 return TruncatedPath {
432 prefix: String::new(),
433 truncated: false,
434 suffix: truncated_path,
435 };
436 }
437
438 let mut suffix_parts: Vec<&str> = Vec::new();
440 let mut suffix_len = 0;
441
442 for component in components.iter().skip(1).rev() {
443 let component_len = component.len() + 1; if suffix_len + component_len <= available_for_suffix {
445 suffix_parts.push(component);
446 suffix_len += component_len;
447 } else {
448 break;
449 }
450 }
451
452 suffix_parts.reverse();
453
454 if suffix_parts.len() == components.len() - 1 {
456 return TruncatedPath {
457 prefix: String::new(),
458 truncated: false,
459 suffix: path_str.to_string(),
460 };
461 }
462
463 let suffix = if suffix_parts.is_empty() {
464 let last = components.last().unwrap_or(&"");
468 let truncate_to = available_for_suffix.saturating_sub(4); if truncate_to > 0 && last.len() > truncate_to {
470 let cut = last.floor_char_boundary(truncate_to);
471 format!("/{}...", &last[..cut])
472 } else {
473 format!("/{}", last)
474 }
475 } else {
476 format!("/{}", suffix_parts.join("/"))
477 };
478
479 TruncatedPath {
480 prefix,
481 truncated: true,
482 suffix,
483 }
484}
485
486fn truncate_to_width(s: &str, max_width: usize) -> String {
488 let width = str_width(s);
489 if width <= max_width {
490 return s.to_string();
491 }
492 let truncate_at = max_width.saturating_sub(3);
493 if truncate_at == 0 {
494 return if max_width >= 3 {
495 "...".to_string()
496 } else {
497 s.chars().take(max_width).collect()
498 };
499 }
500 let mut w = 0;
501 let truncated: String = s
502 .chars()
503 .take_while(|ch| {
504 let cw = char_width(*ch);
505 if w + cw <= truncate_at {
506 w += cw;
507 true
508 } else {
509 false
510 }
511 })
512 .collect();
513 format!("{}...", truncated)
514}
515
516pub struct StatusBarRenderer;
518
519impl StatusBarRenderer {
520 pub fn render_status_bar(
524 frame: &mut Frame,
525 area: Rect,
526 ctx: &mut StatusBarContext<'_>,
527 config: &StatusBarConfig,
528 ) -> StatusBarLayout {
529 Self::render_status(frame, area, ctx, config)
530 }
531
532 pub fn render_prompt(
534 frame: &mut Frame,
535 area: Rect,
536 prompt: &Prompt,
537 theme: &crate::view::theme::Theme,
538 ) {
539 let base_style = Style::default().fg(theme.prompt_fg).bg(theme.prompt_bg);
540
541 let mut spans = vec![Span::styled(prompt.message.clone(), base_style)];
543
544 if let Some((sel_start, sel_end)) = prompt.selection_range() {
546 let input = &prompt.input;
547
548 if sel_start > 0 {
550 spans.push(Span::styled(input[..sel_start].to_string(), base_style));
551 }
552
553 if sel_start < sel_end {
555 let selection_style = Style::default()
557 .fg(theme.prompt_selection_fg)
558 .bg(theme.prompt_selection_bg);
559 spans.push(Span::styled(
560 input[sel_start..sel_end].to_string(),
561 selection_style,
562 ));
563 }
564
565 if sel_end < input.len() {
567 spans.push(Span::styled(input[sel_end..].to_string(), base_style));
568 }
569 } else {
570 spans.push(Span::styled(prompt.input.clone(), base_style));
572 }
573
574 let line = Line::from(spans);
575 let prompt_line = Paragraph::new(line).style(base_style);
576
577 frame.render_widget(prompt_line, area);
578
579 let message_width = str_width(&prompt.message);
584 let input_width_before_cursor = str_width(&prompt.input[..prompt.cursor_pos]);
585 let cursor_x = (message_width + input_width_before_cursor) as u16;
586 if cursor_x < area.width {
587 frame.set_cursor_position((area.x + cursor_x, area.y));
588 }
589 }
590
591 pub fn render_file_open_prompt(
595 frame: &mut Frame,
596 area: Rect,
597 prompt: &Prompt,
598 file_open_state: &crate::app::file_open::FileOpenState,
599 theme: &crate::view::theme::Theme,
600 ) {
601 let base_style = Style::default().fg(theme.prompt_fg).bg(theme.prompt_bg);
602 let dir_style = Style::default()
603 .fg(theme.help_separator_fg)
604 .bg(theme.prompt_bg);
605 let ellipsis_style = Style::default()
607 .fg(theme.menu_highlight_fg)
608 .bg(theme.prompt_bg);
609
610 let mut spans = Vec::new();
611
612 let open_prompt = t!("file.open_prompt").to_string();
614 spans.push(Span::styled(open_prompt.clone(), base_style));
615
616 let prefix_len = str_width(&open_prompt);
619 let dir_path = file_open_state.current_dir.to_string_lossy();
620 let dir_path_len = dir_path.len() + 1; let input_len = prompt.input.len();
622 let total_len = prefix_len + dir_path_len + input_len;
623 let threshold = (area.width as usize * 90) / 100;
624
625 let truncated = if total_len > threshold {
627 let available_for_path = threshold
629 .saturating_sub(prefix_len)
630 .saturating_sub(input_len);
631 truncate_path(&file_open_state.current_dir, available_for_path)
632 } else {
633 TruncatedPath {
635 prefix: String::new(),
636 truncated: false,
637 suffix: dir_path.to_string(),
638 }
639 };
640
641 if truncated.truncated {
643 spans.push(Span::styled(truncated.prefix.clone(), dir_style));
645 spans.push(Span::styled("/[...]", ellipsis_style));
647 let suffix_with_slash = if truncated.suffix.ends_with('/') {
649 truncated.suffix.clone()
650 } else {
651 format!("{}/", truncated.suffix)
652 };
653 spans.push(Span::styled(suffix_with_slash, dir_style));
654 } else {
655 let path_display = if truncated.suffix.ends_with('/') {
657 truncated.suffix.clone()
658 } else {
659 format!("{}/", truncated.suffix)
660 };
661 spans.push(Span::styled(path_display, dir_style));
662 }
663
664 spans.push(Span::styled(prompt.input.clone(), base_style));
666
667 let line = Line::from(spans);
668 let prompt_line = Paragraph::new(line).style(base_style);
669
670 frame.render_widget(prompt_line, area);
671
672 let prefix_width = str_width(&open_prompt);
676 let dir_display_width = if truncated.truncated {
677 let suffix_with_slash = if truncated.suffix.ends_with('/') {
678 &truncated.suffix
679 } else {
680 &truncated.suffix
682 };
683 str_width(&truncated.prefix) + str_width("/[...]") + str_width(suffix_with_slash) + 1
684 } else {
685 str_width(&truncated.suffix) + 1 };
687 let input_width_before_cursor = str_width(&prompt.input[..prompt.cursor_pos]);
688 let cursor_x = (prefix_width + dir_display_width + input_width_before_cursor) as u16;
689 if cursor_x < area.width {
690 frame.set_cursor_position((area.x + cursor_x, area.y));
691 }
692 }
693
694 fn render_element(
697 element: &StatusBarElement,
698 ctx: &mut StatusBarContext<'_>,
699 ) -> Option<RenderedElement> {
700 if ctx.is_synthetic_placeholder
705 && matches!(
706 element,
707 StatusBarElement::Filename
708 | StatusBarElement::Cursor
709 | StatusBarElement::CursorCompact
710 | StatusBarElement::CursorCount
711 | StatusBarElement::Diagnostics
712 | StatusBarElement::LineEnding
713 | StatusBarElement::Encoding
714 | StatusBarElement::Language
715 )
716 {
717 return None;
718 }
719 match element {
720 StatusBarElement::Filename => {
721 let modified = if ctx.state.buffer.is_modified() {
722 " [+]"
723 } else {
724 ""
725 };
726 let read_only_indicator = if ctx.read_only { " [RO]" } else { "" };
727 let remote_disconnected = ctx
728 .remote_connection
729 .map(|conn| conn.contains("(Disconnected)"))
730 .unwrap_or(false);
731 let remote_prefix = if ctx.remote_indicator_on_bar {
738 String::new()
739 } else {
740 ctx.remote_connection
741 .map(|conn| {
742 if conn.starts_with("Container:") {
743 format!("[{}] ", conn)
744 } else {
745 format!("{SSH_PREFIX}{conn}{SSH_PREFIX_TERMINATOR}")
746 }
747 })
748 .unwrap_or_default()
749 };
750 let session_prefix = ctx
751 .session_name
752 .map(|name| format!("[{}] ", name))
753 .unwrap_or_default();
754 let display_name = ctx.display_name;
755 let text = format!(
756 "{session_prefix}{remote_prefix}{display_name}{modified}{read_only_indicator}"
757 );
758 let kind = if remote_disconnected {
759 ElementKind::RemoteDisconnected
760 } else {
761 ElementKind::Normal
762 };
763 Some(RenderedElement { text, kind })
764 }
765 StatusBarElement::Cursor => {
766 if !ctx.state.show_cursors {
767 return None;
768 }
769 let cursor = *ctx.cursors.primary();
770 let byte_offset_mode = ctx.state.buffer.line_count().is_none();
771 let text = if byte_offset_mode {
772 format!("Byte {}", cursor.position)
773 } else {
774 let cursor_iter = ctx.state.buffer.line_iterator(cursor.position, 80);
775 let line_start = cursor_iter.current_position();
776 let col = cursor.position.saturating_sub(line_start);
777 let line = ctx.state.primary_cursor_line_number.value();
778 format!("Ln {}, Col {}", line + 1, col + 1)
779 };
780 Some(RenderedElement {
781 text,
782 kind: ElementKind::Normal,
783 })
784 }
785 StatusBarElement::CursorCompact => {
786 if !ctx.state.show_cursors {
787 return None;
788 }
789 let cursor = *ctx.cursors.primary();
790 let byte_offset_mode = ctx.state.buffer.line_count().is_none();
791 let text = if byte_offset_mode {
792 format!("{}", cursor.position)
793 } else {
794 let cursor_iter = ctx.state.buffer.line_iterator(cursor.position, 80);
795 let line_start = cursor_iter.current_position();
796 let col = cursor.position.saturating_sub(line_start);
797 let line = ctx.state.primary_cursor_line_number.value();
798 format!("{}:{}", line + 1, col + 1)
799 };
800 Some(RenderedElement {
801 text,
802 kind: ElementKind::Normal,
803 })
804 }
805 StatusBarElement::Diagnostics => {
806 let diagnostics = ctx.state.overlays.all();
807 let mut error_count = 0usize;
808 let mut warning_count = 0usize;
809 let mut info_count = 0usize;
810 let diagnostic_ns = crate::services::lsp::diagnostics::lsp_diagnostic_namespace();
811 for overlay in diagnostics {
812 if overlay.namespace.as_ref() == Some(&diagnostic_ns) {
813 match overlay.priority {
814 100 => error_count += 1,
815 50 => warning_count += 1,
816 _ => info_count += 1,
817 }
818 }
819 }
820 if error_count + warning_count + info_count == 0 {
821 return None;
822 }
823 let mut parts = Vec::new();
824 if error_count > 0 {
825 parts.push(format!("E:{}", error_count));
826 }
827 if warning_count > 0 {
828 parts.push(format!("W:{}", warning_count));
829 }
830 if info_count > 0 {
831 parts.push(format!("I:{}", info_count));
832 }
833 Some(RenderedElement {
834 text: parts.join(" "),
835 kind: ElementKind::Normal,
836 })
837 }
838 StatusBarElement::CursorCount => {
839 if ctx.cursors.count() <= 1 {
840 return None;
841 }
842 Some(RenderedElement {
843 text: t!("status.cursors", count = ctx.cursors.count()).to_string(),
844 kind: ElementKind::Normal,
845 })
846 }
847 StatusBarElement::Messages => {
848 let mut parts: Vec<&str> = Vec::new();
849 if let Some(msg) = ctx.status_message {
850 if !msg.is_empty() {
851 parts.push(msg);
852 }
853 }
854 if let Some(msg) = ctx.plugin_status_message {
855 if !msg.is_empty() {
856 parts.push(msg);
857 }
858 }
859 if parts.is_empty() {
860 return None;
861 }
862 Some(RenderedElement {
863 text: parts.join(" | "),
864 kind: ElementKind::Messages,
865 })
866 }
867 StatusBarElement::Chord => {
868 if ctx.chord_state.is_empty() {
869 return None;
870 }
871 let chord_str = ctx
872 .chord_state
873 .iter()
874 .map(|(code, modifiers)| {
875 crate::input::keybindings::format_keybinding(code, modifiers)
876 })
877 .collect::<Vec<_>>()
878 .join(" ");
879 Some(RenderedElement {
880 text: format!("[{}]", chord_str),
881 kind: ElementKind::Normal,
882 })
883 }
884 StatusBarElement::LineEnding => Some(RenderedElement {
885 text: format!(" {} ", ctx.state.buffer.line_ending().display_name()),
886 kind: ElementKind::LineEnding,
887 }),
888 StatusBarElement::Encoding => Some(RenderedElement {
889 text: format!(" {} ", ctx.state.buffer.encoding().display_name()),
890 kind: ElementKind::Encoding,
891 }),
892 StatusBarElement::Language => {
893 let text = if ctx.state.language == "text"
894 && ctx.state.display_name != "Text"
895 && ctx.state.display_name != "Plain Text"
896 && ctx.state.display_name != "text"
897 {
898 format!(" {} [syntax only] ", &ctx.state.display_name)
899 } else {
900 format!(" {} ", &ctx.state.display_name)
901 };
902 Some(RenderedElement {
903 text,
904 kind: ElementKind::Language,
905 })
906 }
907 StatusBarElement::Lsp => {
908 if ctx.lsp_status.is_empty() {
909 return None;
910 }
911 Some(RenderedElement {
912 text: format!(" {} ", ctx.lsp_status),
913 kind: ElementKind::Lsp,
914 })
915 }
916 StatusBarElement::Warnings => {
917 if ctx.general_warning_count == 0 {
918 return None;
919 }
920 Some(RenderedElement {
921 text: format!(" [\u{26a0} {}] ", ctx.general_warning_count),
922 kind: ElementKind::WarningBadge,
923 })
924 }
925 StatusBarElement::Update => {
926 let version = ctx.update_available?;
927 Some(RenderedElement {
928 text: format!(" {} ", t!("status.update_available", version = version)),
929 kind: ElementKind::Update,
930 })
931 }
932 StatusBarElement::Palette => {
933 let shortcut = ctx
934 .keybindings
935 .get_keybinding_for_action(
936 &crate::input::keybindings::Action::QuickOpen,
937 crate::input::keybindings::KeyContext::Global,
938 )
939 .unwrap_or_else(|| "?".to_string());
940 Some(RenderedElement {
941 text: format!(" {} ", t!("status.palette", shortcut = shortcut)),
942 kind: ElementKind::Palette,
943 })
944 }
945 StatusBarElement::Clock => {
946 let now = chrono::Local::now();
947 let text = format!("{:02}:{:02}", now.hour(), now.minute());
948 Some(RenderedElement {
949 text,
950 kind: ElementKind::Clock,
951 })
952 }
953 StatusBarElement::RemoteIndicator => {
954 let (text, state) = if let Some(over) = ctx.remote_state_override {
964 (format!(" {} ", over.label()), over.state())
965 } else {
966 match ctx.remote_connection {
967 None => (" Local ".to_string(), RemoteIndicatorState::Local),
968 Some(conn) if conn.contains("(Disconnected)") => {
969 (format!(" {} ", conn), RemoteIndicatorState::Disconnected)
970 }
971 Some(conn) => (format!(" {} ", conn), RemoteIndicatorState::Connected),
972 }
973 };
974 Some(RenderedElement {
975 text,
976 kind: ElementKind::RemoteIndicator(state),
977 })
978 }
979 }
980 }
981
982 fn element_style(
984 kind: ElementKind,
985 theme: &crate::view::theme::Theme,
986 hover: StatusBarHover,
987 _warning_level: WarningLevel,
988 lsp_state: LspIndicatorState,
989 ) -> Style {
990 match kind {
991 ElementKind::Normal | ElementKind::Messages | ElementKind::Clock => Style::default()
992 .fg(theme.status_bar_fg)
993 .bg(theme.status_bar_bg),
994 ElementKind::RemoteDisconnected => Style::default()
995 .fg(theme.status_error_indicator_fg)
996 .bg(theme.status_error_indicator_bg),
997 ElementKind::LineEnding => {
998 let is_hovering = hover == StatusBarHover::LineEndingIndicator;
999 let (fg, bg) = if is_hovering {
1000 (theme.menu_hover_fg, theme.menu_hover_bg)
1001 } else {
1002 (theme.status_bar_fg, theme.status_bar_bg)
1003 };
1004 let mut style = Style::default().fg(fg).bg(bg);
1005 if is_hovering {
1006 style = style.add_modifier(Modifier::UNDERLINED);
1007 }
1008 style
1009 }
1010 ElementKind::Encoding => {
1011 let is_hovering = hover == StatusBarHover::EncodingIndicator;
1012 let (fg, bg) = if is_hovering {
1013 (theme.menu_hover_fg, theme.menu_hover_bg)
1014 } else {
1015 (theme.status_bar_fg, theme.status_bar_bg)
1016 };
1017 let mut style = Style::default().fg(fg).bg(bg);
1018 if is_hovering {
1019 style = style.add_modifier(Modifier::UNDERLINED);
1020 }
1021 style
1022 }
1023 ElementKind::Language => {
1024 let is_hovering = hover == StatusBarHover::LanguageIndicator;
1025 let (fg, bg) = if is_hovering {
1026 (theme.menu_hover_fg, theme.menu_hover_bg)
1027 } else {
1028 (theme.status_bar_fg, theme.status_bar_bg)
1029 };
1030 let mut style = Style::default().fg(fg).bg(bg);
1031 if is_hovering {
1032 style = style.add_modifier(Modifier::UNDERLINED);
1033 }
1034 style
1035 }
1036 ElementKind::Lsp => {
1037 let is_hovering = hover == StatusBarHover::LspIndicator;
1038 let (fg, bg) = match lsp_state {
1049 LspIndicatorState::Error => {
1050 (theme.diagnostic_error_fg, theme.diagnostic_error_bg)
1051 }
1052 LspIndicatorState::Off => (
1053 theme.status_lsp_actionable_fg,
1054 theme.status_lsp_actionable_bg,
1055 ),
1056 LspIndicatorState::On => (theme.status_lsp_on_fg, theme.status_lsp_on_bg),
1057 LspIndicatorState::OffDismissed => (theme.status_bar_fg, theme.status_bar_bg),
1058 LspIndicatorState::None => (theme.status_bar_fg, theme.status_bar_bg),
1059 };
1060 let mut style = Style::default().fg(fg).bg(bg);
1061 if is_hovering && lsp_state != LspIndicatorState::None {
1066 style = style.add_modifier(Modifier::UNDERLINED);
1067 }
1068 style
1069 }
1070 ElementKind::WarningBadge => {
1071 let is_hovering = hover == StatusBarHover::WarningBadge;
1072 let (fg, bg) = if is_hovering {
1073 (
1074 theme.status_warning_indicator_hover_fg,
1075 theme.status_warning_indicator_hover_bg,
1076 )
1077 } else {
1078 (
1079 theme.status_warning_indicator_fg,
1080 theme.status_warning_indicator_bg,
1081 )
1082 };
1083 let mut style = Style::default().fg(fg).bg(bg);
1084 if is_hovering {
1085 style = style.add_modifier(Modifier::UNDERLINED);
1086 }
1087 style
1088 }
1089 ElementKind::Update => Style::default()
1090 .fg(theme.menu_highlight_fg)
1091 .bg(theme.menu_dropdown_bg),
1092 ElementKind::Palette => Style::default()
1097 .fg(theme.status_palette_fg)
1098 .bg(theme.status_palette_bg),
1099 ElementKind::RemoteIndicator(state) => {
1100 let is_hovering = hover == StatusBarHover::RemoteIndicator;
1101 let (fg, bg) = match state {
1102 RemoteIndicatorState::Connecting | RemoteIndicatorState::Connected => {
1108 (theme.help_indicator_fg, theme.help_indicator_bg)
1109 }
1110 RemoteIndicatorState::FailedAttach | RemoteIndicatorState::Disconnected => (
1114 theme.status_error_indicator_fg,
1115 theme.status_error_indicator_bg,
1116 ),
1117 RemoteIndicatorState::Local => (theme.status_bar_fg, theme.status_bar_bg),
1119 };
1120 let mut style = Style::default().fg(fg).bg(bg);
1121 if is_hovering {
1122 style = style.add_modifier(Modifier::UNDERLINED);
1123 }
1124 style
1125 }
1126 }
1127 }
1128
1129 fn update_layout_for_element(
1131 layout: &mut StatusBarLayout,
1132 kind: ElementKind,
1133 row: u16,
1134 start_col: u16,
1135 end_col: u16,
1136 ) {
1137 match kind {
1138 ElementKind::LineEnding => {
1139 layout.line_ending_indicator = Some((row, start_col, end_col))
1140 }
1141 ElementKind::Encoding => layout.encoding_indicator = Some((row, start_col, end_col)),
1142 ElementKind::Language => layout.language_indicator = Some((row, start_col, end_col)),
1143 ElementKind::Lsp => layout.lsp_indicator = Some((row, start_col, end_col)),
1144 ElementKind::WarningBadge => layout.warning_badge = Some((row, start_col, end_col)),
1145 ElementKind::Messages => layout.message_area = Some((row, start_col, end_col)),
1146 ElementKind::RemoteIndicator(_) => {
1147 layout.remote_indicator = Some((row, start_col, end_col))
1148 }
1149 _ => {}
1150 }
1151 }
1152
1153 fn element_spans(
1158 rendered: &RenderedElement,
1159 theme: &crate::view::theme::Theme,
1160 hover: StatusBarHover,
1161 warning_level: WarningLevel,
1162 lsp_state: LspIndicatorState,
1163 ) -> (Vec<Span<'static>>, usize) {
1164 let base_style = Style::default()
1165 .fg(theme.status_bar_fg)
1166 .bg(theme.status_bar_bg);
1167 let width = str_width(&rendered.text);
1168
1169 if rendered.kind == ElementKind::RemoteDisconnected && rendered.text.starts_with(SSH_PREFIX)
1170 {
1171 let error_style = Style::default()
1172 .fg(theme.status_error_indicator_fg)
1173 .bg(theme.status_error_indicator_bg);
1174 if let Some(term_off) = rendered.text.find(SSH_PREFIX_TERMINATOR) {
1175 let split_at = term_off + SSH_PREFIX_TERMINATOR.len();
1176 let prefix = rendered.text[..split_at].to_string();
1177 let rest = rendered.text[split_at..].to_string();
1178 return (
1179 vec![
1180 Span::styled(prefix, error_style),
1181 Span::styled(rest, base_style),
1182 ],
1183 width,
1184 );
1185 }
1186 return (
1187 vec![Span::styled(rendered.text.clone(), error_style)],
1188 width,
1189 );
1190 }
1191
1192 let style = Self::element_style(rendered.kind, theme, hover, warning_level, lsp_state);
1193 let spans = if rendered.kind == ElementKind::Clock {
1194 vec![
1196 Span::styled(rendered.text[..2].to_string(), style),
1197 Span::styled(":".to_string(), style.add_modifier(Modifier::SLOW_BLINK)),
1198 Span::styled(rendered.text[3..].to_string(), style),
1199 ]
1200 } else {
1201 vec![Span::styled(rendered.text.clone(), style)]
1202 };
1203 (spans, width)
1204 }
1205
1206 fn render_side(
1208 config_side: &[StatusBarElement],
1209 ctx: &mut StatusBarContext<'_>,
1210 ) -> Vec<(Vec<Span<'static>>, usize, ElementKind)> {
1211 let rendered: Vec<RenderedElement> = config_side
1212 .iter()
1213 .filter_map(|elem| Self::render_element(elem, ctx))
1214 .filter(|e| !e.text.is_empty())
1215 .collect();
1216
1217 let theme = ctx.theme;
1218 let hover = ctx.hover;
1219 let warning_level = ctx.warning_level;
1220 let lsp_state = ctx.lsp_indicator_state;
1221 rendered
1222 .into_iter()
1223 .map(|r| {
1224 let kind = r.kind;
1225 let (spans, width) =
1226 Self::element_spans(&r, theme, hover, warning_level, lsp_state);
1227 (spans, width, kind)
1228 })
1229 .collect()
1230 }
1231
1232 fn render_status(
1234 frame: &mut Frame,
1235 area: Rect,
1236 ctx: &mut StatusBarContext<'_>,
1237 config: &StatusBarConfig,
1238 ) -> StatusBarLayout {
1239 let mut layout = StatusBarLayout::default();
1240 let base_style = Style::default()
1241 .fg(ctx.theme.status_bar_fg)
1242 .bg(ctx.theme.status_bar_bg);
1243 let available_width = area.width as usize;
1244
1245 if available_width == 0 || area.height == 0 {
1246 return layout;
1247 }
1248
1249 ctx.remote_indicator_on_bar = config
1254 .left
1255 .iter()
1256 .chain(config.right.iter())
1257 .any(|e| matches!(e, StatusBarElement::RemoteIndicator));
1258
1259 let left_items = Self::render_side(&config.left, ctx);
1260 let mut right_items = Self::render_side(&config.right, ctx);
1261
1262 const SEPARATOR: &str = " | ";
1263 let separator_width = str_width(SEPARATOR);
1264
1265 let total_right_width: usize = right_items.iter().map(|(_, w, _)| *w).sum();
1274 let left_min_target = available_width
1275 .saturating_mul(2)
1276 .saturating_div(5) .min(40); let right_budget = available_width.saturating_sub(left_min_target + 1);
1279 if total_right_width > right_budget && right_items.len() > 1 {
1280 let mut current = total_right_width;
1281 while current > right_budget && right_items.len() > 1 {
1282 if let Some(dropped) = right_items.pop() {
1283 current = current.saturating_sub(dropped.1);
1284 } else {
1285 break;
1286 }
1287 }
1288 }
1289
1290 let right_width: usize = right_items.iter().map(|(_, w, _)| *w).sum();
1291
1292 let narrow = available_width < 15;
1293 let left_max_width = if narrow {
1294 available_width
1295 } else if available_width > right_width + 1 {
1296 available_width - right_width - 1
1297 } else {
1298 1
1299 };
1300
1301 let mut spans: Vec<Span<'static>> = Vec::new();
1305 let mut used_left: usize = 0;
1306
1307 for (idx, (item_spans, width, kind)) in left_items.into_iter().enumerate() {
1308 let sep_width = if idx == 0 { 0 } else { separator_width };
1309 if used_left + sep_width >= left_max_width {
1310 break;
1311 }
1312 if sep_width > 0 {
1313 spans.push(Span::styled(SEPARATOR, base_style));
1314 used_left += sep_width;
1315 }
1316
1317 let remaining = left_max_width - used_left;
1318 let start_col = used_left;
1319
1320 if width <= remaining {
1321 spans.extend(item_spans);
1322 used_left += width;
1323
1324 Self::update_layout_for_element(
1325 &mut layout,
1326 kind,
1327 area.y,
1328 area.x + start_col as u16,
1329 area.x + (start_col + width) as u16,
1330 );
1331 } else {
1332 let group_text: String = item_spans.iter().map(|s| s.content.as_ref()).collect();
1336 let truncated = truncate_to_width(&group_text, remaining);
1337 let truncated_width = str_width(&truncated);
1338 let overflow_style = Self::element_style(
1339 kind,
1340 ctx.theme,
1341 ctx.hover,
1342 ctx.warning_level,
1343 ctx.lsp_indicator_state,
1344 );
1345 spans.push(Span::styled(truncated, overflow_style));
1346 used_left += truncated_width;
1347
1348 Self::update_layout_for_element(
1349 &mut layout,
1350 kind,
1351 area.y,
1352 area.x + start_col as u16,
1353 area.x + (start_col + truncated_width) as u16,
1354 );
1355 break;
1356 }
1357 }
1358
1359 if narrow {
1360 if used_left < available_width {
1361 spans.push(Span::styled(
1362 " ".repeat(available_width - used_left),
1363 base_style,
1364 ));
1365 }
1366 frame.render_widget(Paragraph::new(Line::from(spans)), area);
1367 return layout;
1368 }
1369
1370 let mut col_offset = used_left;
1371 if col_offset + right_width < available_width {
1372 let padding = available_width - col_offset - right_width;
1373 spans.push(Span::styled(" ".repeat(padding), base_style));
1374 col_offset = available_width - right_width;
1375 } else if col_offset < available_width {
1376 spans.push(Span::styled(" ", base_style));
1377 col_offset += 1;
1378 }
1379
1380 let mut current_col = area.x + col_offset as u16;
1381 for (item_spans, width, kind) in right_items {
1382 Self::update_layout_for_element(
1383 &mut layout,
1384 kind,
1385 area.y,
1386 current_col,
1387 current_col + width as u16,
1388 );
1389 spans.extend(item_spans);
1390 current_col += width as u16;
1391 }
1392
1393 frame.render_widget(Paragraph::new(Line::from(spans)), area);
1394 layout
1395 }
1396
1397 #[allow(clippy::too_many_arguments)]
1408 pub fn render_search_options(
1409 frame: &mut Frame,
1410 area: Rect,
1411 case_sensitive: bool,
1412 whole_word: bool,
1413 use_regex: bool,
1414 confirm_each: Option<bool>, theme: &crate::view::theme::Theme,
1416 keybindings: &crate::input::keybindings::KeybindingResolver,
1417 hover: SearchOptionsHover,
1418 ) -> SearchOptionsLayout {
1419 use crate::primitives::display_width::str_width;
1420
1421 let mut layout = SearchOptionsLayout {
1422 row: area.y,
1423 ..Default::default()
1424 };
1425
1426 let base_style = Style::default()
1428 .fg(theme.menu_dropdown_fg)
1429 .bg(theme.menu_dropdown_bg);
1430
1431 let hover_style = Style::default()
1433 .fg(theme.menu_hover_fg)
1434 .bg(theme.menu_hover_bg);
1435
1436 let get_shortcut = |action: &crate::input::keybindings::Action| -> Option<String> {
1438 keybindings
1439 .get_keybinding_for_action(action, crate::input::keybindings::KeyContext::Prompt)
1440 .or_else(|| {
1441 keybindings.get_keybinding_for_action(
1442 action,
1443 crate::input::keybindings::KeyContext::Global,
1444 )
1445 })
1446 };
1447
1448 let case_shortcut =
1450 get_shortcut(&crate::input::keybindings::Action::ToggleSearchCaseSensitive);
1451 let word_shortcut = get_shortcut(&crate::input::keybindings::Action::ToggleSearchWholeWord);
1452 let regex_shortcut = get_shortcut(&crate::input::keybindings::Action::ToggleSearchRegex);
1453
1454 let case_checkbox = if case_sensitive { "[x]" } else { "[ ]" };
1456 let word_checkbox = if whole_word { "[x]" } else { "[ ]" };
1457 let regex_checkbox = if use_regex { "[x]" } else { "[ ]" };
1458
1459 let active_style = Style::default()
1461 .fg(theme.menu_highlight_fg)
1462 .bg(theme.menu_dropdown_bg);
1463
1464 let shortcut_style = Style::default()
1466 .fg(theme.help_separator_fg)
1467 .bg(theme.menu_dropdown_bg);
1468
1469 let hover_shortcut_style = Style::default()
1471 .fg(theme.menu_hover_fg)
1472 .bg(theme.menu_hover_bg);
1473
1474 let mut spans = Vec::new();
1475 let mut current_col = area.x;
1476
1477 spans.push(Span::styled(" ", base_style));
1479 current_col += 1;
1480
1481 let get_checkbox_style = |is_hovered: bool, is_checked: bool| -> Style {
1483 if is_hovered {
1484 hover_style
1485 } else if is_checked {
1486 active_style
1487 } else {
1488 base_style
1489 }
1490 };
1491
1492 let case_hovered = hover == SearchOptionsHover::CaseSensitive;
1494 let case_start = current_col;
1495 let case_label = format!("{} {}", case_checkbox, t!("search.case_sensitive"));
1496 let case_shortcut_text = case_shortcut
1497 .as_ref()
1498 .map(|s| format!(" ({})", s))
1499 .unwrap_or_default();
1500 let case_full_width = str_width(&case_label) + str_width(&case_shortcut_text);
1501
1502 spans.push(Span::styled(
1503 case_label,
1504 get_checkbox_style(case_hovered, case_sensitive),
1505 ));
1506 if !case_shortcut_text.is_empty() {
1507 spans.push(Span::styled(
1508 case_shortcut_text,
1509 if case_hovered {
1510 hover_shortcut_style
1511 } else {
1512 shortcut_style
1513 },
1514 ));
1515 }
1516 current_col += case_full_width as u16;
1517 layout.case_sensitive = Some((case_start, current_col));
1518
1519 spans.push(Span::styled(" ", base_style));
1521 current_col += 3;
1522
1523 let word_hovered = hover == SearchOptionsHover::WholeWord;
1525 let word_start = current_col;
1526 let word_label = format!("{} {}", word_checkbox, t!("search.whole_word"));
1527 let word_shortcut_text = word_shortcut
1528 .as_ref()
1529 .map(|s| format!(" ({})", s))
1530 .unwrap_or_default();
1531 let word_full_width = str_width(&word_label) + str_width(&word_shortcut_text);
1532
1533 spans.push(Span::styled(
1534 word_label,
1535 get_checkbox_style(word_hovered, whole_word),
1536 ));
1537 if !word_shortcut_text.is_empty() {
1538 spans.push(Span::styled(
1539 word_shortcut_text,
1540 if word_hovered {
1541 hover_shortcut_style
1542 } else {
1543 shortcut_style
1544 },
1545 ));
1546 }
1547 current_col += word_full_width as u16;
1548 layout.whole_word = Some((word_start, current_col));
1549
1550 spans.push(Span::styled(" ", base_style));
1552 current_col += 3;
1553
1554 let regex_hovered = hover == SearchOptionsHover::Regex;
1556 let regex_start = current_col;
1557 let regex_label = format!("{} {}", regex_checkbox, t!("search.regex"));
1558 let regex_shortcut_text = regex_shortcut
1559 .as_ref()
1560 .map(|s| format!(" ({})", s))
1561 .unwrap_or_default();
1562 let regex_full_width = str_width(®ex_label) + str_width(®ex_shortcut_text);
1563
1564 spans.push(Span::styled(
1565 regex_label,
1566 get_checkbox_style(regex_hovered, use_regex),
1567 ));
1568 if !regex_shortcut_text.is_empty() {
1569 spans.push(Span::styled(
1570 regex_shortcut_text,
1571 if regex_hovered {
1572 hover_shortcut_style
1573 } else {
1574 shortcut_style
1575 },
1576 ));
1577 }
1578 current_col += regex_full_width as u16;
1579 layout.regex = Some((regex_start, current_col));
1580
1581 if use_regex && confirm_each.is_some() {
1583 let hint = " \u{2502} $1,$2,…";
1584 spans.push(Span::styled(hint, shortcut_style));
1585 current_col += str_width(hint) as u16;
1586 }
1587
1588 if let Some(confirm_value) = confirm_each {
1590 let confirm_shortcut =
1591 get_shortcut(&crate::input::keybindings::Action::ToggleSearchConfirmEach);
1592 let confirm_checkbox = if confirm_value { "[x]" } else { "[ ]" };
1593
1594 spans.push(Span::styled(" ", base_style));
1596 current_col += 3;
1597
1598 let confirm_hovered = hover == SearchOptionsHover::ConfirmEach;
1599 let confirm_start = current_col;
1600 let confirm_label = format!("{} {}", confirm_checkbox, t!("search.confirm_each"));
1601 let confirm_shortcut_text = confirm_shortcut
1602 .as_ref()
1603 .map(|s| format!(" ({})", s))
1604 .unwrap_or_default();
1605 let confirm_full_width = str_width(&confirm_label) + str_width(&confirm_shortcut_text);
1606
1607 spans.push(Span::styled(
1608 confirm_label,
1609 get_checkbox_style(confirm_hovered, confirm_value),
1610 ));
1611 if !confirm_shortcut_text.is_empty() {
1612 spans.push(Span::styled(
1613 confirm_shortcut_text,
1614 if confirm_hovered {
1615 hover_shortcut_style
1616 } else {
1617 shortcut_style
1618 },
1619 ));
1620 }
1621 current_col += confirm_full_width as u16;
1622 layout.confirm_each = Some((confirm_start, current_col));
1623 }
1624
1625 let current_width = (current_col - area.x) as usize;
1627 let available_width = area.width as usize;
1628 if current_width < available_width {
1629 spans.push(Span::styled(
1630 " ".repeat(available_width.saturating_sub(current_width)),
1631 base_style,
1632 ));
1633 }
1634
1635 let options_line = Paragraph::new(Line::from(spans));
1636 frame.render_widget(options_line, area);
1637
1638 layout
1639 }
1640}
1641
1642#[cfg(test)]
1643mod tests {
1644 use super::*;
1645 use std::path::PathBuf;
1646
1647 #[test]
1648 fn test_truncate_path_short_path() {
1649 let path = PathBuf::from("/home/user/project");
1650 let result = truncate_path(&path, 50);
1651
1652 assert!(!result.truncated);
1653 assert_eq!(result.suffix, "/home/user/project");
1654 assert!(result.prefix.is_empty());
1655 }
1656
1657 #[test]
1658 fn test_truncate_path_long_path() {
1659 let path = PathBuf::from(
1660 "/private/var/folders/p6/nlmq3k8146990kpkxl73mq340000gn/T/.tmpNYt4Fc/project_root",
1661 );
1662 let result = truncate_path(&path, 40);
1663
1664 assert!(result.truncated, "Path should be truncated");
1665 assert_eq!(result.prefix, "/private");
1666 assert!(
1667 result.suffix.contains("project_root"),
1668 "Suffix should contain project_root"
1669 );
1670 }
1671
1672 #[test]
1673 fn test_truncate_path_preserves_last_components() {
1674 let path = PathBuf::from("/a/b/c/d/e/f/g/h/i/j/project/src");
1675 let result = truncate_path(&path, 30);
1676
1677 assert!(result.truncated);
1678 assert!(
1680 result.suffix.contains("src"),
1681 "Should preserve last component 'src', got: {}",
1682 result.suffix
1683 );
1684 }
1685
1686 #[test]
1687 fn test_truncate_path_display_len() {
1688 let path = PathBuf::from("/private/var/folders/deep/nested/path/here");
1689 let result = truncate_path(&path, 30);
1690
1691 let display = result.to_string_plain();
1693 assert!(
1694 display.len() <= 35, "Display should be truncated to around 30 chars, got {} chars: {}",
1696 display.len(),
1697 display
1698 );
1699 }
1700
1701 #[test]
1702 fn test_truncate_path_root_only() {
1703 let path = PathBuf::from("/");
1704 let result = truncate_path(&path, 50);
1705
1706 assert!(!result.truncated);
1707 assert_eq!(result.suffix, "/");
1708 }
1709
1710 #[test]
1711 fn test_truncate_path_multibyte_single_component_does_not_panic() {
1712 let path = PathBuf::from("/ユーザーのプロジェクト名前/file");
1718 let result = truncate_path(&path, 5);
1719 let display = result.to_string_plain();
1720 assert!(display.is_char_boundary(display.len()));
1721 assert!(display.ends_with("..."));
1722 }
1723
1724 #[test]
1725 fn test_truncate_path_multibyte_last_component_does_not_panic() {
1726 let path = PathBuf::from("/a/ユーザーのプロジェクト名前");
1733 let result = truncate_path(&path, 13);
1734 let display = result.to_string_plain();
1735 assert!(display.is_char_boundary(display.len()));
1736 }
1737
1738 #[test]
1739 fn test_truncated_path_to_string_plain() {
1740 let truncated = TruncatedPath {
1741 prefix: "/home".to_string(),
1742 truncated: true,
1743 suffix: "/project/src".to_string(),
1744 };
1745
1746 assert_eq!(truncated.to_string_plain(), "/home/[...]/project/src");
1747 }
1748
1749 #[test]
1750 fn test_truncated_path_to_string_plain_no_truncation() {
1751 let truncated = TruncatedPath {
1752 prefix: String::new(),
1753 truncated: false,
1754 suffix: "/home/user/project".to_string(),
1755 };
1756
1757 assert_eq!(truncated.to_string_plain(), "/home/user/project");
1758 }
1759
1760 #[test]
1761 fn test_remote_indicator_element_kind_equality() {
1762 assert_eq!(
1766 ElementKind::RemoteIndicator(RemoteIndicatorState::Local),
1767 ElementKind::RemoteIndicator(RemoteIndicatorState::Local)
1768 );
1769 let distinct = [
1770 RemoteIndicatorState::Local,
1771 RemoteIndicatorState::Connecting,
1772 RemoteIndicatorState::Connected,
1773 RemoteIndicatorState::FailedAttach,
1774 RemoteIndicatorState::Disconnected,
1775 ];
1776 for (i, a) in distinct.iter().enumerate() {
1777 for (j, b) in distinct.iter().enumerate() {
1778 if i == j {
1779 continue;
1780 }
1781 assert_ne!(
1782 ElementKind::RemoteIndicator(*a),
1783 ElementKind::RemoteIndicator(*b),
1784 "expected {:?} != {:?}",
1785 a,
1786 b
1787 );
1788 }
1789 }
1790 }
1791
1792 #[test]
1793 fn test_remote_indicator_state_default_is_local() {
1794 assert_eq!(RemoteIndicatorState::default(), RemoteIndicatorState::Local);
1797 }
1798
1799 #[test]
1800 fn test_remote_indicator_override_deserializes_kind_tags() {
1801 let cases: &[(&str, RemoteIndicatorOverride)] = &[
1805 (r#"{"kind":"local"}"#, RemoteIndicatorOverride::Local),
1806 (
1807 r#"{"kind":"connecting","label":"Building"}"#,
1808 RemoteIndicatorOverride::Connecting {
1809 label: Some("Building".into()),
1810 },
1811 ),
1812 (
1813 r#"{"kind":"connecting"}"#,
1814 RemoteIndicatorOverride::Connecting { label: None },
1815 ),
1816 (
1817 r#"{"kind":"connected","label":"Container:abc"}"#,
1818 RemoteIndicatorOverride::Connected {
1819 label: Some("Container:abc".into()),
1820 },
1821 ),
1822 (
1823 r#"{"kind":"failed_attach","error":"exit 1"}"#,
1824 RemoteIndicatorOverride::FailedAttach {
1825 error: Some("exit 1".into()),
1826 },
1827 ),
1828 (
1829 r#"{"kind":"disconnected","label":"Container:abc"}"#,
1830 RemoteIndicatorOverride::Disconnected {
1831 label: Some("Container:abc".into()),
1832 },
1833 ),
1834 ];
1835 for (json, expected) in cases {
1836 let parsed: RemoteIndicatorOverride = serde_json::from_str(json)
1837 .unwrap_or_else(|e| panic!("failed to parse {}: {}", json, e));
1838 assert_eq!(&parsed, expected, "wire shape mismatch for {}", json);
1839 }
1840 }
1841
1842 #[test]
1843 fn test_remote_indicator_override_labels() {
1844 let connecting = RemoteIndicatorOverride::Connecting { label: None };
1848 assert!(
1849 connecting.label().contains("Connecting"),
1850 "connecting default label should mention Connecting, got {:?}",
1851 connecting.label()
1852 );
1853
1854 let connecting_labeled = RemoteIndicatorOverride::Connecting {
1855 label: Some("Building".into()),
1856 };
1857 assert!(
1858 connecting_labeled.label().contains("Building"),
1859 "labeled connecting should include the label, got {:?}",
1860 connecting_labeled.label()
1861 );
1862
1863 let failed_bare = RemoteIndicatorOverride::FailedAttach { error: None };
1864 assert_eq!(failed_bare.label(), "Attach failed");
1865
1866 let failed_detail = RemoteIndicatorOverride::FailedAttach {
1867 error: Some("exit 1".into()),
1868 };
1869 assert!(
1870 failed_detail.label().contains("exit 1"),
1871 "failed with error should include the error, got {:?}",
1872 failed_detail.label()
1873 );
1874 }
1875
1876 #[test]
1877 fn test_palette_and_lsp_on_use_dedicated_theme_keys() {
1878 let theme = crate::view::theme::Theme::from_json(
1890 r#"{"name":"t","editor":{},"ui":{},"search":{},"diagnostic":{},"syntax":{}}"#,
1891 )
1892 .expect("minimal theme should parse");
1893
1894 assert_eq!(theme.status_palette_fg, theme.status_bar_fg);
1896 assert_eq!(theme.status_palette_bg, theme.status_bar_bg);
1897 assert_eq!(theme.status_lsp_on_fg, theme.status_bar_fg);
1898 assert_eq!(theme.status_lsp_on_bg, theme.status_bar_bg);
1899
1900 let palette_style = StatusBarRenderer::element_style(
1901 ElementKind::Palette,
1902 &theme,
1903 StatusBarHover::None,
1904 WarningLevel::None,
1905 LspIndicatorState::None,
1906 );
1907 assert_eq!(palette_style.fg, Some(theme.status_palette_fg));
1908 assert_eq!(palette_style.bg, Some(theme.status_palette_bg));
1909
1910 let lsp_on_style = StatusBarRenderer::element_style(
1911 ElementKind::Lsp,
1912 &theme,
1913 StatusBarHover::None,
1914 WarningLevel::None,
1915 LspIndicatorState::On,
1916 );
1917 assert_eq!(lsp_on_style.fg, Some(theme.status_lsp_on_fg));
1918 assert_eq!(lsp_on_style.bg, Some(theme.status_lsp_on_bg));
1919
1920 let lsp_off_style = StatusBarRenderer::element_style(
1923 ElementKind::Lsp,
1924 &theme,
1925 StatusBarHover::None,
1926 WarningLevel::None,
1927 LspIndicatorState::Off,
1928 );
1929 assert_eq!(lsp_off_style.fg, Some(theme.status_lsp_actionable_fg));
1930 assert_eq!(lsp_off_style.bg, Some(theme.status_lsp_actionable_bg));
1931
1932 let lsp_error_style = StatusBarRenderer::element_style(
1933 ElementKind::Lsp,
1934 &theme,
1935 StatusBarHover::None,
1936 WarningLevel::None,
1937 LspIndicatorState::Error,
1938 );
1939 assert_eq!(lsp_error_style.fg, Some(theme.diagnostic_error_fg));
1940 assert_eq!(lsp_error_style.bg, Some(theme.diagnostic_error_bg));
1941 }
1942
1943 #[test]
1944 fn test_status_palette_and_lsp_on_keys_override_independently() {
1945 let theme_json = r#"{
1951 "name":"t",
1952 "editor":{},
1953 "ui":{
1954 "status_bar_fg":"White",
1955 "status_bar_bg":"DarkGray",
1956 "status_palette_fg":"Black",
1957 "status_palette_bg":"Yellow",
1958 "status_lsp_on_fg":"Black",
1959 "status_lsp_on_bg":"Cyan"
1960 },
1961 "search":{},
1962 "diagnostic":{},
1963 "syntax":{}
1964 }"#;
1965 let theme = crate::view::theme::Theme::from_json(theme_json).expect("theme should parse");
1966 assert_ne!(theme.status_palette_fg, theme.status_bar_fg);
1967 assert_ne!(theme.status_palette_bg, theme.status_bar_bg);
1968 assert_ne!(theme.status_lsp_on_fg, theme.status_bar_fg);
1969 assert_ne!(theme.status_lsp_on_bg, theme.status_bar_bg);
1970 }
1971
1972 #[test]
1973 fn test_remote_indicator_override_state_projection() {
1974 assert_eq!(
1975 RemoteIndicatorOverride::Local.state(),
1976 RemoteIndicatorState::Local
1977 );
1978 assert_eq!(
1979 RemoteIndicatorOverride::Connecting { label: None }.state(),
1980 RemoteIndicatorState::Connecting
1981 );
1982 assert_eq!(
1983 RemoteIndicatorOverride::Connected { label: None }.state(),
1984 RemoteIndicatorState::Connected
1985 );
1986 assert_eq!(
1987 RemoteIndicatorOverride::FailedAttach { error: None }.state(),
1988 RemoteIndicatorState::FailedAttach
1989 );
1990 assert_eq!(
1991 RemoteIndicatorOverride::Disconnected { label: None }.state(),
1992 RemoteIndicatorState::Disconnected
1993 );
1994 }
1995}