1use std::collections::HashMap;
4use std::path::Path;
5
6use crate::app::WarningLevel;
7use crate::config::{StatusBarConfig, StatusBarElement};
8use crate::primitives::display_width::{char_width, str_width};
9use crate::state::EditorState;
10use crate::view::prompt::Prompt;
11use chrono::Timelike;
12use ratatui::layout::Rect;
13use ratatui::style::{Modifier, Style};
14use ratatui::text::{Line, Span};
15use ratatui::widgets::Paragraph;
16use ratatui::Frame;
17use rust_i18n::t;
18
19const SSH_PREFIX: &str = "[SSH:";
23const SSH_PREFIX_TERMINATOR: &str = "] ";
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27enum ElementKind {
28 Normal,
30 LineEnding,
32 Encoding,
34 Language,
36 Lsp,
38 WarningBadge,
40 Update,
42 Palette,
44 Messages,
46 RemoteDisconnected,
48 Clock,
50 RemoteIndicator(RemoteIndicatorState),
52 Custom,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
70pub enum RemoteIndicatorState {
71 #[default]
73 Local,
74 Connecting,
79 Connected,
81 FailedAttach,
85 Disconnected,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
100#[serde(tag = "kind", rename_all = "snake_case")]
101pub enum RemoteIndicatorOverride {
102 Local,
105 Connecting {
108 #[serde(default)]
109 label: Option<String>,
110 },
111 Connected {
114 #[serde(default)]
115 label: Option<String>,
116 },
117 FailedAttach {
120 #[serde(default)]
121 error: Option<String>,
122 },
123 Disconnected {
126 #[serde(default)]
127 label: Option<String>,
128 },
129}
130
131impl RemoteIndicatorOverride {
132 pub fn state(&self) -> RemoteIndicatorState {
134 match self {
135 Self::Local => RemoteIndicatorState::Local,
136 Self::Connecting { .. } => RemoteIndicatorState::Connecting,
137 Self::Connected { .. } => RemoteIndicatorState::Connected,
138 Self::FailedAttach { .. } => RemoteIndicatorState::FailedAttach,
139 Self::Disconnected { .. } => RemoteIndicatorState::Disconnected,
140 }
141 }
142
143 pub fn label(&self) -> String {
147 match self {
148 Self::Local => "Local".to_string(),
149 Self::Connecting { label } => match label {
150 Some(s) if !s.is_empty() => format!("⠿ {}", s),
151 _ => "⠿ Connecting".to_string(),
152 },
153 Self::Connected { label } => label
154 .as_deref()
155 .filter(|s| !s.is_empty())
156 .unwrap_or("Connected")
157 .to_string(),
158 Self::FailedAttach { error } => match error {
159 Some(s) if !s.is_empty() => format!("Attach failed: {}", s),
160 _ => "Attach failed".to_string(),
161 },
162 Self::Disconnected { label } => match label {
163 Some(s) if !s.is_empty() => format!("{} (Disconnected)", s),
164 _ => "Disconnected".to_string(),
165 },
166 }
167 }
168}
169
170struct RenderedElement {
172 text: String,
173 kind: ElementKind,
174}
175
176#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
192pub enum LspIndicatorState {
193 #[default]
194 None,
195 On,
196 Off,
197 OffDismissed,
198 Error,
199}
200
201pub struct StatusBarContext<'a> {
203 pub state: &'a mut EditorState,
204 pub cursors: &'a crate::model::cursor::Cursors,
205 pub status_message: &'a Option<String>,
206 pub plugin_status_message: &'a Option<String>,
207 pub lsp_status: &'a str,
208 pub lsp_indicator_state: LspIndicatorState,
213 pub theme: &'a crate::view::theme::Theme,
214 pub display_name: &'a str,
215 pub keybindings: &'a crate::input::keybindings::KeybindingResolver,
216 pub chord_state: &'a [(crossterm::event::KeyCode, crossterm::event::KeyModifiers)],
217 pub update_available: Option<&'a str>,
218 pub warning_level: WarningLevel,
219 pub general_warning_count: usize,
220 pub hover: StatusBarHover,
221 pub remote_connection: Option<&'a str>,
222 pub session_name: Option<&'a str>,
223 pub read_only: bool,
224 pub remote_state_override: Option<&'a RemoteIndicatorOverride>,
231 pub is_synthetic_placeholder: bool,
237 pub remote_indicator_on_bar: bool,
246 pub dynamic_status_bar_elements: HashMap<String, String>,
250}
251
252#[derive(Debug, Clone, Default)]
254pub struct StatusBarLayout {
255 pub lsp_indicator: Option<(u16, u16, u16)>,
257 pub warning_badge: Option<(u16, u16, u16)>,
259 pub line_ending_indicator: Option<(u16, u16, u16)>,
261 pub encoding_indicator: Option<(u16, u16, u16)>,
263 pub language_indicator: Option<(u16, u16, u16)>,
265 pub message_area: Option<(u16, u16, u16)>,
267 pub remote_indicator: Option<(u16, u16, u16)>,
270}
271
272#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
274pub enum StatusBarHover {
275 #[default]
276 None,
277 LspIndicator,
279 WarningBadge,
281 LineEndingIndicator,
283 EncodingIndicator,
285 LanguageIndicator,
287 MessageArea,
289 RemoteIndicator,
291}
292
293#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
295pub enum SearchOptionsHover {
296 #[default]
297 None,
298 CaseSensitive,
299 WholeWord,
300 Regex,
301 ConfirmEach,
302}
303
304#[derive(Debug, Clone, Default)]
306pub struct SearchOptionsLayout {
307 pub row: u16,
309 pub case_sensitive: Option<(u16, u16)>,
311 pub whole_word: Option<(u16, u16)>,
313 pub regex: Option<(u16, u16)>,
315 pub confirm_each: Option<(u16, u16)>,
317}
318
319impl SearchOptionsLayout {
320 pub fn checkbox_at(&self, x: u16, y: u16) -> Option<SearchOptionsHover> {
322 if y != self.row {
323 return None;
324 }
325
326 if let Some((start, end)) = self.case_sensitive {
327 if x >= start && x < end {
328 return Some(SearchOptionsHover::CaseSensitive);
329 }
330 }
331 if let Some((start, end)) = self.whole_word {
332 if x >= start && x < end {
333 return Some(SearchOptionsHover::WholeWord);
334 }
335 }
336 if let Some((start, end)) = self.regex {
337 if x >= start && x < end {
338 return Some(SearchOptionsHover::Regex);
339 }
340 }
341 if let Some((start, end)) = self.confirm_each {
342 if x >= start && x < end {
343 return Some(SearchOptionsHover::ConfirmEach);
344 }
345 }
346 None
347 }
348}
349
350#[derive(Debug, Clone)]
352pub struct TruncatedPath {
353 pub prefix: String,
355 pub truncated: bool,
357 pub suffix: String,
359}
360
361impl TruncatedPath {
362 pub fn to_string_plain(&self) -> String {
364 if self.truncated {
365 format!("{}/[...]{}", self.prefix, self.suffix)
366 } else {
367 format!("{}{}", self.prefix, self.suffix)
368 }
369 }
370
371 pub fn display_len(&self) -> usize {
373 if self.truncated {
374 self.prefix.len() + "/[...]".len() + self.suffix.len()
375 } else {
376 self.prefix.len() + self.suffix.len()
377 }
378 }
379}
380
381pub fn truncate_path(path: &Path, max_len: usize) -> TruncatedPath {
393 let path_str = path.to_string_lossy();
394
395 if path_str.len() <= max_len {
397 return TruncatedPath {
398 prefix: String::new(),
399 truncated: false,
400 suffix: path_str.to_string(),
401 };
402 }
403
404 let components: Vec<&str> = path_str.split('/').filter(|s| !s.is_empty()).collect();
405
406 if components.is_empty() {
407 return TruncatedPath {
408 prefix: "/".to_string(),
409 truncated: false,
410 suffix: String::new(),
411 };
412 }
413
414 let prefix = if path_str.starts_with('/') {
416 format!("/{}", components.first().unwrap_or(&""))
417 } else {
418 components.first().unwrap_or(&"").to_string()
419 };
420
421 let ellipsis_len = "/[...]".len();
423
424 let available_for_suffix = max_len.saturating_sub(prefix.len() + ellipsis_len);
426
427 if available_for_suffix < 5 || components.len() <= 1 {
428 let truncated_path = if path_str.len() > max_len.saturating_sub(3) {
433 let cut = path_str.floor_char_boundary(max_len.saturating_sub(3));
434 format!("{}...", &path_str[..cut])
435 } else {
436 path_str.to_string()
437 };
438 return TruncatedPath {
439 prefix: String::new(),
440 truncated: false,
441 suffix: truncated_path,
442 };
443 }
444
445 let mut suffix_parts: Vec<&str> = Vec::new();
447 let mut suffix_len = 0;
448
449 for component in components.iter().skip(1).rev() {
450 let component_len = component.len() + 1; if suffix_len + component_len <= available_for_suffix {
452 suffix_parts.push(component);
453 suffix_len += component_len;
454 } else {
455 break;
456 }
457 }
458
459 suffix_parts.reverse();
460
461 if suffix_parts.len() == components.len() - 1 {
463 return TruncatedPath {
464 prefix: String::new(),
465 truncated: false,
466 suffix: path_str.to_string(),
467 };
468 }
469
470 let suffix = if suffix_parts.is_empty() {
471 let last = components.last().unwrap_or(&"");
475 let truncate_to = available_for_suffix.saturating_sub(4); if truncate_to > 0 && last.len() > truncate_to {
477 let cut = last.floor_char_boundary(truncate_to);
478 format!("/{}...", &last[..cut])
479 } else {
480 format!("/{}", last)
481 }
482 } else {
483 format!("/{}", suffix_parts.join("/"))
484 };
485
486 TruncatedPath {
487 prefix,
488 truncated: true,
489 suffix,
490 }
491}
492
493fn truncate_to_width(s: &str, max_width: usize) -> String {
495 let width = str_width(s);
496 if width <= max_width {
497 return s.to_string();
498 }
499 let truncate_at = max_width.saturating_sub(3);
500 if truncate_at == 0 {
501 return if max_width >= 3 {
502 "...".to_string()
503 } else {
504 s.chars().take(max_width).collect()
505 };
506 }
507 let mut w = 0;
508 let truncated: String = s
509 .chars()
510 .take_while(|ch| {
511 let cw = char_width(*ch);
512 if w + cw <= truncate_at {
513 w += cw;
514 true
515 } else {
516 false
517 }
518 })
519 .collect();
520 format!("{}...", truncated)
521}
522
523const CURSOR_COL_RESERVE: usize = 3;
528
529fn format_cursor_position(line: usize, col: usize, line_count: usize) -> String {
537 let text = format!("Ln {line}, Col {col}");
538 let line_digits = line_count.max(1).to_string().len();
539 let min_width = 9 + line_digits + CURSOR_COL_RESERVE;
541 if text.len() < min_width {
542 format!("{text:<min_width$}")
543 } else {
544 text
545 }
546}
547
548fn format_cursor_position_compact(line: usize, col: usize, line_count: usize) -> String {
552 let text = format!("{line}:{col}");
553 let line_digits = line_count.max(1).to_string().len();
554 let min_width = 1 + line_digits + CURSOR_COL_RESERVE;
556 if text.len() < min_width {
557 format!("{text:<min_width$}")
558 } else {
559 text
560 }
561}
562
563pub struct StatusBarRenderer;
565
566impl StatusBarRenderer {
567 pub fn render_status_bar(
571 frame: &mut Frame,
572 area: Rect,
573 ctx: &mut StatusBarContext<'_>,
574 config: &StatusBarConfig,
575 ) -> StatusBarLayout {
576 Self::render_status(frame, area, ctx, config)
577 }
578
579 pub fn render_prompt(
581 frame: &mut Frame,
582 area: Rect,
583 prompt: &Prompt,
584 theme: &crate::view::theme::Theme,
585 ) {
586 let base_style = Style::default().fg(theme.prompt_fg).bg(theme.prompt_bg);
587
588 let mut spans = vec![Span::styled(prompt.message.clone(), base_style)];
590
591 if let Some((sel_start, sel_end)) = prompt.selection_range() {
593 let input = &prompt.input;
594
595 if sel_start > 0 {
597 spans.push(Span::styled(input[..sel_start].to_string(), base_style));
598 }
599
600 if sel_start < sel_end {
602 let selection_style = Style::default()
604 .fg(theme.prompt_selection_fg)
605 .bg(theme.prompt_selection_bg);
606 spans.push(Span::styled(
607 input[sel_start..sel_end].to_string(),
608 selection_style,
609 ));
610 }
611
612 if sel_end < input.len() {
614 spans.push(Span::styled(input[sel_end..].to_string(), base_style));
615 }
616 } else {
617 spans.push(Span::styled(prompt.input.clone(), base_style));
619 }
620
621 let line = Line::from(spans);
622 let prompt_line = Paragraph::new(line).style(base_style);
623
624 frame.render_widget(prompt_line, area);
625
626 let message_width = str_width(&prompt.message);
631 let input_width_before_cursor = str_width(&prompt.input[..prompt.cursor_pos]);
632 let cursor_x = (message_width + input_width_before_cursor) as u16;
633 if cursor_x < area.width {
634 frame.set_cursor_position((area.x + cursor_x, area.y));
635 }
636 }
637
638 pub fn render_file_open_prompt(
642 frame: &mut Frame,
643 area: Rect,
644 prompt: &Prompt,
645 file_open_state: &crate::app::file_open::FileOpenState,
646 theme: &crate::view::theme::Theme,
647 ) {
648 let base_style = Style::default().fg(theme.prompt_fg).bg(theme.prompt_bg);
649 let dir_style = Style::default()
650 .fg(theme.help_separator_fg)
651 .bg(theme.prompt_bg);
652 let ellipsis_style = Style::default()
654 .fg(theme.menu_highlight_fg)
655 .bg(theme.prompt_bg);
656
657 let mut spans = Vec::new();
658
659 let open_prompt = t!("file.open_prompt").to_string();
661 spans.push(Span::styled(open_prompt.clone(), base_style));
662
663 let prefix_len = str_width(&open_prompt);
666 let dir_path = file_open_state.current_dir.to_string_lossy();
667 let dir_path_len = dir_path.len() + 1; let input_len = prompt.input.len();
669 let total_len = prefix_len + dir_path_len + input_len;
670 let threshold = (area.width as usize * 90) / 100;
671
672 let truncated = if total_len > threshold {
674 let available_for_path = threshold
676 .saturating_sub(prefix_len)
677 .saturating_sub(input_len);
678 truncate_path(&file_open_state.current_dir, available_for_path)
679 } else {
680 TruncatedPath {
682 prefix: String::new(),
683 truncated: false,
684 suffix: dir_path.to_string(),
685 }
686 };
687
688 if truncated.truncated {
690 spans.push(Span::styled(truncated.prefix.clone(), dir_style));
692 spans.push(Span::styled("/[...]", ellipsis_style));
694 let suffix_with_slash = if truncated.suffix.ends_with('/') {
696 truncated.suffix.clone()
697 } else {
698 format!("{}/", truncated.suffix)
699 };
700 spans.push(Span::styled(suffix_with_slash, dir_style));
701 } else {
702 let path_display = if truncated.suffix.ends_with('/') {
704 truncated.suffix.clone()
705 } else {
706 format!("{}/", truncated.suffix)
707 };
708 spans.push(Span::styled(path_display, dir_style));
709 }
710
711 spans.push(Span::styled(prompt.input.clone(), base_style));
713
714 let line = Line::from(spans);
715 let prompt_line = Paragraph::new(line).style(base_style);
716
717 frame.render_widget(prompt_line, area);
718
719 let prefix_width = str_width(&open_prompt);
723 let dir_display_width = if truncated.truncated {
724 let suffix_with_slash = if truncated.suffix.ends_with('/') {
725 &truncated.suffix
726 } else {
727 &truncated.suffix
729 };
730 str_width(&truncated.prefix) + str_width("/[...]") + str_width(suffix_with_slash) + 1
731 } else {
732 str_width(&truncated.suffix) + 1 };
734 let input_width_before_cursor = str_width(&prompt.input[..prompt.cursor_pos]);
735 let cursor_x = (prefix_width + dir_display_width + input_width_before_cursor) as u16;
736 if cursor_x < area.width {
737 frame.set_cursor_position((area.x + cursor_x, area.y));
738 }
739 }
740
741 fn render_element(
744 element: &StatusBarElement,
745 ctx: &mut StatusBarContext<'_>,
746 ) -> Option<RenderedElement> {
747 if ctx.is_synthetic_placeholder
752 && matches!(
753 element,
754 StatusBarElement::Filename
755 | StatusBarElement::Cursor
756 | StatusBarElement::CursorCompact
757 | StatusBarElement::CursorCount
758 | StatusBarElement::Diagnostics
759 | StatusBarElement::LineEnding
760 | StatusBarElement::Encoding
761 | StatusBarElement::Language
762 )
763 {
764 return None;
765 }
766 match element {
767 StatusBarElement::Filename => {
768 let modified = if ctx.state.buffer.is_modified() {
769 " [+]"
770 } else {
771 ""
772 };
773 let read_only_indicator = if ctx.read_only { " [RO]" } else { "" };
774 let remote_disconnected = ctx
775 .remote_connection
776 .map(|conn| conn.contains("(Disconnected)"))
777 .unwrap_or(false);
778 let remote_prefix = if ctx.remote_indicator_on_bar {
785 String::new()
786 } else {
787 ctx.remote_connection
788 .map(|conn| {
789 if conn.starts_with("Container:") {
790 format!("[{}] ", conn)
791 } else {
792 format!("{SSH_PREFIX}{conn}{SSH_PREFIX_TERMINATOR}")
793 }
794 })
795 .unwrap_or_default()
796 };
797 let session_prefix = ctx
798 .session_name
799 .map(|name| format!("[{}] ", name))
800 .unwrap_or_default();
801 let display_name = ctx.display_name;
802 let text = format!(
803 "{session_prefix}{remote_prefix}{display_name}{modified}{read_only_indicator}"
804 );
805 let kind = if remote_disconnected {
806 ElementKind::RemoteDisconnected
807 } else {
808 ElementKind::Normal
809 };
810 Some(RenderedElement { text, kind })
811 }
812 StatusBarElement::Cursor => {
813 if !ctx.state.show_cursors {
814 return None;
815 }
816 let cursor = *ctx.cursors.primary();
817 let line_count = ctx.state.buffer.line_count();
818 let text = if let Some(lc) = line_count {
819 let cursor_iter = ctx.state.buffer.line_iterator(cursor.position, 80);
820 let line_start = cursor_iter.current_position();
821 let col = cursor.position.saturating_sub(line_start);
822 let line = ctx.state.primary_cursor_line_number.value();
823 format_cursor_position(line + 1, col + 1, lc)
824 } else {
825 format!("Byte {}", cursor.position)
826 };
827 Some(RenderedElement {
828 text,
829 kind: ElementKind::Normal,
830 })
831 }
832 StatusBarElement::CursorCompact => {
833 if !ctx.state.show_cursors {
834 return None;
835 }
836 let cursor = *ctx.cursors.primary();
837 let line_count = ctx.state.buffer.line_count();
838 let text = if let Some(lc) = line_count {
839 let cursor_iter = ctx.state.buffer.line_iterator(cursor.position, 80);
840 let line_start = cursor_iter.current_position();
841 let col = cursor.position.saturating_sub(line_start);
842 let line = ctx.state.primary_cursor_line_number.value();
843 format_cursor_position_compact(line + 1, col + 1, lc)
844 } else {
845 format!("{}", cursor.position)
846 };
847 Some(RenderedElement {
848 text,
849 kind: ElementKind::Normal,
850 })
851 }
852 StatusBarElement::Diagnostics => {
853 let diagnostics = ctx.state.overlays.all();
854 let mut error_count = 0usize;
855 let mut warning_count = 0usize;
856 let mut info_count = 0usize;
857 let diagnostic_ns = crate::services::lsp::diagnostics::lsp_diagnostic_namespace();
858 for overlay in diagnostics {
859 if overlay.namespace.as_ref() == Some(&diagnostic_ns) {
860 match overlay.priority {
861 100 => error_count += 1,
862 50 => warning_count += 1,
863 _ => info_count += 1,
864 }
865 }
866 }
867 if error_count + warning_count + info_count == 0 {
868 return None;
869 }
870 let mut parts = Vec::new();
871 if error_count > 0 {
872 parts.push(format!("E:{}", error_count));
873 }
874 if warning_count > 0 {
875 parts.push(format!("W:{}", warning_count));
876 }
877 if info_count > 0 {
878 parts.push(format!("I:{}", info_count));
879 }
880 Some(RenderedElement {
881 text: parts.join(" "),
882 kind: ElementKind::Normal,
883 })
884 }
885 StatusBarElement::CursorCount => {
886 if ctx.cursors.count() <= 1 {
887 return None;
888 }
889 Some(RenderedElement {
890 text: t!("status.cursors", count = ctx.cursors.count()).to_string(),
891 kind: ElementKind::Normal,
892 })
893 }
894 StatusBarElement::Messages => {
895 let mut parts: Vec<&str> = Vec::new();
896 if let Some(msg) = ctx.status_message {
897 if !msg.is_empty() {
898 parts.push(msg);
899 }
900 }
901 if let Some(msg) = ctx.plugin_status_message {
902 if !msg.is_empty() {
903 parts.push(msg);
904 }
905 }
906 if parts.is_empty() {
907 return None;
908 }
909 Some(RenderedElement {
910 text: parts.join(" | "),
911 kind: ElementKind::Messages,
912 })
913 }
914 StatusBarElement::Chord => {
915 if ctx.chord_state.is_empty() {
916 return None;
917 }
918 let chord_str = ctx
919 .chord_state
920 .iter()
921 .map(|(code, modifiers)| {
922 crate::input::keybindings::format_keybinding(code, modifiers)
923 })
924 .collect::<Vec<_>>()
925 .join(" ");
926 Some(RenderedElement {
927 text: format!("[{}]", chord_str),
928 kind: ElementKind::Normal,
929 })
930 }
931 StatusBarElement::LineEnding => Some(RenderedElement {
932 text: format!(" {} ", ctx.state.buffer.line_ending().display_name()),
933 kind: ElementKind::LineEnding,
934 }),
935 StatusBarElement::Encoding => Some(RenderedElement {
936 text: format!(" {} ", ctx.state.buffer.encoding().display_name()),
937 kind: ElementKind::Encoding,
938 }),
939 StatusBarElement::Language => {
940 let text = if ctx.state.language == "text"
941 && ctx.state.display_name != "Text"
942 && ctx.state.display_name != "Plain Text"
943 && ctx.state.display_name != "text"
944 {
945 format!(" {} [syntax only] ", &ctx.state.display_name)
946 } else {
947 format!(" {} ", &ctx.state.display_name)
948 };
949 Some(RenderedElement {
950 text,
951 kind: ElementKind::Language,
952 })
953 }
954 StatusBarElement::Lsp => {
955 if ctx.lsp_status.is_empty() {
956 return None;
957 }
958 Some(RenderedElement {
959 text: format!(" {} ", ctx.lsp_status),
960 kind: ElementKind::Lsp,
961 })
962 }
963 StatusBarElement::Warnings => {
964 if ctx.general_warning_count == 0 {
965 return None;
966 }
967 Some(RenderedElement {
968 text: format!(" [\u{26a0} {}] ", ctx.general_warning_count),
969 kind: ElementKind::WarningBadge,
970 })
971 }
972 StatusBarElement::Update => {
973 let version = ctx.update_available?;
974 Some(RenderedElement {
975 text: format!(" {} ", t!("status.update_available", version = version)),
976 kind: ElementKind::Update,
977 })
978 }
979 StatusBarElement::Palette => {
980 let shortcut = ctx
981 .keybindings
982 .get_keybinding_for_action(
983 &crate::input::keybindings::Action::QuickOpen,
984 crate::input::keybindings::KeyContext::Global,
985 )
986 .unwrap_or_else(|| "?".to_string());
987 Some(RenderedElement {
988 text: format!(" {} ", t!("status.palette", shortcut = shortcut)),
989 kind: ElementKind::Palette,
990 })
991 }
992 StatusBarElement::Clock => {
993 let now = chrono::Local::now();
994 let text = format!("{:02}:{:02}", now.hour(), now.minute());
995 Some(RenderedElement {
996 text,
997 kind: ElementKind::Clock,
998 })
999 }
1000 StatusBarElement::RemoteIndicator => {
1001 let (text, state) = if let Some(over) = ctx.remote_state_override {
1011 (format!(" {} ", over.label()), over.state())
1012 } else {
1013 match ctx.remote_connection {
1014 None => (" Local ".to_string(), RemoteIndicatorState::Local),
1015 Some(conn) if conn.contains("(Disconnected)") => {
1016 (format!(" {} ", conn), RemoteIndicatorState::Disconnected)
1017 }
1018 Some(conn) => (format!(" {} ", conn), RemoteIndicatorState::Connected),
1019 }
1020 };
1021 Some(RenderedElement {
1022 text,
1023 kind: ElementKind::RemoteIndicator(state),
1024 })
1025 }
1026 StatusBarElement::CustomToken(key) => {
1027 if let Some(value) = ctx.dynamic_status_bar_elements.get(key) {
1028 Some(RenderedElement {
1029 text: value.clone(),
1030 kind: ElementKind::Custom,
1031 })
1032 } else {
1033 None }
1035 }
1036 }
1037 }
1038
1039 fn element_style(
1041 kind: ElementKind,
1042 theme: &crate::view::theme::Theme,
1043 hover: StatusBarHover,
1044 _warning_level: WarningLevel,
1045 lsp_state: LspIndicatorState,
1046 ) -> Style {
1047 match kind {
1048 ElementKind::Normal | ElementKind::Messages | ElementKind::Clock => Style::default()
1049 .fg(theme.status_bar_fg)
1050 .bg(theme.status_bar_bg),
1051 ElementKind::RemoteDisconnected => Style::default()
1052 .fg(theme.status_error_indicator_fg)
1053 .bg(theme.status_error_indicator_bg),
1054 ElementKind::LineEnding => {
1055 let is_hovering = hover == StatusBarHover::LineEndingIndicator;
1056 let (fg, bg) = if is_hovering {
1057 (theme.menu_hover_fg, theme.menu_hover_bg)
1058 } else {
1059 (theme.status_bar_fg, theme.status_bar_bg)
1060 };
1061 let mut style = Style::default().fg(fg).bg(bg);
1062 if is_hovering {
1063 style = style.add_modifier(Modifier::UNDERLINED);
1064 }
1065 style
1066 }
1067 ElementKind::Encoding => {
1068 let is_hovering = hover == StatusBarHover::EncodingIndicator;
1069 let (fg, bg) = if is_hovering {
1070 (theme.menu_hover_fg, theme.menu_hover_bg)
1071 } else {
1072 (theme.status_bar_fg, theme.status_bar_bg)
1073 };
1074 let mut style = Style::default().fg(fg).bg(bg);
1075 if is_hovering {
1076 style = style.add_modifier(Modifier::UNDERLINED);
1077 }
1078 style
1079 }
1080 ElementKind::Language => {
1081 let is_hovering = hover == StatusBarHover::LanguageIndicator;
1082 let (fg, bg) = if is_hovering {
1083 (theme.menu_hover_fg, theme.menu_hover_bg)
1084 } else {
1085 (theme.status_bar_fg, theme.status_bar_bg)
1086 };
1087 let mut style = Style::default().fg(fg).bg(bg);
1088 if is_hovering {
1089 style = style.add_modifier(Modifier::UNDERLINED);
1090 }
1091 style
1092 }
1093 ElementKind::Lsp => {
1094 let is_hovering = hover == StatusBarHover::LspIndicator;
1095 let (fg, bg) = match lsp_state {
1106 LspIndicatorState::Error => {
1107 (theme.diagnostic_error_fg, theme.diagnostic_error_bg)
1108 }
1109 LspIndicatorState::Off => (
1110 theme.status_lsp_actionable_fg,
1111 theme.status_lsp_actionable_bg,
1112 ),
1113 LspIndicatorState::On => (theme.status_lsp_on_fg, theme.status_lsp_on_bg),
1114 LspIndicatorState::OffDismissed => (theme.status_bar_fg, theme.status_bar_bg),
1115 LspIndicatorState::None => (theme.status_bar_fg, theme.status_bar_bg),
1116 };
1117 let mut style = Style::default().fg(fg).bg(bg);
1118 if is_hovering && lsp_state != LspIndicatorState::None {
1123 style = style.add_modifier(Modifier::UNDERLINED);
1124 }
1125 style
1126 }
1127 ElementKind::WarningBadge => {
1128 let is_hovering = hover == StatusBarHover::WarningBadge;
1129 let (fg, bg) = if is_hovering {
1130 (
1131 theme.status_warning_indicator_hover_fg,
1132 theme.status_warning_indicator_hover_bg,
1133 )
1134 } else {
1135 (
1136 theme.status_warning_indicator_fg,
1137 theme.status_warning_indicator_bg,
1138 )
1139 };
1140 let mut style = Style::default().fg(fg).bg(bg);
1141 if is_hovering {
1142 style = style.add_modifier(Modifier::UNDERLINED);
1143 }
1144 style
1145 }
1146 ElementKind::Update => Style::default()
1147 .fg(theme.menu_highlight_fg)
1148 .bg(theme.menu_dropdown_bg),
1149 ElementKind::Palette => Style::default()
1154 .fg(theme.status_palette_fg)
1155 .bg(theme.status_palette_bg),
1156 ElementKind::Custom => Style::default()
1157 .fg(theme.status_bar_fg)
1158 .bg(theme.status_bar_bg),
1159 ElementKind::RemoteIndicator(state) => {
1160 let is_hovering = hover == StatusBarHover::RemoteIndicator;
1161 let (fg, bg) = match state {
1162 RemoteIndicatorState::Connecting | RemoteIndicatorState::Connected => {
1168 (theme.help_indicator_fg, theme.help_indicator_bg)
1169 }
1170 RemoteIndicatorState::FailedAttach | RemoteIndicatorState::Disconnected => (
1174 theme.status_error_indicator_fg,
1175 theme.status_error_indicator_bg,
1176 ),
1177 RemoteIndicatorState::Local => (theme.status_bar_fg, theme.status_bar_bg),
1179 };
1180 let mut style = Style::default().fg(fg).bg(bg);
1181 if is_hovering {
1182 style = style.add_modifier(Modifier::UNDERLINED);
1183 }
1184 style
1185 }
1186 }
1187 }
1188
1189 fn update_layout_for_element(
1191 layout: &mut StatusBarLayout,
1192 kind: ElementKind,
1193 row: u16,
1194 start_col: u16,
1195 end_col: u16,
1196 ) {
1197 match kind {
1198 ElementKind::LineEnding => {
1199 layout.line_ending_indicator = Some((row, start_col, end_col))
1200 }
1201 ElementKind::Encoding => layout.encoding_indicator = Some((row, start_col, end_col)),
1202 ElementKind::Language => layout.language_indicator = Some((row, start_col, end_col)),
1203 ElementKind::Lsp => layout.lsp_indicator = Some((row, start_col, end_col)),
1204 ElementKind::WarningBadge => layout.warning_badge = Some((row, start_col, end_col)),
1205 ElementKind::Messages => layout.message_area = Some((row, start_col, end_col)),
1206 ElementKind::RemoteIndicator(_) => {
1207 layout.remote_indicator = Some((row, start_col, end_col))
1208 }
1209 _ => {}
1210 }
1211 }
1212
1213 fn element_spans(
1218 rendered: &RenderedElement,
1219 theme: &crate::view::theme::Theme,
1220 hover: StatusBarHover,
1221 warning_level: WarningLevel,
1222 lsp_state: LspIndicatorState,
1223 ) -> (Vec<Span<'static>>, usize) {
1224 let base_style = Style::default()
1225 .fg(theme.status_bar_fg)
1226 .bg(theme.status_bar_bg);
1227 let width = str_width(&rendered.text);
1228
1229 if rendered.kind == ElementKind::RemoteDisconnected && rendered.text.starts_with(SSH_PREFIX)
1230 {
1231 let error_style = Style::default()
1232 .fg(theme.status_error_indicator_fg)
1233 .bg(theme.status_error_indicator_bg);
1234 if let Some(term_off) = rendered.text.find(SSH_PREFIX_TERMINATOR) {
1235 let split_at = term_off + SSH_PREFIX_TERMINATOR.len();
1236 let prefix = rendered.text[..split_at].to_string();
1237 let rest = rendered.text[split_at..].to_string();
1238 return (
1239 vec![
1240 Span::styled(prefix, error_style),
1241 Span::styled(rest, base_style),
1242 ],
1243 width,
1244 );
1245 }
1246 return (
1247 vec![Span::styled(rendered.text.clone(), error_style)],
1248 width,
1249 );
1250 }
1251
1252 let style = Self::element_style(rendered.kind, theme, hover, warning_level, lsp_state);
1253 let spans = if rendered.kind == ElementKind::Clock {
1254 vec![
1256 Span::styled(rendered.text[..2].to_string(), style),
1257 Span::styled(":".to_string(), style.add_modifier(Modifier::SLOW_BLINK)),
1258 Span::styled(rendered.text[3..].to_string(), style),
1259 ]
1260 } else {
1261 vec![Span::styled(rendered.text.clone(), style)]
1262 };
1263 (spans, width)
1264 }
1265
1266 fn render_side(
1268 config_side: &[StatusBarElement],
1269 ctx: &mut StatusBarContext<'_>,
1270 ) -> Vec<(Vec<Span<'static>>, usize, ElementKind)> {
1271 let rendered: Vec<RenderedElement> = config_side
1272 .iter()
1273 .filter_map(|elem| Self::render_element(elem, ctx))
1274 .filter(|e| !e.text.is_empty())
1275 .collect();
1276
1277 let theme = ctx.theme;
1278 let hover = ctx.hover;
1279 let warning_level = ctx.warning_level;
1280 let lsp_state = ctx.lsp_indicator_state;
1281 rendered
1282 .into_iter()
1283 .map(|r| {
1284 let kind = r.kind;
1285 let (spans, width) =
1286 Self::element_spans(&r, theme, hover, warning_level, lsp_state);
1287 (spans, width, kind)
1288 })
1289 .collect()
1290 }
1291
1292 fn render_status(
1294 frame: &mut Frame,
1295 area: Rect,
1296 ctx: &mut StatusBarContext<'_>,
1297 config: &StatusBarConfig,
1298 ) -> StatusBarLayout {
1299 let mut layout = StatusBarLayout::default();
1300 let base_style = Style::default()
1301 .fg(ctx.theme.status_bar_fg)
1302 .bg(ctx.theme.status_bar_bg);
1303 let available_width = area.width as usize;
1304
1305 if available_width == 0 || area.height == 0 {
1306 return layout;
1307 }
1308
1309 ctx.remote_indicator_on_bar = config
1314 .left
1315 .iter()
1316 .chain(config.right.iter())
1317 .any(|e| matches!(e, StatusBarElement::RemoteIndicator));
1318
1319 let left_items = Self::render_side(&config.left, ctx);
1320 let mut right_items = Self::render_side(&config.right, ctx);
1321
1322 const SEPARATOR: &str = " | ";
1323 let separator_width = str_width(SEPARATOR);
1324
1325 let total_right_width: usize = right_items.iter().map(|(_, w, _)| *w).sum();
1334 let left_min_target = available_width
1335 .saturating_mul(2)
1336 .saturating_div(5) .min(40); let right_budget = available_width.saturating_sub(left_min_target + 1);
1339 if total_right_width > right_budget && right_items.len() > 1 {
1340 let mut current = total_right_width;
1341 while current > right_budget && right_items.len() > 1 {
1342 if let Some(dropped) = right_items.pop() {
1343 current = current.saturating_sub(dropped.1);
1344 } else {
1345 break;
1346 }
1347 }
1348 }
1349
1350 let right_width: usize = right_items.iter().map(|(_, w, _)| *w).sum();
1351
1352 let narrow = available_width < 15;
1353 let left_max_width = if narrow {
1354 available_width
1355 } else if available_width > right_width + 1 {
1356 available_width - right_width - 1
1357 } else {
1358 1
1359 };
1360
1361 let mut spans: Vec<Span<'static>> = Vec::new();
1365 let mut used_left: usize = 0;
1366
1367 for (idx, (item_spans, width, kind)) in left_items.into_iter().enumerate() {
1368 let sep_width = if idx == 0 { 0 } else { separator_width };
1369 if used_left + sep_width >= left_max_width {
1370 break;
1371 }
1372 if sep_width > 0 {
1373 spans.push(Span::styled(SEPARATOR, base_style));
1374 used_left += sep_width;
1375 }
1376
1377 let remaining = left_max_width - used_left;
1378 let start_col = used_left;
1379
1380 if width <= remaining {
1381 spans.extend(item_spans);
1382 used_left += width;
1383
1384 Self::update_layout_for_element(
1385 &mut layout,
1386 kind,
1387 area.y,
1388 area.x + start_col as u16,
1389 area.x + (start_col + width) as u16,
1390 );
1391 } else {
1392 let group_text: String = item_spans.iter().map(|s| s.content.as_ref()).collect();
1396 let truncated = truncate_to_width(&group_text, remaining);
1397 let truncated_width = str_width(&truncated);
1398 let overflow_style = Self::element_style(
1399 kind,
1400 ctx.theme,
1401 ctx.hover,
1402 ctx.warning_level,
1403 ctx.lsp_indicator_state,
1404 );
1405 spans.push(Span::styled(truncated, overflow_style));
1406 used_left += truncated_width;
1407
1408 Self::update_layout_for_element(
1409 &mut layout,
1410 kind,
1411 area.y,
1412 area.x + start_col as u16,
1413 area.x + (start_col + truncated_width) as u16,
1414 );
1415 break;
1416 }
1417 }
1418
1419 if narrow {
1420 if used_left < available_width {
1421 spans.push(Span::styled(
1422 " ".repeat(available_width - used_left),
1423 base_style,
1424 ));
1425 }
1426 frame.render_widget(Paragraph::new(Line::from(spans)), area);
1427 return layout;
1428 }
1429
1430 let mut col_offset = used_left;
1431 if col_offset + right_width < available_width {
1432 let padding = available_width - col_offset - right_width;
1433 spans.push(Span::styled(" ".repeat(padding), base_style));
1434 col_offset = available_width - right_width;
1435 } else if col_offset < available_width {
1436 spans.push(Span::styled(" ", base_style));
1437 col_offset += 1;
1438 }
1439
1440 let mut current_col = area.x + col_offset as u16;
1441 for (item_spans, width, kind) in right_items {
1442 Self::update_layout_for_element(
1443 &mut layout,
1444 kind,
1445 area.y,
1446 current_col,
1447 current_col + width as u16,
1448 );
1449 spans.extend(item_spans);
1450 current_col += width as u16;
1451 }
1452
1453 frame.render_widget(Paragraph::new(Line::from(spans)), area);
1454 layout
1455 }
1456
1457 #[allow(clippy::too_many_arguments)]
1468 pub fn render_search_options(
1469 frame: &mut Frame,
1470 area: Rect,
1471 case_sensitive: bool,
1472 whole_word: bool,
1473 use_regex: bool,
1474 confirm_each: Option<bool>, theme: &crate::view::theme::Theme,
1476 keybindings: &crate::input::keybindings::KeybindingResolver,
1477 hover: SearchOptionsHover,
1478 ) -> SearchOptionsLayout {
1479 use crate::primitives::display_width::str_width;
1480
1481 let mut layout = SearchOptionsLayout {
1482 row: area.y,
1483 ..Default::default()
1484 };
1485
1486 let base_style = Style::default()
1488 .fg(theme.menu_dropdown_fg)
1489 .bg(theme.menu_dropdown_bg);
1490
1491 let hover_style = Style::default()
1493 .fg(theme.menu_hover_fg)
1494 .bg(theme.menu_hover_bg);
1495
1496 let get_shortcut = |action: &crate::input::keybindings::Action| -> Option<String> {
1498 keybindings
1499 .get_keybinding_for_action(action, crate::input::keybindings::KeyContext::Prompt)
1500 .or_else(|| {
1501 keybindings.get_keybinding_for_action(
1502 action,
1503 crate::input::keybindings::KeyContext::Global,
1504 )
1505 })
1506 };
1507
1508 let case_shortcut =
1510 get_shortcut(&crate::input::keybindings::Action::ToggleSearchCaseSensitive);
1511 let word_shortcut = get_shortcut(&crate::input::keybindings::Action::ToggleSearchWholeWord);
1512 let regex_shortcut = get_shortcut(&crate::input::keybindings::Action::ToggleSearchRegex);
1513
1514 let case_checkbox = if case_sensitive { "[x]" } else { "[ ]" };
1516 let word_checkbox = if whole_word { "[x]" } else { "[ ]" };
1517 let regex_checkbox = if use_regex { "[x]" } else { "[ ]" };
1518
1519 let active_style = Style::default()
1521 .fg(theme.menu_highlight_fg)
1522 .bg(theme.menu_dropdown_bg);
1523
1524 let shortcut_style = Style::default()
1526 .fg(theme.help_separator_fg)
1527 .bg(theme.menu_dropdown_bg);
1528
1529 let hover_shortcut_style = Style::default()
1531 .fg(theme.menu_hover_fg)
1532 .bg(theme.menu_hover_bg);
1533
1534 let mut spans = Vec::new();
1535 let mut current_col = area.x;
1536
1537 spans.push(Span::styled(" ", base_style));
1539 current_col += 1;
1540
1541 let get_checkbox_style = |is_hovered: bool, is_checked: bool| -> Style {
1543 if is_hovered {
1544 hover_style
1545 } else if is_checked {
1546 active_style
1547 } else {
1548 base_style
1549 }
1550 };
1551
1552 let case_hovered = hover == SearchOptionsHover::CaseSensitive;
1554 let case_start = current_col;
1555 let case_label = format!("{} {}", case_checkbox, t!("search.case_sensitive"));
1556 let case_shortcut_text = case_shortcut
1557 .as_ref()
1558 .map(|s| format!(" ({})", s))
1559 .unwrap_or_default();
1560 let case_full_width = str_width(&case_label) + str_width(&case_shortcut_text);
1561
1562 spans.push(Span::styled(
1563 case_label,
1564 get_checkbox_style(case_hovered, case_sensitive),
1565 ));
1566 if !case_shortcut_text.is_empty() {
1567 spans.push(Span::styled(
1568 case_shortcut_text,
1569 if case_hovered {
1570 hover_shortcut_style
1571 } else {
1572 shortcut_style
1573 },
1574 ));
1575 }
1576 current_col += case_full_width as u16;
1577 layout.case_sensitive = Some((case_start, current_col));
1578
1579 spans.push(Span::styled(" ", base_style));
1581 current_col += 3;
1582
1583 let word_hovered = hover == SearchOptionsHover::WholeWord;
1585 let word_start = current_col;
1586 let word_label = format!("{} {}", word_checkbox, t!("search.whole_word"));
1587 let word_shortcut_text = word_shortcut
1588 .as_ref()
1589 .map(|s| format!(" ({})", s))
1590 .unwrap_or_default();
1591 let word_full_width = str_width(&word_label) + str_width(&word_shortcut_text);
1592
1593 spans.push(Span::styled(
1594 word_label,
1595 get_checkbox_style(word_hovered, whole_word),
1596 ));
1597 if !word_shortcut_text.is_empty() {
1598 spans.push(Span::styled(
1599 word_shortcut_text,
1600 if word_hovered {
1601 hover_shortcut_style
1602 } else {
1603 shortcut_style
1604 },
1605 ));
1606 }
1607 current_col += word_full_width as u16;
1608 layout.whole_word = Some((word_start, current_col));
1609
1610 spans.push(Span::styled(" ", base_style));
1612 current_col += 3;
1613
1614 let regex_hovered = hover == SearchOptionsHover::Regex;
1616 let regex_start = current_col;
1617 let regex_label = format!("{} {}", regex_checkbox, t!("search.regex"));
1618 let regex_shortcut_text = regex_shortcut
1619 .as_ref()
1620 .map(|s| format!(" ({})", s))
1621 .unwrap_or_default();
1622 let regex_full_width = str_width(®ex_label) + str_width(®ex_shortcut_text);
1623
1624 spans.push(Span::styled(
1625 regex_label,
1626 get_checkbox_style(regex_hovered, use_regex),
1627 ));
1628 if !regex_shortcut_text.is_empty() {
1629 spans.push(Span::styled(
1630 regex_shortcut_text,
1631 if regex_hovered {
1632 hover_shortcut_style
1633 } else {
1634 shortcut_style
1635 },
1636 ));
1637 }
1638 current_col += regex_full_width as u16;
1639 layout.regex = Some((regex_start, current_col));
1640
1641 if use_regex && confirm_each.is_some() {
1643 let hint = " \u{2502} $1,$2,…";
1644 spans.push(Span::styled(hint, shortcut_style));
1645 current_col += str_width(hint) as u16;
1646 }
1647
1648 if let Some(confirm_value) = confirm_each {
1650 let confirm_shortcut =
1651 get_shortcut(&crate::input::keybindings::Action::ToggleSearchConfirmEach);
1652 let confirm_checkbox = if confirm_value { "[x]" } else { "[ ]" };
1653
1654 spans.push(Span::styled(" ", base_style));
1656 current_col += 3;
1657
1658 let confirm_hovered = hover == SearchOptionsHover::ConfirmEach;
1659 let confirm_start = current_col;
1660 let confirm_label = format!("{} {}", confirm_checkbox, t!("search.confirm_each"));
1661 let confirm_shortcut_text = confirm_shortcut
1662 .as_ref()
1663 .map(|s| format!(" ({})", s))
1664 .unwrap_or_default();
1665 let confirm_full_width = str_width(&confirm_label) + str_width(&confirm_shortcut_text);
1666
1667 spans.push(Span::styled(
1668 confirm_label,
1669 get_checkbox_style(confirm_hovered, confirm_value),
1670 ));
1671 if !confirm_shortcut_text.is_empty() {
1672 spans.push(Span::styled(
1673 confirm_shortcut_text,
1674 if confirm_hovered {
1675 hover_shortcut_style
1676 } else {
1677 shortcut_style
1678 },
1679 ));
1680 }
1681 current_col += confirm_full_width as u16;
1682 layout.confirm_each = Some((confirm_start, current_col));
1683 }
1684
1685 let current_width = (current_col - area.x) as usize;
1687 let available_width = area.width as usize;
1688 if current_width < available_width {
1689 spans.push(Span::styled(
1690 " ".repeat(available_width.saturating_sub(current_width)),
1691 base_style,
1692 ));
1693 }
1694
1695 let options_line = Paragraph::new(Line::from(spans));
1696 frame.render_widget(options_line, area);
1697
1698 layout
1699 }
1700}
1701
1702#[cfg(test)]
1703mod tests {
1704 use super::*;
1705 use std::path::PathBuf;
1706
1707 #[test]
1708 fn test_truncate_path_short_path() {
1709 let path = PathBuf::from("/home/user/project");
1710 let result = truncate_path(&path, 50);
1711
1712 assert!(!result.truncated);
1713 assert_eq!(result.suffix, "/home/user/project");
1714 assert!(result.prefix.is_empty());
1715 }
1716
1717 #[test]
1718 fn test_truncate_path_long_path() {
1719 let path = PathBuf::from(
1720 "/private/var/folders/p6/nlmq3k8146990kpkxl73mq340000gn/T/.tmpNYt4Fc/project_root",
1721 );
1722 let result = truncate_path(&path, 40);
1723
1724 assert!(result.truncated, "Path should be truncated");
1725 assert_eq!(result.prefix, "/private");
1726 assert!(
1727 result.suffix.contains("project_root"),
1728 "Suffix should contain project_root"
1729 );
1730 }
1731
1732 #[test]
1733 fn test_truncate_path_preserves_last_components() {
1734 let path = PathBuf::from("/a/b/c/d/e/f/g/h/i/j/project/src");
1735 let result = truncate_path(&path, 30);
1736
1737 assert!(result.truncated);
1738 assert!(
1740 result.suffix.contains("src"),
1741 "Should preserve last component 'src', got: {}",
1742 result.suffix
1743 );
1744 }
1745
1746 #[test]
1747 fn test_truncate_path_display_len() {
1748 let path = PathBuf::from("/private/var/folders/deep/nested/path/here");
1749 let result = truncate_path(&path, 30);
1750
1751 let display = result.to_string_plain();
1753 assert!(
1754 display.len() <= 35, "Display should be truncated to around 30 chars, got {} chars: {}",
1756 display.len(),
1757 display
1758 );
1759 }
1760
1761 #[test]
1762 fn test_truncate_path_root_only() {
1763 let path = PathBuf::from("/");
1764 let result = truncate_path(&path, 50);
1765
1766 assert!(!result.truncated);
1767 assert_eq!(result.suffix, "/");
1768 }
1769
1770 #[test]
1771 fn test_truncate_path_multibyte_single_component_does_not_panic() {
1772 let path = PathBuf::from("/ユーザーのプロジェクト名前/file");
1778 let result = truncate_path(&path, 5);
1779 let display = result.to_string_plain();
1780 assert!(display.is_char_boundary(display.len()));
1781 assert!(display.ends_with("..."));
1782 }
1783
1784 #[test]
1785 fn test_truncate_path_multibyte_last_component_does_not_panic() {
1786 let path = PathBuf::from("/a/ユーザーのプロジェクト名前");
1793 let result = truncate_path(&path, 13);
1794 let display = result.to_string_plain();
1795 assert!(display.is_char_boundary(display.len()));
1796 }
1797
1798 #[test]
1799 fn test_truncated_path_to_string_plain() {
1800 let truncated = TruncatedPath {
1801 prefix: "/home".to_string(),
1802 truncated: true,
1803 suffix: "/project/src".to_string(),
1804 };
1805
1806 assert_eq!(truncated.to_string_plain(), "/home/[...]/project/src");
1807 }
1808
1809 #[test]
1810 fn test_truncated_path_to_string_plain_no_truncation() {
1811 let truncated = TruncatedPath {
1812 prefix: String::new(),
1813 truncated: false,
1814 suffix: "/home/user/project".to_string(),
1815 };
1816
1817 assert_eq!(truncated.to_string_plain(), "/home/user/project");
1818 }
1819
1820 #[test]
1821 fn test_remote_indicator_element_kind_equality() {
1822 assert_eq!(
1826 ElementKind::RemoteIndicator(RemoteIndicatorState::Local),
1827 ElementKind::RemoteIndicator(RemoteIndicatorState::Local)
1828 );
1829 let distinct = [
1830 RemoteIndicatorState::Local,
1831 RemoteIndicatorState::Connecting,
1832 RemoteIndicatorState::Connected,
1833 RemoteIndicatorState::FailedAttach,
1834 RemoteIndicatorState::Disconnected,
1835 ];
1836 for (i, a) in distinct.iter().enumerate() {
1837 for (j, b) in distinct.iter().enumerate() {
1838 if i == j {
1839 continue;
1840 }
1841 assert_ne!(
1842 ElementKind::RemoteIndicator(*a),
1843 ElementKind::RemoteIndicator(*b),
1844 "expected {:?} != {:?}",
1845 a,
1846 b
1847 );
1848 }
1849 }
1850 }
1851
1852 #[test]
1853 fn test_remote_indicator_state_default_is_local() {
1854 assert_eq!(RemoteIndicatorState::default(), RemoteIndicatorState::Local);
1857 }
1858
1859 #[test]
1860 fn test_remote_indicator_override_deserializes_kind_tags() {
1861 let cases: &[(&str, RemoteIndicatorOverride)] = &[
1865 (r#"{"kind":"local"}"#, RemoteIndicatorOverride::Local),
1866 (
1867 r#"{"kind":"connecting","label":"Building"}"#,
1868 RemoteIndicatorOverride::Connecting {
1869 label: Some("Building".into()),
1870 },
1871 ),
1872 (
1873 r#"{"kind":"connecting"}"#,
1874 RemoteIndicatorOverride::Connecting { label: None },
1875 ),
1876 (
1877 r#"{"kind":"connected","label":"Container:abc"}"#,
1878 RemoteIndicatorOverride::Connected {
1879 label: Some("Container:abc".into()),
1880 },
1881 ),
1882 (
1883 r#"{"kind":"failed_attach","error":"exit 1"}"#,
1884 RemoteIndicatorOverride::FailedAttach {
1885 error: Some("exit 1".into()),
1886 },
1887 ),
1888 (
1889 r#"{"kind":"disconnected","label":"Container:abc"}"#,
1890 RemoteIndicatorOverride::Disconnected {
1891 label: Some("Container:abc".into()),
1892 },
1893 ),
1894 ];
1895 for (json, expected) in cases {
1896 let parsed: RemoteIndicatorOverride = serde_json::from_str(json)
1897 .unwrap_or_else(|e| panic!("failed to parse {}: {}", json, e));
1898 assert_eq!(&parsed, expected, "wire shape mismatch for {}", json);
1899 }
1900 }
1901
1902 #[test]
1903 fn test_remote_indicator_override_labels() {
1904 let connecting = RemoteIndicatorOverride::Connecting { label: None };
1908 assert!(
1909 connecting.label().contains("Connecting"),
1910 "connecting default label should mention Connecting, got {:?}",
1911 connecting.label()
1912 );
1913
1914 let connecting_labeled = RemoteIndicatorOverride::Connecting {
1915 label: Some("Building".into()),
1916 };
1917 assert!(
1918 connecting_labeled.label().contains("Building"),
1919 "labeled connecting should include the label, got {:?}",
1920 connecting_labeled.label()
1921 );
1922
1923 let failed_bare = RemoteIndicatorOverride::FailedAttach { error: None };
1924 assert_eq!(failed_bare.label(), "Attach failed");
1925
1926 let failed_detail = RemoteIndicatorOverride::FailedAttach {
1927 error: Some("exit 1".into()),
1928 };
1929 assert!(
1930 failed_detail.label().contains("exit 1"),
1931 "failed with error should include the error, got {:?}",
1932 failed_detail.label()
1933 );
1934 }
1935
1936 #[test]
1937 fn test_palette_and_lsp_on_use_dedicated_theme_keys() {
1938 let theme = crate::view::theme::Theme::from_json(
1950 r#"{"name":"t","editor":{},"ui":{},"search":{},"diagnostic":{},"syntax":{}}"#,
1951 )
1952 .expect("minimal theme should parse");
1953
1954 assert_eq!(theme.status_palette_fg, theme.status_bar_fg);
1956 assert_eq!(theme.status_palette_bg, theme.status_bar_bg);
1957 assert_eq!(theme.status_lsp_on_fg, theme.status_bar_fg);
1958 assert_eq!(theme.status_lsp_on_bg, theme.status_bar_bg);
1959
1960 let palette_style = StatusBarRenderer::element_style(
1961 ElementKind::Palette,
1962 &theme,
1963 StatusBarHover::None,
1964 WarningLevel::None,
1965 LspIndicatorState::None,
1966 );
1967 assert_eq!(palette_style.fg, Some(theme.status_palette_fg));
1968 assert_eq!(palette_style.bg, Some(theme.status_palette_bg));
1969
1970 let lsp_on_style = StatusBarRenderer::element_style(
1971 ElementKind::Lsp,
1972 &theme,
1973 StatusBarHover::None,
1974 WarningLevel::None,
1975 LspIndicatorState::On,
1976 );
1977 assert_eq!(lsp_on_style.fg, Some(theme.status_lsp_on_fg));
1978 assert_eq!(lsp_on_style.bg, Some(theme.status_lsp_on_bg));
1979
1980 let lsp_off_style = StatusBarRenderer::element_style(
1983 ElementKind::Lsp,
1984 &theme,
1985 StatusBarHover::None,
1986 WarningLevel::None,
1987 LspIndicatorState::Off,
1988 );
1989 assert_eq!(lsp_off_style.fg, Some(theme.status_lsp_actionable_fg));
1990 assert_eq!(lsp_off_style.bg, Some(theme.status_lsp_actionable_bg));
1991
1992 let lsp_error_style = StatusBarRenderer::element_style(
1993 ElementKind::Lsp,
1994 &theme,
1995 StatusBarHover::None,
1996 WarningLevel::None,
1997 LspIndicatorState::Error,
1998 );
1999 assert_eq!(lsp_error_style.fg, Some(theme.diagnostic_error_fg));
2000 assert_eq!(lsp_error_style.bg, Some(theme.diagnostic_error_bg));
2001 }
2002
2003 #[test]
2004 fn test_status_palette_and_lsp_on_keys_override_independently() {
2005 let theme_json = r#"{
2011 "name":"t",
2012 "editor":{},
2013 "ui":{
2014 "status_bar_fg":"White",
2015 "status_bar_bg":"DarkGray",
2016 "status_palette_fg":"Black",
2017 "status_palette_bg":"Yellow",
2018 "status_lsp_on_fg":"Black",
2019 "status_lsp_on_bg":"Cyan"
2020 },
2021 "search":{},
2022 "diagnostic":{},
2023 "syntax":{}
2024 }"#;
2025 let theme = crate::view::theme::Theme::from_json(theme_json).expect("theme should parse");
2026 assert_ne!(theme.status_palette_fg, theme.status_bar_fg);
2027 assert_ne!(theme.status_palette_bg, theme.status_bar_bg);
2028 assert_ne!(theme.status_lsp_on_fg, theme.status_bar_fg);
2029 assert_ne!(theme.status_lsp_on_bg, theme.status_bar_bg);
2030 }
2031
2032 #[test]
2033 fn test_remote_indicator_override_state_projection() {
2034 assert_eq!(
2035 RemoteIndicatorOverride::Local.state(),
2036 RemoteIndicatorState::Local
2037 );
2038 assert_eq!(
2039 RemoteIndicatorOverride::Connecting { label: None }.state(),
2040 RemoteIndicatorState::Connecting
2041 );
2042 assert_eq!(
2043 RemoteIndicatorOverride::Connected { label: None }.state(),
2044 RemoteIndicatorState::Connected
2045 );
2046 assert_eq!(
2047 RemoteIndicatorOverride::FailedAttach { error: None }.state(),
2048 RemoteIndicatorState::FailedAttach
2049 );
2050 assert_eq!(
2051 RemoteIndicatorOverride::Disconnected { label: None }.state(),
2052 RemoteIndicatorState::Disconnected
2053 );
2054 }
2055
2056 #[test]
2065 fn test_cursor_position_widths_stable_across_cursor_movement() {
2066 let line_count = 50;
2067 let widths: Vec<usize> = [(1, 1), (5, 12), (12, 5), (50, 100), (1, 1)]
2070 .into_iter()
2071 .map(|(ln, col)| format_cursor_position(ln, col, line_count).len())
2072 .collect();
2073 assert!(
2074 widths.windows(2).all(|w| w[0] == w[1]),
2075 "rendered widths drift across cursor movements: {widths:?}"
2076 );
2077 }
2078
2079 #[test]
2080 fn test_cursor_position_preserves_natural_number_text() {
2081 let text = format_cursor_position(1, 1, 50);
2085 assert!(
2086 text.starts_with("Ln 1, Col 1"),
2087 "expected text to start with natural numbers, got {text:?}"
2088 );
2089 assert!(
2090 text.ends_with(' '),
2091 "expected trailing padding, got {text:?}"
2092 );
2093 }
2094
2095 #[test]
2096 fn test_cursor_position_no_padding_for_single_line_buffer() {
2097 let text = format_cursor_position(1, 1, 1);
2101 assert_eq!(text.len(), 13);
2103 assert!(text.starts_with("Ln 1, Col 1"));
2104 }
2105
2106 #[test]
2107 fn test_cursor_position_does_not_shrink_below_actual() {
2108 let text = format_cursor_position(99, 99999, 50);
2111 assert_eq!(text, "Ln 99, Col 99999");
2112 }
2113
2114 #[test]
2115 fn test_cursor_position_compact_widths_stable() {
2116 let line_count = 50;
2117 let widths: Vec<usize> = [(1, 1), (5, 12), (12, 5), (50, 100), (1, 1)]
2118 .into_iter()
2119 .map(|(ln, col)| format_cursor_position_compact(ln, col, line_count).len())
2120 .collect();
2121 assert!(
2122 widths.windows(2).all(|w| w[0] == w[1]),
2123 "compact widths drift across cursor movements: {widths:?}"
2124 );
2125 }
2126
2127 #[test]
2128 fn test_cursor_position_compact_preserves_natural_text() {
2129 let text = format_cursor_position_compact(1, 1, 50);
2130 assert!(
2131 text.starts_with("1:1"),
2132 "expected text to start with natural numbers, got {text:?}"
2133 );
2134 }
2135
2136 #[test]
2137 fn test_cursor_position_scales_with_line_count() {
2138 let short = format_cursor_position(1, 1, 9);
2141 let long = format_cursor_position(1, 1, 10_000);
2142 assert!(
2143 long.len() > short.len(),
2144 "wider buffers should reserve more width: {short:?} vs {long:?}"
2145 );
2146 let top = format_cursor_position(1, 1, 10_000);
2149 let high = format_cursor_position(9_999, 999, 10_000);
2150 assert_eq!(top.len(), high.len());
2151 }
2152}