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 {
1044 LspIndicatorState::Error => {
1045 (theme.diagnostic_error_fg, theme.diagnostic_error_bg)
1046 }
1047 LspIndicatorState::Off => {
1048 (theme.diagnostic_warning_fg, theme.diagnostic_warning_bg)
1049 }
1050 LspIndicatorState::On => (theme.status_lsp_on_fg, theme.status_lsp_on_bg),
1057 LspIndicatorState::OffDismissed => (theme.status_bar_fg, theme.status_bar_bg),
1065 LspIndicatorState::None => (theme.status_bar_fg, theme.status_bar_bg),
1066 };
1067 let mut style = Style::default().fg(fg).bg(bg);
1068 if is_hovering && lsp_state != LspIndicatorState::None {
1073 style = style.add_modifier(Modifier::UNDERLINED);
1074 }
1075 style
1076 }
1077 ElementKind::WarningBadge => {
1078 let is_hovering = hover == StatusBarHover::WarningBadge;
1079 let (fg, bg) = if is_hovering {
1080 (
1081 theme.status_warning_indicator_hover_fg,
1082 theme.status_warning_indicator_hover_bg,
1083 )
1084 } else {
1085 (
1086 theme.status_warning_indicator_fg,
1087 theme.status_warning_indicator_bg,
1088 )
1089 };
1090 let mut style = Style::default().fg(fg).bg(bg);
1091 if is_hovering {
1092 style = style.add_modifier(Modifier::UNDERLINED);
1093 }
1094 style
1095 }
1096 ElementKind::Update => Style::default()
1097 .fg(theme.menu_highlight_fg)
1098 .bg(theme.menu_dropdown_bg),
1099 ElementKind::Palette => Style::default()
1104 .fg(theme.status_palette_fg)
1105 .bg(theme.status_palette_bg),
1106 ElementKind::RemoteIndicator(state) => {
1107 let is_hovering = hover == StatusBarHover::RemoteIndicator;
1108 let (fg, bg) = match state {
1109 RemoteIndicatorState::Connecting | RemoteIndicatorState::Connected => {
1115 (theme.help_indicator_fg, theme.help_indicator_bg)
1116 }
1117 RemoteIndicatorState::FailedAttach | RemoteIndicatorState::Disconnected => (
1121 theme.status_error_indicator_fg,
1122 theme.status_error_indicator_bg,
1123 ),
1124 RemoteIndicatorState::Local => (theme.status_bar_fg, theme.status_bar_bg),
1126 };
1127 let mut style = Style::default().fg(fg).bg(bg);
1128 if is_hovering {
1129 style = style.add_modifier(Modifier::UNDERLINED);
1130 }
1131 style
1132 }
1133 }
1134 }
1135
1136 fn update_layout_for_element(
1138 layout: &mut StatusBarLayout,
1139 kind: ElementKind,
1140 row: u16,
1141 start_col: u16,
1142 end_col: u16,
1143 ) {
1144 match kind {
1145 ElementKind::LineEnding => {
1146 layout.line_ending_indicator = Some((row, start_col, end_col))
1147 }
1148 ElementKind::Encoding => layout.encoding_indicator = Some((row, start_col, end_col)),
1149 ElementKind::Language => layout.language_indicator = Some((row, start_col, end_col)),
1150 ElementKind::Lsp => layout.lsp_indicator = Some((row, start_col, end_col)),
1151 ElementKind::WarningBadge => layout.warning_badge = Some((row, start_col, end_col)),
1152 ElementKind::Messages => layout.message_area = Some((row, start_col, end_col)),
1153 ElementKind::RemoteIndicator(_) => {
1154 layout.remote_indicator = Some((row, start_col, end_col))
1155 }
1156 _ => {}
1157 }
1158 }
1159
1160 fn element_spans(
1165 rendered: &RenderedElement,
1166 theme: &crate::view::theme::Theme,
1167 hover: StatusBarHover,
1168 warning_level: WarningLevel,
1169 lsp_state: LspIndicatorState,
1170 ) -> (Vec<Span<'static>>, usize) {
1171 let base_style = Style::default()
1172 .fg(theme.status_bar_fg)
1173 .bg(theme.status_bar_bg);
1174 let width = str_width(&rendered.text);
1175
1176 if rendered.kind == ElementKind::RemoteDisconnected && rendered.text.starts_with(SSH_PREFIX)
1177 {
1178 let error_style = Style::default()
1179 .fg(theme.status_error_indicator_fg)
1180 .bg(theme.status_error_indicator_bg);
1181 if let Some(term_off) = rendered.text.find(SSH_PREFIX_TERMINATOR) {
1182 let split_at = term_off + SSH_PREFIX_TERMINATOR.len();
1183 let prefix = rendered.text[..split_at].to_string();
1184 let rest = rendered.text[split_at..].to_string();
1185 return (
1186 vec![
1187 Span::styled(prefix, error_style),
1188 Span::styled(rest, base_style),
1189 ],
1190 width,
1191 );
1192 }
1193 return (
1194 vec![Span::styled(rendered.text.clone(), error_style)],
1195 width,
1196 );
1197 }
1198
1199 let style = Self::element_style(rendered.kind, theme, hover, warning_level, lsp_state);
1200 let spans = if rendered.kind == ElementKind::Clock {
1201 vec![
1203 Span::styled(rendered.text[..2].to_string(), style),
1204 Span::styled(":".to_string(), style.add_modifier(Modifier::SLOW_BLINK)),
1205 Span::styled(rendered.text[3..].to_string(), style),
1206 ]
1207 } else {
1208 vec![Span::styled(rendered.text.clone(), style)]
1209 };
1210 (spans, width)
1211 }
1212
1213 fn render_side(
1215 config_side: &[StatusBarElement],
1216 ctx: &mut StatusBarContext<'_>,
1217 ) -> Vec<(Vec<Span<'static>>, usize, ElementKind)> {
1218 let rendered: Vec<RenderedElement> = config_side
1219 .iter()
1220 .filter_map(|elem| Self::render_element(elem, ctx))
1221 .filter(|e| !e.text.is_empty())
1222 .collect();
1223
1224 let theme = ctx.theme;
1225 let hover = ctx.hover;
1226 let warning_level = ctx.warning_level;
1227 let lsp_state = ctx.lsp_indicator_state;
1228 rendered
1229 .into_iter()
1230 .map(|r| {
1231 let kind = r.kind;
1232 let (spans, width) =
1233 Self::element_spans(&r, theme, hover, warning_level, lsp_state);
1234 (spans, width, kind)
1235 })
1236 .collect()
1237 }
1238
1239 fn render_status(
1241 frame: &mut Frame,
1242 area: Rect,
1243 ctx: &mut StatusBarContext<'_>,
1244 config: &StatusBarConfig,
1245 ) -> StatusBarLayout {
1246 let mut layout = StatusBarLayout::default();
1247 let base_style = Style::default()
1248 .fg(ctx.theme.status_bar_fg)
1249 .bg(ctx.theme.status_bar_bg);
1250 let available_width = area.width as usize;
1251
1252 if available_width == 0 || area.height == 0 {
1253 return layout;
1254 }
1255
1256 ctx.remote_indicator_on_bar = config
1261 .left
1262 .iter()
1263 .chain(config.right.iter())
1264 .any(|e| matches!(e, StatusBarElement::RemoteIndicator));
1265
1266 let left_items = Self::render_side(&config.left, ctx);
1267 let mut right_items = Self::render_side(&config.right, ctx);
1268
1269 const SEPARATOR: &str = " | ";
1270 let separator_width = str_width(SEPARATOR);
1271
1272 let total_right_width: usize = right_items.iter().map(|(_, w, _)| *w).sum();
1281 let left_min_target = available_width
1282 .saturating_mul(2)
1283 .saturating_div(5) .min(40); let right_budget = available_width.saturating_sub(left_min_target + 1);
1286 if total_right_width > right_budget && right_items.len() > 1 {
1287 let mut current = total_right_width;
1288 while current > right_budget && right_items.len() > 1 {
1289 if let Some(dropped) = right_items.pop() {
1290 current = current.saturating_sub(dropped.1);
1291 } else {
1292 break;
1293 }
1294 }
1295 }
1296
1297 let right_width: usize = right_items.iter().map(|(_, w, _)| *w).sum();
1298
1299 let narrow = available_width < 15;
1300 let left_max_width = if narrow {
1301 available_width
1302 } else if available_width > right_width + 1 {
1303 available_width - right_width - 1
1304 } else {
1305 1
1306 };
1307
1308 let mut spans: Vec<Span<'static>> = Vec::new();
1312 let mut used_left: usize = 0;
1313
1314 for (idx, (item_spans, width, kind)) in left_items.into_iter().enumerate() {
1315 let sep_width = if idx == 0 { 0 } else { separator_width };
1316 if used_left + sep_width >= left_max_width {
1317 break;
1318 }
1319 if sep_width > 0 {
1320 spans.push(Span::styled(SEPARATOR, base_style));
1321 used_left += sep_width;
1322 }
1323
1324 let remaining = left_max_width - used_left;
1325 let start_col = used_left;
1326
1327 if width <= remaining {
1328 spans.extend(item_spans);
1329 used_left += width;
1330
1331 Self::update_layout_for_element(
1332 &mut layout,
1333 kind,
1334 area.y,
1335 area.x + start_col as u16,
1336 area.x + (start_col + width) as u16,
1337 );
1338 } else {
1339 let group_text: String = item_spans.iter().map(|s| s.content.as_ref()).collect();
1343 let truncated = truncate_to_width(&group_text, remaining);
1344 let truncated_width = str_width(&truncated);
1345 let overflow_style = Self::element_style(
1346 kind,
1347 ctx.theme,
1348 ctx.hover,
1349 ctx.warning_level,
1350 ctx.lsp_indicator_state,
1351 );
1352 spans.push(Span::styled(truncated, overflow_style));
1353 used_left += truncated_width;
1354
1355 Self::update_layout_for_element(
1356 &mut layout,
1357 kind,
1358 area.y,
1359 area.x + start_col as u16,
1360 area.x + (start_col + truncated_width) as u16,
1361 );
1362 break;
1363 }
1364 }
1365
1366 if narrow {
1367 if used_left < available_width {
1368 spans.push(Span::styled(
1369 " ".repeat(available_width - used_left),
1370 base_style,
1371 ));
1372 }
1373 frame.render_widget(Paragraph::new(Line::from(spans)), area);
1374 return layout;
1375 }
1376
1377 let mut col_offset = used_left;
1378 if col_offset + right_width < available_width {
1379 let padding = available_width - col_offset - right_width;
1380 spans.push(Span::styled(" ".repeat(padding), base_style));
1381 col_offset = available_width - right_width;
1382 } else if col_offset < available_width {
1383 spans.push(Span::styled(" ", base_style));
1384 col_offset += 1;
1385 }
1386
1387 let mut current_col = area.x + col_offset as u16;
1388 for (item_spans, width, kind) in right_items {
1389 Self::update_layout_for_element(
1390 &mut layout,
1391 kind,
1392 area.y,
1393 current_col,
1394 current_col + width as u16,
1395 );
1396 spans.extend(item_spans);
1397 current_col += width as u16;
1398 }
1399
1400 frame.render_widget(Paragraph::new(Line::from(spans)), area);
1401 layout
1402 }
1403
1404 #[allow(clippy::too_many_arguments)]
1415 pub fn render_search_options(
1416 frame: &mut Frame,
1417 area: Rect,
1418 case_sensitive: bool,
1419 whole_word: bool,
1420 use_regex: bool,
1421 confirm_each: Option<bool>, theme: &crate::view::theme::Theme,
1423 keybindings: &crate::input::keybindings::KeybindingResolver,
1424 hover: SearchOptionsHover,
1425 ) -> SearchOptionsLayout {
1426 use crate::primitives::display_width::str_width;
1427
1428 let mut layout = SearchOptionsLayout {
1429 row: area.y,
1430 ..Default::default()
1431 };
1432
1433 let base_style = Style::default()
1435 .fg(theme.menu_dropdown_fg)
1436 .bg(theme.menu_dropdown_bg);
1437
1438 let hover_style = Style::default()
1440 .fg(theme.menu_hover_fg)
1441 .bg(theme.menu_hover_bg);
1442
1443 let get_shortcut = |action: &crate::input::keybindings::Action| -> Option<String> {
1445 keybindings
1446 .get_keybinding_for_action(action, crate::input::keybindings::KeyContext::Prompt)
1447 .or_else(|| {
1448 keybindings.get_keybinding_for_action(
1449 action,
1450 crate::input::keybindings::KeyContext::Global,
1451 )
1452 })
1453 };
1454
1455 let case_shortcut =
1457 get_shortcut(&crate::input::keybindings::Action::ToggleSearchCaseSensitive);
1458 let word_shortcut = get_shortcut(&crate::input::keybindings::Action::ToggleSearchWholeWord);
1459 let regex_shortcut = get_shortcut(&crate::input::keybindings::Action::ToggleSearchRegex);
1460
1461 let case_checkbox = if case_sensitive { "[x]" } else { "[ ]" };
1463 let word_checkbox = if whole_word { "[x]" } else { "[ ]" };
1464 let regex_checkbox = if use_regex { "[x]" } else { "[ ]" };
1465
1466 let active_style = Style::default()
1468 .fg(theme.menu_highlight_fg)
1469 .bg(theme.menu_dropdown_bg);
1470
1471 let shortcut_style = Style::default()
1473 .fg(theme.help_separator_fg)
1474 .bg(theme.menu_dropdown_bg);
1475
1476 let hover_shortcut_style = Style::default()
1478 .fg(theme.menu_hover_fg)
1479 .bg(theme.menu_hover_bg);
1480
1481 let mut spans = Vec::new();
1482 let mut current_col = area.x;
1483
1484 spans.push(Span::styled(" ", base_style));
1486 current_col += 1;
1487
1488 let get_checkbox_style = |is_hovered: bool, is_checked: bool| -> Style {
1490 if is_hovered {
1491 hover_style
1492 } else if is_checked {
1493 active_style
1494 } else {
1495 base_style
1496 }
1497 };
1498
1499 let case_hovered = hover == SearchOptionsHover::CaseSensitive;
1501 let case_start = current_col;
1502 let case_label = format!("{} {}", case_checkbox, t!("search.case_sensitive"));
1503 let case_shortcut_text = case_shortcut
1504 .as_ref()
1505 .map(|s| format!(" ({})", s))
1506 .unwrap_or_default();
1507 let case_full_width = str_width(&case_label) + str_width(&case_shortcut_text);
1508
1509 spans.push(Span::styled(
1510 case_label,
1511 get_checkbox_style(case_hovered, case_sensitive),
1512 ));
1513 if !case_shortcut_text.is_empty() {
1514 spans.push(Span::styled(
1515 case_shortcut_text,
1516 if case_hovered {
1517 hover_shortcut_style
1518 } else {
1519 shortcut_style
1520 },
1521 ));
1522 }
1523 current_col += case_full_width as u16;
1524 layout.case_sensitive = Some((case_start, current_col));
1525
1526 spans.push(Span::styled(" ", base_style));
1528 current_col += 3;
1529
1530 let word_hovered = hover == SearchOptionsHover::WholeWord;
1532 let word_start = current_col;
1533 let word_label = format!("{} {}", word_checkbox, t!("search.whole_word"));
1534 let word_shortcut_text = word_shortcut
1535 .as_ref()
1536 .map(|s| format!(" ({})", s))
1537 .unwrap_or_default();
1538 let word_full_width = str_width(&word_label) + str_width(&word_shortcut_text);
1539
1540 spans.push(Span::styled(
1541 word_label,
1542 get_checkbox_style(word_hovered, whole_word),
1543 ));
1544 if !word_shortcut_text.is_empty() {
1545 spans.push(Span::styled(
1546 word_shortcut_text,
1547 if word_hovered {
1548 hover_shortcut_style
1549 } else {
1550 shortcut_style
1551 },
1552 ));
1553 }
1554 current_col += word_full_width as u16;
1555 layout.whole_word = Some((word_start, current_col));
1556
1557 spans.push(Span::styled(" ", base_style));
1559 current_col += 3;
1560
1561 let regex_hovered = hover == SearchOptionsHover::Regex;
1563 let regex_start = current_col;
1564 let regex_label = format!("{} {}", regex_checkbox, t!("search.regex"));
1565 let regex_shortcut_text = regex_shortcut
1566 .as_ref()
1567 .map(|s| format!(" ({})", s))
1568 .unwrap_or_default();
1569 let regex_full_width = str_width(®ex_label) + str_width(®ex_shortcut_text);
1570
1571 spans.push(Span::styled(
1572 regex_label,
1573 get_checkbox_style(regex_hovered, use_regex),
1574 ));
1575 if !regex_shortcut_text.is_empty() {
1576 spans.push(Span::styled(
1577 regex_shortcut_text,
1578 if regex_hovered {
1579 hover_shortcut_style
1580 } else {
1581 shortcut_style
1582 },
1583 ));
1584 }
1585 current_col += regex_full_width as u16;
1586 layout.regex = Some((regex_start, current_col));
1587
1588 if use_regex && confirm_each.is_some() {
1590 let hint = " \u{2502} $1,$2,…";
1591 spans.push(Span::styled(hint, shortcut_style));
1592 current_col += str_width(hint) as u16;
1593 }
1594
1595 if let Some(confirm_value) = confirm_each {
1597 let confirm_shortcut =
1598 get_shortcut(&crate::input::keybindings::Action::ToggleSearchConfirmEach);
1599 let confirm_checkbox = if confirm_value { "[x]" } else { "[ ]" };
1600
1601 spans.push(Span::styled(" ", base_style));
1603 current_col += 3;
1604
1605 let confirm_hovered = hover == SearchOptionsHover::ConfirmEach;
1606 let confirm_start = current_col;
1607 let confirm_label = format!("{} {}", confirm_checkbox, t!("search.confirm_each"));
1608 let confirm_shortcut_text = confirm_shortcut
1609 .as_ref()
1610 .map(|s| format!(" ({})", s))
1611 .unwrap_or_default();
1612 let confirm_full_width = str_width(&confirm_label) + str_width(&confirm_shortcut_text);
1613
1614 spans.push(Span::styled(
1615 confirm_label,
1616 get_checkbox_style(confirm_hovered, confirm_value),
1617 ));
1618 if !confirm_shortcut_text.is_empty() {
1619 spans.push(Span::styled(
1620 confirm_shortcut_text,
1621 if confirm_hovered {
1622 hover_shortcut_style
1623 } else {
1624 shortcut_style
1625 },
1626 ));
1627 }
1628 current_col += confirm_full_width as u16;
1629 layout.confirm_each = Some((confirm_start, current_col));
1630 }
1631
1632 let current_width = (current_col - area.x) as usize;
1634 let available_width = area.width as usize;
1635 if current_width < available_width {
1636 spans.push(Span::styled(
1637 " ".repeat(available_width.saturating_sub(current_width)),
1638 base_style,
1639 ));
1640 }
1641
1642 let options_line = Paragraph::new(Line::from(spans));
1643 frame.render_widget(options_line, area);
1644
1645 layout
1646 }
1647}
1648
1649#[cfg(test)]
1650mod tests {
1651 use super::*;
1652 use std::path::PathBuf;
1653
1654 #[test]
1655 fn test_truncate_path_short_path() {
1656 let path = PathBuf::from("/home/user/project");
1657 let result = truncate_path(&path, 50);
1658
1659 assert!(!result.truncated);
1660 assert_eq!(result.suffix, "/home/user/project");
1661 assert!(result.prefix.is_empty());
1662 }
1663
1664 #[test]
1665 fn test_truncate_path_long_path() {
1666 let path = PathBuf::from(
1667 "/private/var/folders/p6/nlmq3k8146990kpkxl73mq340000gn/T/.tmpNYt4Fc/project_root",
1668 );
1669 let result = truncate_path(&path, 40);
1670
1671 assert!(result.truncated, "Path should be truncated");
1672 assert_eq!(result.prefix, "/private");
1673 assert!(
1674 result.suffix.contains("project_root"),
1675 "Suffix should contain project_root"
1676 );
1677 }
1678
1679 #[test]
1680 fn test_truncate_path_preserves_last_components() {
1681 let path = PathBuf::from("/a/b/c/d/e/f/g/h/i/j/project/src");
1682 let result = truncate_path(&path, 30);
1683
1684 assert!(result.truncated);
1685 assert!(
1687 result.suffix.contains("src"),
1688 "Should preserve last component 'src', got: {}",
1689 result.suffix
1690 );
1691 }
1692
1693 #[test]
1694 fn test_truncate_path_display_len() {
1695 let path = PathBuf::from("/private/var/folders/deep/nested/path/here");
1696 let result = truncate_path(&path, 30);
1697
1698 let display = result.to_string_plain();
1700 assert!(
1701 display.len() <= 35, "Display should be truncated to around 30 chars, got {} chars: {}",
1703 display.len(),
1704 display
1705 );
1706 }
1707
1708 #[test]
1709 fn test_truncate_path_root_only() {
1710 let path = PathBuf::from("/");
1711 let result = truncate_path(&path, 50);
1712
1713 assert!(!result.truncated);
1714 assert_eq!(result.suffix, "/");
1715 }
1716
1717 #[test]
1718 fn test_truncate_path_multibyte_single_component_does_not_panic() {
1719 let path = PathBuf::from("/ユーザーのプロジェクト名前/file");
1725 let result = truncate_path(&path, 5);
1726 let display = result.to_string_plain();
1727 assert!(display.is_char_boundary(display.len()));
1728 assert!(display.ends_with("..."));
1729 }
1730
1731 #[test]
1732 fn test_truncate_path_multibyte_last_component_does_not_panic() {
1733 let path = PathBuf::from("/a/ユーザーのプロジェクト名前");
1740 let result = truncate_path(&path, 13);
1741 let display = result.to_string_plain();
1742 assert!(display.is_char_boundary(display.len()));
1743 }
1744
1745 #[test]
1746 fn test_truncated_path_to_string_plain() {
1747 let truncated = TruncatedPath {
1748 prefix: "/home".to_string(),
1749 truncated: true,
1750 suffix: "/project/src".to_string(),
1751 };
1752
1753 assert_eq!(truncated.to_string_plain(), "/home/[...]/project/src");
1754 }
1755
1756 #[test]
1757 fn test_truncated_path_to_string_plain_no_truncation() {
1758 let truncated = TruncatedPath {
1759 prefix: String::new(),
1760 truncated: false,
1761 suffix: "/home/user/project".to_string(),
1762 };
1763
1764 assert_eq!(truncated.to_string_plain(), "/home/user/project");
1765 }
1766
1767 #[test]
1768 fn test_remote_indicator_element_kind_equality() {
1769 assert_eq!(
1773 ElementKind::RemoteIndicator(RemoteIndicatorState::Local),
1774 ElementKind::RemoteIndicator(RemoteIndicatorState::Local)
1775 );
1776 let distinct = [
1777 RemoteIndicatorState::Local,
1778 RemoteIndicatorState::Connecting,
1779 RemoteIndicatorState::Connected,
1780 RemoteIndicatorState::FailedAttach,
1781 RemoteIndicatorState::Disconnected,
1782 ];
1783 for (i, a) in distinct.iter().enumerate() {
1784 for (j, b) in distinct.iter().enumerate() {
1785 if i == j {
1786 continue;
1787 }
1788 assert_ne!(
1789 ElementKind::RemoteIndicator(*a),
1790 ElementKind::RemoteIndicator(*b),
1791 "expected {:?} != {:?}",
1792 a,
1793 b
1794 );
1795 }
1796 }
1797 }
1798
1799 #[test]
1800 fn test_remote_indicator_state_default_is_local() {
1801 assert_eq!(RemoteIndicatorState::default(), RemoteIndicatorState::Local);
1804 }
1805
1806 #[test]
1807 fn test_remote_indicator_override_deserializes_kind_tags() {
1808 let cases: &[(&str, RemoteIndicatorOverride)] = &[
1812 (r#"{"kind":"local"}"#, RemoteIndicatorOverride::Local),
1813 (
1814 r#"{"kind":"connecting","label":"Building"}"#,
1815 RemoteIndicatorOverride::Connecting {
1816 label: Some("Building".into()),
1817 },
1818 ),
1819 (
1820 r#"{"kind":"connecting"}"#,
1821 RemoteIndicatorOverride::Connecting { label: None },
1822 ),
1823 (
1824 r#"{"kind":"connected","label":"Container:abc"}"#,
1825 RemoteIndicatorOverride::Connected {
1826 label: Some("Container:abc".into()),
1827 },
1828 ),
1829 (
1830 r#"{"kind":"failed_attach","error":"exit 1"}"#,
1831 RemoteIndicatorOverride::FailedAttach {
1832 error: Some("exit 1".into()),
1833 },
1834 ),
1835 (
1836 r#"{"kind":"disconnected","label":"Container:abc"}"#,
1837 RemoteIndicatorOverride::Disconnected {
1838 label: Some("Container:abc".into()),
1839 },
1840 ),
1841 ];
1842 for (json, expected) in cases {
1843 let parsed: RemoteIndicatorOverride = serde_json::from_str(json)
1844 .unwrap_or_else(|e| panic!("failed to parse {}: {}", json, e));
1845 assert_eq!(&parsed, expected, "wire shape mismatch for {}", json);
1846 }
1847 }
1848
1849 #[test]
1850 fn test_remote_indicator_override_labels() {
1851 let connecting = RemoteIndicatorOverride::Connecting { label: None };
1855 assert!(
1856 connecting.label().contains("Connecting"),
1857 "connecting default label should mention Connecting, got {:?}",
1858 connecting.label()
1859 );
1860
1861 let connecting_labeled = RemoteIndicatorOverride::Connecting {
1862 label: Some("Building".into()),
1863 };
1864 assert!(
1865 connecting_labeled.label().contains("Building"),
1866 "labeled connecting should include the label, got {:?}",
1867 connecting_labeled.label()
1868 );
1869
1870 let failed_bare = RemoteIndicatorOverride::FailedAttach { error: None };
1871 assert_eq!(failed_bare.label(), "Attach failed");
1872
1873 let failed_detail = RemoteIndicatorOverride::FailedAttach {
1874 error: Some("exit 1".into()),
1875 };
1876 assert!(
1877 failed_detail.label().contains("exit 1"),
1878 "failed with error should include the error, got {:?}",
1879 failed_detail.label()
1880 );
1881 }
1882
1883 #[test]
1884 fn test_palette_and_lsp_on_use_dedicated_theme_keys() {
1885 let theme = crate::view::theme::Theme::from_json(
1897 r#"{"name":"t","editor":{},"ui":{},"search":{},"diagnostic":{},"syntax":{}}"#,
1898 )
1899 .expect("minimal theme should parse");
1900
1901 assert_eq!(theme.status_palette_fg, theme.status_bar_fg);
1903 assert_eq!(theme.status_palette_bg, theme.status_bar_bg);
1904 assert_eq!(theme.status_lsp_on_fg, theme.status_bar_fg);
1905 assert_eq!(theme.status_lsp_on_bg, theme.status_bar_bg);
1906
1907 let palette_style = StatusBarRenderer::element_style(
1908 ElementKind::Palette,
1909 &theme,
1910 StatusBarHover::None,
1911 WarningLevel::None,
1912 LspIndicatorState::None,
1913 );
1914 assert_eq!(palette_style.fg, Some(theme.status_palette_fg));
1915 assert_eq!(palette_style.bg, Some(theme.status_palette_bg));
1916
1917 let lsp_on_style = StatusBarRenderer::element_style(
1918 ElementKind::Lsp,
1919 &theme,
1920 StatusBarHover::None,
1921 WarningLevel::None,
1922 LspIndicatorState::On,
1923 );
1924 assert_eq!(lsp_on_style.fg, Some(theme.status_lsp_on_fg));
1925 assert_eq!(lsp_on_style.bg, Some(theme.status_lsp_on_bg));
1926
1927 let lsp_off_style = StatusBarRenderer::element_style(
1930 ElementKind::Lsp,
1931 &theme,
1932 StatusBarHover::None,
1933 WarningLevel::None,
1934 LspIndicatorState::Off,
1935 );
1936 assert_eq!(lsp_off_style.fg, Some(theme.diagnostic_warning_fg));
1937 assert_eq!(lsp_off_style.bg, Some(theme.diagnostic_warning_bg));
1938
1939 let lsp_error_style = StatusBarRenderer::element_style(
1940 ElementKind::Lsp,
1941 &theme,
1942 StatusBarHover::None,
1943 WarningLevel::None,
1944 LspIndicatorState::Error,
1945 );
1946 assert_eq!(lsp_error_style.fg, Some(theme.diagnostic_error_fg));
1947 assert_eq!(lsp_error_style.bg, Some(theme.diagnostic_error_bg));
1948 }
1949
1950 #[test]
1951 fn test_status_palette_and_lsp_on_keys_override_independently() {
1952 let theme_json = r#"{
1958 "name":"t",
1959 "editor":{},
1960 "ui":{
1961 "status_bar_fg":"White",
1962 "status_bar_bg":"DarkGray",
1963 "status_palette_fg":"Black",
1964 "status_palette_bg":"Yellow",
1965 "status_lsp_on_fg":"Black",
1966 "status_lsp_on_bg":"Cyan"
1967 },
1968 "search":{},
1969 "diagnostic":{},
1970 "syntax":{}
1971 }"#;
1972 let theme = crate::view::theme::Theme::from_json(theme_json).expect("theme should parse");
1973 assert_ne!(theme.status_palette_fg, theme.status_bar_fg);
1974 assert_ne!(theme.status_palette_bg, theme.status_bar_bg);
1975 assert_ne!(theme.status_lsp_on_fg, theme.status_bar_fg);
1976 assert_ne!(theme.status_lsp_on_bg, theme.status_bar_bg);
1977 }
1978
1979 #[test]
1980 fn test_remote_indicator_override_state_projection() {
1981 assert_eq!(
1982 RemoteIndicatorOverride::Local.state(),
1983 RemoteIndicatorState::Local
1984 );
1985 assert_eq!(
1986 RemoteIndicatorOverride::Connecting { label: None }.state(),
1987 RemoteIndicatorState::Connecting
1988 );
1989 assert_eq!(
1990 RemoteIndicatorOverride::Connected { label: None }.state(),
1991 RemoteIndicatorState::Connected
1992 );
1993 assert_eq!(
1994 RemoteIndicatorOverride::FailedAttach { error: None }.state(),
1995 RemoteIndicatorState::FailedAttach
1996 );
1997 assert_eq!(
1998 RemoteIndicatorOverride::Disconnected { label: None }.state(),
1999 RemoteIndicatorState::Disconnected
2000 );
2001 }
2002}