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 pub sep: char,
362}
363
364impl TruncatedPath {
365 pub fn to_string_plain(&self) -> String {
367 if self.truncated {
368 format!("{}{}[...]{}", self.prefix, self.sep, self.suffix)
369 } else {
370 format!("{}{}", self.prefix, self.suffix)
371 }
372 }
373
374 pub fn display_len(&self) -> usize {
377 if self.truncated {
378 self.prefix.len() + self.sep.len_utf8() + "[...]".len() + self.suffix.len()
379 } else {
380 self.prefix.len() + self.suffix.len()
381 }
382 }
383}
384
385fn path_display_sep(path_str: &str) -> char {
389 if path_str.contains('\\') {
390 '\\'
391 } else {
392 '/'
393 }
394}
395
396pub fn truncate_path(path: &Path, max_len: usize) -> TruncatedPath {
408 let path_str = path.to_string_lossy();
409 let sep = path_display_sep(&path_str);
414
415 if path_str.len() <= max_len {
417 return TruncatedPath {
418 prefix: String::new(),
419 truncated: false,
420 suffix: path_str.to_string(),
421 sep,
422 };
423 }
424
425 let components: Vec<&str> = path_str
426 .split(['/', '\\'])
427 .filter(|s| !s.is_empty())
428 .collect();
429
430 if components.is_empty() {
431 return TruncatedPath {
432 prefix: sep.to_string(),
433 truncated: false,
434 suffix: String::new(),
435 sep,
436 };
437 }
438
439 let leading_sep = path_str.starts_with('/') || path_str.starts_with('\\');
444 let is_drive = |c: &str| {
445 let b = c.as_bytes();
446 b.len() == 2 && b[1] == b':' && b[0].is_ascii_alphabetic()
447 };
448 let prefix_count = if !leading_sep && is_drive(components[0]) {
449 2
450 } else {
451 1
452 }
453 .min(components.len());
454 let sep_str = sep.to_string();
455 let prefix = {
456 let joined = components[..prefix_count].join(&sep_str);
457 if leading_sep {
458 format!("{}{}", sep, joined)
459 } else {
460 joined
461 }
462 };
463
464 let ellipsis_len = sep.len_utf8() + "[...]".len();
466
467 let available_for_suffix = max_len.saturating_sub(prefix.len() + ellipsis_len);
469
470 if available_for_suffix < 5 || components.len() <= prefix_count {
471 let truncated_path = if path_str.len() > max_len.saturating_sub(3) {
477 let cut = path_str.floor_char_boundary(max_len.saturating_sub(3));
478 format!("{}...", &path_str[..cut])
479 } else {
480 path_str.to_string()
481 };
482 return TruncatedPath {
483 prefix: String::new(),
484 truncated: false,
485 suffix: truncated_path,
486 sep,
487 };
488 }
489
490 let mut suffix_parts: Vec<&str> = Vec::new();
492 let mut suffix_len = 0;
493
494 for component in components.iter().skip(prefix_count).rev() {
495 let component_len = component.len() + 1; if suffix_len + component_len <= available_for_suffix {
497 suffix_parts.push(component);
498 suffix_len += component_len;
499 } else {
500 break;
501 }
502 }
503
504 suffix_parts.reverse();
505
506 if suffix_parts.len() == components.len() - prefix_count {
508 return TruncatedPath {
509 prefix: String::new(),
510 truncated: false,
511 suffix: path_str.to_string(),
512 sep,
513 };
514 }
515
516 let suffix = if suffix_parts.is_empty() {
517 let last = components.last().unwrap_or(&"");
521 let truncate_to = available_for_suffix.saturating_sub(4); if truncate_to > 0 && last.len() > truncate_to {
523 let cut = last.floor_char_boundary(truncate_to);
524 format!("{}{}...", sep, &last[..cut])
525 } else {
526 format!("{}{}", sep, last)
527 }
528 } else {
529 format!("{}{}", sep, suffix_parts.join(&sep_str))
530 };
531
532 TruncatedPath {
533 prefix,
534 truncated: true,
535 suffix,
536 sep,
537 }
538}
539
540fn truncate_to_width(s: &str, max_width: usize) -> String {
542 let width = str_width(s);
543 if width <= max_width {
544 return s.to_string();
545 }
546 let truncate_at = max_width.saturating_sub(3);
547 if truncate_at == 0 {
548 return if max_width >= 3 {
549 "...".to_string()
550 } else {
551 s.chars().take(max_width).collect()
552 };
553 }
554 let mut w = 0;
555 let truncated: String = s
556 .chars()
557 .take_while(|ch| {
558 let cw = char_width(*ch);
559 if w + cw <= truncate_at {
560 w += cw;
561 true
562 } else {
563 false
564 }
565 })
566 .collect();
567 format!("{}...", truncated)
568}
569
570const CURSOR_COL_RESERVE: usize = 3;
575
576fn format_cursor_position(line: usize, col: usize, line_count: usize) -> String {
584 let text = format!("Ln {line}, Col {col}");
585 let line_digits = line_count.max(1).to_string().len();
586 let min_width = 9 + line_digits + CURSOR_COL_RESERVE;
588 if text.len() < min_width {
589 format!("{text:<min_width$}")
590 } else {
591 text
592 }
593}
594
595fn format_cursor_position_compact(line: usize, col: usize, line_count: usize) -> String {
599 let text = format!("{line}:{col}");
600 let line_digits = line_count.max(1).to_string().len();
601 let min_width = 1 + line_digits + CURSOR_COL_RESERVE;
603 if text.len() < min_width {
604 format!("{text:<min_width$}")
605 } else {
606 text
607 }
608}
609
610pub struct StatusBarRenderer;
612
613impl StatusBarRenderer {
614 pub fn render_status_bar(
618 frame: &mut Frame,
619 area: Rect,
620 ctx: &mut StatusBarContext<'_>,
621 config: &StatusBarConfig,
622 ) -> StatusBarLayout {
623 Self::render_status(frame, area, ctx, config)
624 }
625
626 pub fn render_prompt(
628 frame: &mut Frame,
629 area: Rect,
630 prompt: &Prompt,
631 theme: &crate::view::theme::Theme,
632 ) {
633 let base_style = Style::default().fg(theme.prompt_fg).bg(theme.prompt_bg);
634
635 let mut spans = vec![Span::styled(prompt.message.clone(), base_style)];
637
638 if let Some((sel_start, sel_end)) = prompt.selection_range() {
640 let input = &prompt.input;
641
642 if sel_start > 0 {
644 spans.push(Span::styled(input[..sel_start].to_string(), base_style));
645 }
646
647 if sel_start < sel_end {
649 let selection_style = Style::default()
651 .fg(theme.prompt_selection_fg)
652 .bg(theme.prompt_selection_bg);
653 spans.push(Span::styled(
654 input[sel_start..sel_end].to_string(),
655 selection_style,
656 ));
657 }
658
659 if sel_end < input.len() {
661 spans.push(Span::styled(input[sel_end..].to_string(), base_style));
662 }
663 } else {
664 spans.push(Span::styled(prompt.input.clone(), base_style));
666 }
667
668 let line = Line::from(spans);
669 let prompt_line = Paragraph::new(line).style(base_style);
670
671 frame.render_widget(prompt_line, area);
672
673 let message_width = str_width(&prompt.message);
678 let input_width_before_cursor = str_width(&prompt.input[..prompt.cursor_pos]);
679 let cursor_x = (message_width + input_width_before_cursor) as u16;
680 if cursor_x < area.width {
681 frame.set_cursor_position((area.x + cursor_x, area.y));
682 }
683 }
684
685 pub fn render_file_open_prompt(
689 frame: &mut Frame,
690 area: Rect,
691 prompt: &Prompt,
692 file_open_state: &crate::app::file_open::FileOpenState,
693 theme: &crate::view::theme::Theme,
694 ) {
695 let base_style = Style::default().fg(theme.prompt_fg).bg(theme.prompt_bg);
696 let dir_style = Style::default()
697 .fg(theme.help_separator_fg)
698 .bg(theme.prompt_bg);
699 let ellipsis_style = Style::default()
701 .fg(theme.menu_highlight_fg)
702 .bg(theme.prompt_bg);
703
704 let mut spans = Vec::new();
705
706 let open_prompt = t!("file.open_prompt").to_string();
708 spans.push(Span::styled(open_prompt.clone(), base_style));
709
710 let prefix_len = str_width(&open_prompt);
713 let dir_path = file_open_state.current_dir.to_string_lossy();
714 let dir_path_len = dir_path.len() + 1; let input_len = prompt.input.len();
716 let total_len = prefix_len + dir_path_len + input_len;
717 let threshold = (area.width as usize * 90) / 100;
718
719 let truncated = if total_len > threshold {
721 let available_for_path = threshold
723 .saturating_sub(prefix_len)
724 .saturating_sub(input_len);
725 truncate_path(&file_open_state.current_dir, available_for_path)
726 } else {
727 TruncatedPath {
729 prefix: String::new(),
730 truncated: false,
731 suffix: dir_path.to_string(),
732 sep: path_display_sep(&dir_path),
733 }
734 };
735
736 if truncated.truncated {
738 spans.push(Span::styled(truncated.prefix.clone(), dir_style));
740 spans.push(Span::styled(
742 format!("{}[...]", truncated.sep),
743 ellipsis_style,
744 ));
745 let suffix_with_slash = if truncated.suffix.ends_with('/') {
747 truncated.suffix.clone()
748 } else {
749 format!("{}/", truncated.suffix)
750 };
751 spans.push(Span::styled(suffix_with_slash, dir_style));
752 } else {
753 let path_display = if truncated.suffix.ends_with('/') {
755 truncated.suffix.clone()
756 } else {
757 format!("{}/", truncated.suffix)
758 };
759 spans.push(Span::styled(path_display, dir_style));
760 }
761
762 spans.push(Span::styled(prompt.input.clone(), base_style));
764
765 let line = Line::from(spans);
766 let prompt_line = Paragraph::new(line).style(base_style);
767
768 frame.render_widget(prompt_line, area);
769
770 let prefix_width = str_width(&open_prompt);
774 let dir_display_width = if truncated.truncated {
775 let suffix_with_slash = if truncated.suffix.ends_with('/') {
776 &truncated.suffix
777 } else {
778 &truncated.suffix
780 };
781 str_width(&truncated.prefix) + str_width("/[...]") + str_width(suffix_with_slash) + 1
782 } else {
783 str_width(&truncated.suffix) + 1 };
785 let input_width_before_cursor = str_width(&prompt.input[..prompt.cursor_pos]);
786 let cursor_x = (prefix_width + dir_display_width + input_width_before_cursor) as u16;
787 if cursor_x < area.width {
788 frame.set_cursor_position((area.x + cursor_x, area.y));
789 }
790 }
791
792 fn render_element(
795 element: &StatusBarElement,
796 ctx: &mut StatusBarContext<'_>,
797 ) -> Option<RenderedElement> {
798 if ctx.is_synthetic_placeholder
803 && matches!(
804 element,
805 StatusBarElement::Filename
806 | StatusBarElement::Cursor
807 | StatusBarElement::CursorCompact
808 | StatusBarElement::CursorCount
809 | StatusBarElement::Diagnostics
810 | StatusBarElement::LineEnding
811 | StatusBarElement::Encoding
812 | StatusBarElement::Language
813 )
814 {
815 return None;
816 }
817 match element {
818 StatusBarElement::Filename => {
819 let modified = if ctx.state.buffer.is_modified() {
820 " [+]"
821 } else {
822 ""
823 };
824 let read_only_indicator = if ctx.read_only { " [RO]" } else { "" };
825 let remote_disconnected = ctx
826 .remote_connection
827 .map(|conn| conn.contains("(Disconnected)"))
828 .unwrap_or(false);
829 let remote_prefix = if ctx.remote_indicator_on_bar {
836 String::new()
837 } else {
838 ctx.remote_connection
839 .map(|conn| {
840 if conn.starts_with("Container:") {
841 format!("[{}] ", conn)
842 } else {
843 format!("{SSH_PREFIX}{conn}{SSH_PREFIX_TERMINATOR}")
844 }
845 })
846 .unwrap_or_default()
847 };
848 let session_prefix = ctx
849 .session_name
850 .map(|name| format!("[{}] ", name))
851 .unwrap_or_default();
852 let display_name = ctx.display_name;
853 let text = format!(
854 "{session_prefix}{remote_prefix}{display_name}{modified}{read_only_indicator}"
855 );
856 let kind = if remote_disconnected {
857 ElementKind::RemoteDisconnected
858 } else {
859 ElementKind::Normal
860 };
861 Some(RenderedElement { text, kind })
862 }
863 StatusBarElement::Cursor => {
864 if !ctx.state.show_cursors {
865 return None;
866 }
867 let cursor = *ctx.cursors.primary();
868 let line_count = ctx.state.buffer.line_count();
869 let text = if let Some(lc) = line_count {
870 let cursor_iter = ctx.state.buffer.line_iterator(cursor.position, 80);
871 let line_start = cursor_iter.current_position();
872 let col = cursor.position.saturating_sub(line_start);
873 let line = ctx.state.primary_cursor_line_number.value();
874 format_cursor_position(line + 1, col + 1, lc)
875 } else {
876 format!("Byte {}", cursor.position)
877 };
878 Some(RenderedElement {
879 text,
880 kind: ElementKind::Normal,
881 })
882 }
883 StatusBarElement::CursorCompact => {
884 if !ctx.state.show_cursors {
885 return None;
886 }
887 let cursor = *ctx.cursors.primary();
888 let line_count = ctx.state.buffer.line_count();
889 let text = if let Some(lc) = line_count {
890 let cursor_iter = ctx.state.buffer.line_iterator(cursor.position, 80);
891 let line_start = cursor_iter.current_position();
892 let col = cursor.position.saturating_sub(line_start);
893 let line = ctx.state.primary_cursor_line_number.value();
894 format_cursor_position_compact(line + 1, col + 1, lc)
895 } else {
896 format!("{}", cursor.position)
897 };
898 Some(RenderedElement {
899 text,
900 kind: ElementKind::Normal,
901 })
902 }
903 StatusBarElement::Diagnostics => {
904 let diagnostics = ctx.state.overlays.all();
905 let mut error_count = 0usize;
906 let mut warning_count = 0usize;
907 let mut info_count = 0usize;
908 let diagnostic_ns = crate::services::lsp::diagnostics::lsp_diagnostic_namespace();
909 for overlay in diagnostics {
910 if overlay.namespace.as_ref() == Some(&diagnostic_ns) {
911 match overlay.priority {
912 100 => error_count += 1,
913 50 => warning_count += 1,
914 _ => info_count += 1,
915 }
916 }
917 }
918 if error_count + warning_count + info_count == 0 {
919 return None;
920 }
921 let mut parts = Vec::new();
922 if error_count > 0 {
923 parts.push(format!("E:{}", error_count));
924 }
925 if warning_count > 0 {
926 parts.push(format!("W:{}", warning_count));
927 }
928 if info_count > 0 {
929 parts.push(format!("I:{}", info_count));
930 }
931 Some(RenderedElement {
932 text: parts.join(" "),
933 kind: ElementKind::Normal,
934 })
935 }
936 StatusBarElement::CursorCount => {
937 if ctx.cursors.count() <= 1 {
938 return None;
939 }
940 Some(RenderedElement {
941 text: t!("status.cursors", count = ctx.cursors.count()).to_string(),
942 kind: ElementKind::Normal,
943 })
944 }
945 StatusBarElement::Messages => {
946 let mut parts: Vec<&str> = Vec::new();
947 if let Some(msg) = ctx.status_message {
948 if !msg.is_empty() {
949 parts.push(msg);
950 }
951 }
952 if let Some(msg) = ctx.plugin_status_message {
953 if !msg.is_empty() {
954 parts.push(msg);
955 }
956 }
957 if parts.is_empty() {
958 return None;
959 }
960 Some(RenderedElement {
961 text: parts.join(" | "),
962 kind: ElementKind::Messages,
963 })
964 }
965 StatusBarElement::Chord => {
966 if ctx.chord_state.is_empty() {
967 return None;
968 }
969 let chord_str = ctx
970 .chord_state
971 .iter()
972 .map(|(code, modifiers)| {
973 crate::input::keybindings::format_keybinding(code, modifiers)
974 })
975 .collect::<Vec<_>>()
976 .join(" ");
977 Some(RenderedElement {
978 text: format!("[{}]", chord_str),
979 kind: ElementKind::Normal,
980 })
981 }
982 StatusBarElement::LineEnding => Some(RenderedElement {
983 text: format!(" {} ", ctx.state.buffer.line_ending().display_name()),
984 kind: ElementKind::LineEnding,
985 }),
986 StatusBarElement::Encoding => Some(RenderedElement {
987 text: format!(" {} ", ctx.state.buffer.encoding().display_name()),
988 kind: ElementKind::Encoding,
989 }),
990 StatusBarElement::Language => {
991 let text = if ctx.state.language == "text"
992 && ctx.state.display_name != "Text"
993 && ctx.state.display_name != "Plain Text"
994 && ctx.state.display_name != "text"
995 {
996 format!(" {} [syntax only] ", &ctx.state.display_name)
997 } else {
998 format!(" {} ", &ctx.state.display_name)
999 };
1000 Some(RenderedElement {
1001 text,
1002 kind: ElementKind::Language,
1003 })
1004 }
1005 StatusBarElement::Lsp => {
1006 if ctx.lsp_status.is_empty() {
1007 return None;
1008 }
1009 Some(RenderedElement {
1010 text: format!(" {} ", ctx.lsp_status),
1011 kind: ElementKind::Lsp,
1012 })
1013 }
1014 StatusBarElement::Warnings => {
1015 if ctx.general_warning_count == 0 {
1016 return None;
1017 }
1018 Some(RenderedElement {
1019 text: format!(" [\u{26a0} {}] ", ctx.general_warning_count),
1020 kind: ElementKind::WarningBadge,
1021 })
1022 }
1023 StatusBarElement::Update => {
1024 let version = ctx.update_available?;
1025 Some(RenderedElement {
1026 text: format!(" {} ", t!("status.update_available", version = version)),
1027 kind: ElementKind::Update,
1028 })
1029 }
1030 StatusBarElement::Palette => {
1031 let shortcut = ctx
1032 .keybindings
1033 .get_keybinding_for_action(
1034 &crate::input::keybindings::Action::QuickOpen,
1035 crate::input::keybindings::KeyContext::Global,
1036 )
1037 .unwrap_or_else(|| "?".to_string());
1038 Some(RenderedElement {
1039 text: format!(" {} ", t!("status.palette", shortcut = shortcut)),
1040 kind: ElementKind::Palette,
1041 })
1042 }
1043 StatusBarElement::Clock => {
1044 let now = chrono::Local::now();
1045 let text = format!("{:02}:{:02}", now.hour(), now.minute());
1046 Some(RenderedElement {
1047 text,
1048 kind: ElementKind::Clock,
1049 })
1050 }
1051 StatusBarElement::RemoteIndicator => {
1052 let (text, state) = if let Some(over) = ctx.remote_state_override {
1062 (format!(" {} ", over.label()), over.state())
1063 } else {
1064 match ctx.remote_connection {
1065 None => (" Local ".to_string(), RemoteIndicatorState::Local),
1066 Some(conn) if conn.contains("(Disconnected)") => {
1067 (format!(" {} ", conn), RemoteIndicatorState::Disconnected)
1068 }
1069 Some(conn) => (format!(" {} ", conn), RemoteIndicatorState::Connected),
1070 }
1071 };
1072 Some(RenderedElement {
1073 text,
1074 kind: ElementKind::RemoteIndicator(state),
1075 })
1076 }
1077 StatusBarElement::CustomToken(key) => {
1078 if let Some(value) = ctx.dynamic_status_bar_elements.get(key) {
1079 Some(RenderedElement {
1080 text: value.clone(),
1081 kind: ElementKind::Custom,
1082 })
1083 } else {
1084 None }
1086 }
1087 }
1088 }
1089
1090 fn element_style(
1092 kind: ElementKind,
1093 theme: &crate::view::theme::Theme,
1094 hover: StatusBarHover,
1095 _warning_level: WarningLevel,
1096 lsp_state: LspIndicatorState,
1097 ) -> Style {
1098 match kind {
1099 ElementKind::Normal | ElementKind::Messages | ElementKind::Clock => Style::default()
1100 .fg(theme.status_bar_fg)
1101 .bg(theme.status_bar_bg),
1102 ElementKind::RemoteDisconnected => Style::default()
1103 .fg(theme.status_error_indicator_fg)
1104 .bg(theme.status_error_indicator_bg),
1105 ElementKind::LineEnding => {
1106 let is_hovering = hover == StatusBarHover::LineEndingIndicator;
1107 let (fg, bg) = if is_hovering {
1108 (theme.menu_hover_fg, theme.menu_hover_bg)
1109 } else {
1110 (theme.status_bar_fg, theme.status_bar_bg)
1111 };
1112 let mut style = Style::default().fg(fg).bg(bg);
1113 if is_hovering {
1114 style = style.add_modifier(Modifier::UNDERLINED);
1115 }
1116 style
1117 }
1118 ElementKind::Encoding => {
1119 let is_hovering = hover == StatusBarHover::EncodingIndicator;
1120 let (fg, bg) = if is_hovering {
1121 (theme.menu_hover_fg, theme.menu_hover_bg)
1122 } else {
1123 (theme.status_bar_fg, theme.status_bar_bg)
1124 };
1125 let mut style = Style::default().fg(fg).bg(bg);
1126 if is_hovering {
1127 style = style.add_modifier(Modifier::UNDERLINED);
1128 }
1129 style
1130 }
1131 ElementKind::Language => {
1132 let is_hovering = hover == StatusBarHover::LanguageIndicator;
1133 let (fg, bg) = if is_hovering {
1134 (theme.menu_hover_fg, theme.menu_hover_bg)
1135 } else {
1136 (theme.status_bar_fg, theme.status_bar_bg)
1137 };
1138 let mut style = Style::default().fg(fg).bg(bg);
1139 if is_hovering {
1140 style = style.add_modifier(Modifier::UNDERLINED);
1141 }
1142 style
1143 }
1144 ElementKind::Lsp => {
1145 let is_hovering = hover == StatusBarHover::LspIndicator;
1146 let (fg, bg) = match lsp_state {
1157 LspIndicatorState::Error => {
1158 (theme.diagnostic_error_fg, theme.diagnostic_error_bg)
1159 }
1160 LspIndicatorState::Off => (
1161 theme.status_lsp_actionable_fg,
1162 theme.status_lsp_actionable_bg,
1163 ),
1164 LspIndicatorState::On => (theme.status_lsp_on_fg, theme.status_lsp_on_bg),
1165 LspIndicatorState::OffDismissed => (theme.status_bar_fg, theme.status_bar_bg),
1166 LspIndicatorState::None => (theme.status_bar_fg, theme.status_bar_bg),
1167 };
1168 let mut style = Style::default().fg(fg).bg(bg);
1169 if is_hovering && lsp_state != LspIndicatorState::None {
1174 style = style.add_modifier(Modifier::UNDERLINED);
1175 }
1176 style
1177 }
1178 ElementKind::WarningBadge => {
1179 let is_hovering = hover == StatusBarHover::WarningBadge;
1180 let (fg, bg) = if is_hovering {
1181 (
1182 theme.status_warning_indicator_hover_fg,
1183 theme.status_warning_indicator_hover_bg,
1184 )
1185 } else {
1186 (
1187 theme.status_warning_indicator_fg,
1188 theme.status_warning_indicator_bg,
1189 )
1190 };
1191 let mut style = Style::default().fg(fg).bg(bg);
1192 if is_hovering {
1193 style = style.add_modifier(Modifier::UNDERLINED);
1194 }
1195 style
1196 }
1197 ElementKind::Update => Style::default()
1198 .fg(theme.menu_highlight_fg)
1199 .bg(theme.menu_dropdown_bg),
1200 ElementKind::Palette => Style::default()
1205 .fg(theme.status_palette_fg)
1206 .bg(theme.status_palette_bg),
1207 ElementKind::Custom => Style::default()
1208 .fg(theme.status_bar_fg)
1209 .bg(theme.status_bar_bg),
1210 ElementKind::RemoteIndicator(state) => {
1211 let is_hovering = hover == StatusBarHover::RemoteIndicator;
1212 let (fg, bg) = match state {
1213 RemoteIndicatorState::Connecting | RemoteIndicatorState::Connected => {
1219 (theme.help_indicator_fg, theme.help_indicator_bg)
1220 }
1221 RemoteIndicatorState::FailedAttach | RemoteIndicatorState::Disconnected => (
1225 theme.status_error_indicator_fg,
1226 theme.status_error_indicator_bg,
1227 ),
1228 RemoteIndicatorState::Local => (theme.status_bar_fg, theme.status_bar_bg),
1230 };
1231 let mut style = Style::default().fg(fg).bg(bg);
1232 if is_hovering {
1233 style = style.add_modifier(Modifier::UNDERLINED);
1234 }
1235 style
1236 }
1237 }
1238 }
1239
1240 fn update_layout_for_element(
1242 layout: &mut StatusBarLayout,
1243 kind: ElementKind,
1244 row: u16,
1245 start_col: u16,
1246 end_col: u16,
1247 ) {
1248 match kind {
1249 ElementKind::LineEnding => {
1250 layout.line_ending_indicator = Some((row, start_col, end_col))
1251 }
1252 ElementKind::Encoding => layout.encoding_indicator = Some((row, start_col, end_col)),
1253 ElementKind::Language => layout.language_indicator = Some((row, start_col, end_col)),
1254 ElementKind::Lsp => layout.lsp_indicator = Some((row, start_col, end_col)),
1255 ElementKind::WarningBadge => layout.warning_badge = Some((row, start_col, end_col)),
1256 ElementKind::Messages => layout.message_area = Some((row, start_col, end_col)),
1257 ElementKind::RemoteIndicator(_) => {
1258 layout.remote_indicator = Some((row, start_col, end_col))
1259 }
1260 _ => {}
1261 }
1262 }
1263
1264 fn element_spans(
1269 rendered: &RenderedElement,
1270 theme: &crate::view::theme::Theme,
1271 hover: StatusBarHover,
1272 warning_level: WarningLevel,
1273 lsp_state: LspIndicatorState,
1274 ) -> (Vec<Span<'static>>, usize) {
1275 let base_style = Style::default()
1276 .fg(theme.status_bar_fg)
1277 .bg(theme.status_bar_bg);
1278 let width = str_width(&rendered.text);
1279
1280 if rendered.kind == ElementKind::RemoteDisconnected && rendered.text.starts_with(SSH_PREFIX)
1281 {
1282 let error_style = Style::default()
1283 .fg(theme.status_error_indicator_fg)
1284 .bg(theme.status_error_indicator_bg);
1285 if let Some(term_off) = rendered.text.find(SSH_PREFIX_TERMINATOR) {
1286 let split_at = term_off + SSH_PREFIX_TERMINATOR.len();
1287 let prefix = rendered.text[..split_at].to_string();
1288 let rest = rendered.text[split_at..].to_string();
1289 return (
1290 vec![
1291 Span::styled(prefix, error_style),
1292 Span::styled(rest, base_style),
1293 ],
1294 width,
1295 );
1296 }
1297 return (
1298 vec![Span::styled(rendered.text.clone(), error_style)],
1299 width,
1300 );
1301 }
1302
1303 let style = Self::element_style(rendered.kind, theme, hover, warning_level, lsp_state);
1304 let spans = if rendered.kind == ElementKind::Clock {
1305 vec![
1307 Span::styled(rendered.text[..2].to_string(), style),
1308 Span::styled(":".to_string(), style.add_modifier(Modifier::SLOW_BLINK)),
1309 Span::styled(rendered.text[3..].to_string(), style),
1310 ]
1311 } else {
1312 vec![Span::styled(rendered.text.clone(), style)]
1313 };
1314 (spans, width)
1315 }
1316
1317 fn render_side(
1319 config_side: &[StatusBarElement],
1320 ctx: &mut StatusBarContext<'_>,
1321 ) -> Vec<(Vec<Span<'static>>, usize, ElementKind)> {
1322 let rendered: Vec<RenderedElement> = config_side
1323 .iter()
1324 .filter_map(|elem| Self::render_element(elem, ctx))
1325 .filter(|e| !e.text.is_empty())
1326 .collect();
1327
1328 let theme = ctx.theme;
1329 let hover = ctx.hover;
1330 let warning_level = ctx.warning_level;
1331 let lsp_state = ctx.lsp_indicator_state;
1332 rendered
1333 .into_iter()
1334 .map(|r| {
1335 let kind = r.kind;
1336 let (spans, width) =
1337 Self::element_spans(&r, theme, hover, warning_level, lsp_state);
1338 (spans, width, kind)
1339 })
1340 .collect()
1341 }
1342
1343 fn render_status(
1345 frame: &mut Frame,
1346 area: Rect,
1347 ctx: &mut StatusBarContext<'_>,
1348 config: &StatusBarConfig,
1349 ) -> StatusBarLayout {
1350 let mut layout = StatusBarLayout::default();
1351 let base_style = Style::default()
1352 .fg(ctx.theme.status_bar_fg)
1353 .bg(ctx.theme.status_bar_bg);
1354 let available_width = area.width as usize;
1355
1356 if available_width == 0 || area.height == 0 {
1357 return layout;
1358 }
1359
1360 ctx.remote_indicator_on_bar = config
1365 .left
1366 .iter()
1367 .chain(config.right.iter())
1368 .any(|e| matches!(e, StatusBarElement::RemoteIndicator));
1369
1370 let left_items = Self::render_side(&config.left, ctx);
1371 let mut right_items = Self::render_side(&config.right, ctx);
1372
1373 const SEPARATOR: &str = " | ";
1374 let separator_width = str_width(SEPARATOR);
1375
1376 let total_right_width: usize = right_items.iter().map(|(_, w, _)| *w).sum();
1385 let left_min_target = available_width
1386 .saturating_mul(2)
1387 .saturating_div(5) .min(40); let right_budget = available_width.saturating_sub(left_min_target + 1);
1390 if total_right_width > right_budget && right_items.len() > 1 {
1391 let mut current = total_right_width;
1392 while current > right_budget && right_items.len() > 1 {
1393 if let Some(dropped) = right_items.pop() {
1394 current = current.saturating_sub(dropped.1);
1395 } else {
1396 break;
1397 }
1398 }
1399 }
1400
1401 let right_width: usize = right_items.iter().map(|(_, w, _)| *w).sum();
1402
1403 let narrow = available_width < 15;
1404 let left_max_width = if narrow {
1405 available_width
1406 } else if available_width > right_width + 1 {
1407 available_width - right_width - 1
1408 } else {
1409 1
1410 };
1411
1412 let mut spans: Vec<Span<'static>> = Vec::new();
1416 let mut used_left: usize = 0;
1417
1418 for (idx, (item_spans, width, kind)) in left_items.into_iter().enumerate() {
1419 let sep_width = if idx == 0 { 0 } else { separator_width };
1420 if used_left + sep_width >= left_max_width {
1421 break;
1422 }
1423 if sep_width > 0 {
1424 spans.push(Span::styled(SEPARATOR, base_style));
1425 used_left += sep_width;
1426 }
1427
1428 let remaining = left_max_width - used_left;
1429 let start_col = used_left;
1430
1431 if width <= remaining {
1432 spans.extend(item_spans);
1433 used_left += width;
1434
1435 Self::update_layout_for_element(
1436 &mut layout,
1437 kind,
1438 area.y,
1439 area.x + start_col as u16,
1440 area.x + (start_col + width) as u16,
1441 );
1442 } else {
1443 let group_text: String = item_spans.iter().map(|s| s.content.as_ref()).collect();
1447 let truncated = truncate_to_width(&group_text, remaining);
1448 let truncated_width = str_width(&truncated);
1449 let overflow_style = Self::element_style(
1450 kind,
1451 ctx.theme,
1452 ctx.hover,
1453 ctx.warning_level,
1454 ctx.lsp_indicator_state,
1455 );
1456 spans.push(Span::styled(truncated, overflow_style));
1457 used_left += truncated_width;
1458
1459 Self::update_layout_for_element(
1460 &mut layout,
1461 kind,
1462 area.y,
1463 area.x + start_col as u16,
1464 area.x + (start_col + truncated_width) as u16,
1465 );
1466 break;
1467 }
1468 }
1469
1470 if narrow {
1471 if used_left < available_width {
1472 spans.push(Span::styled(
1473 " ".repeat(available_width - used_left),
1474 base_style,
1475 ));
1476 }
1477 frame.render_widget(Paragraph::new(Line::from(spans)), area);
1478 return layout;
1479 }
1480
1481 let mut col_offset = used_left;
1482 if col_offset + right_width < available_width {
1483 let padding = available_width - col_offset - right_width;
1484 spans.push(Span::styled(" ".repeat(padding), base_style));
1485 col_offset = available_width - right_width;
1486 } else if col_offset < available_width {
1487 spans.push(Span::styled(" ", base_style));
1488 col_offset += 1;
1489 }
1490
1491 let mut current_col = area.x + col_offset as u16;
1492 for (item_spans, width, kind) in right_items {
1493 Self::update_layout_for_element(
1494 &mut layout,
1495 kind,
1496 area.y,
1497 current_col,
1498 current_col + width as u16,
1499 );
1500 spans.extend(item_spans);
1501 current_col += width as u16;
1502 }
1503
1504 frame.render_widget(Paragraph::new(Line::from(spans)), area);
1505 layout
1506 }
1507
1508 #[allow(clippy::too_many_arguments)]
1519 pub fn render_search_options(
1520 frame: &mut Frame,
1521 area: Rect,
1522 case_sensitive: bool,
1523 whole_word: bool,
1524 use_regex: bool,
1525 confirm_each: Option<bool>, theme: &crate::view::theme::Theme,
1527 keybindings: &crate::input::keybindings::KeybindingResolver,
1528 hover: SearchOptionsHover,
1529 ) -> SearchOptionsLayout {
1530 use crate::primitives::display_width::str_width;
1531
1532 let mut layout = SearchOptionsLayout {
1533 row: area.y,
1534 ..Default::default()
1535 };
1536
1537 let base_style = Style::default()
1539 .fg(theme.menu_dropdown_fg)
1540 .bg(theme.menu_dropdown_bg);
1541
1542 let hover_style = Style::default()
1544 .fg(theme.menu_hover_fg)
1545 .bg(theme.menu_hover_bg);
1546
1547 let get_shortcut = |action: &crate::input::keybindings::Action| -> Option<String> {
1549 keybindings
1550 .get_keybinding_for_action(action, crate::input::keybindings::KeyContext::Prompt)
1551 .or_else(|| {
1552 keybindings.get_keybinding_for_action(
1553 action,
1554 crate::input::keybindings::KeyContext::Global,
1555 )
1556 })
1557 };
1558
1559 let case_shortcut =
1561 get_shortcut(&crate::input::keybindings::Action::ToggleSearchCaseSensitive);
1562 let word_shortcut = get_shortcut(&crate::input::keybindings::Action::ToggleSearchWholeWord);
1563 let regex_shortcut = get_shortcut(&crate::input::keybindings::Action::ToggleSearchRegex);
1564
1565 let case_checkbox = if case_sensitive { "[x]" } else { "[ ]" };
1567 let word_checkbox = if whole_word { "[x]" } else { "[ ]" };
1568 let regex_checkbox = if use_regex { "[x]" } else { "[ ]" };
1569
1570 let active_style = Style::default()
1572 .fg(theme.menu_highlight_fg)
1573 .bg(theme.menu_dropdown_bg);
1574
1575 let shortcut_style = Style::default()
1577 .fg(theme.help_separator_fg)
1578 .bg(theme.menu_dropdown_bg);
1579
1580 let hover_shortcut_style = Style::default()
1582 .fg(theme.menu_hover_fg)
1583 .bg(theme.menu_hover_bg);
1584
1585 let mut spans = Vec::new();
1586 let mut current_col = area.x;
1587
1588 spans.push(Span::styled(" ", base_style));
1590 current_col += 1;
1591
1592 let get_checkbox_style = |is_hovered: bool, is_checked: bool| -> Style {
1594 if is_hovered {
1595 hover_style
1596 } else if is_checked {
1597 active_style
1598 } else {
1599 base_style
1600 }
1601 };
1602
1603 let case_hovered = hover == SearchOptionsHover::CaseSensitive;
1605 let case_start = current_col;
1606 let case_label = format!("{} {}", case_checkbox, t!("search.case_sensitive"));
1607 let case_shortcut_text = case_shortcut
1608 .as_ref()
1609 .map(|s| format!(" ({})", s))
1610 .unwrap_or_default();
1611 let case_full_width = str_width(&case_label) + str_width(&case_shortcut_text);
1612
1613 spans.push(Span::styled(
1614 case_label,
1615 get_checkbox_style(case_hovered, case_sensitive),
1616 ));
1617 if !case_shortcut_text.is_empty() {
1618 spans.push(Span::styled(
1619 case_shortcut_text,
1620 if case_hovered {
1621 hover_shortcut_style
1622 } else {
1623 shortcut_style
1624 },
1625 ));
1626 }
1627 current_col += case_full_width as u16;
1628 layout.case_sensitive = Some((case_start, current_col));
1629
1630 spans.push(Span::styled(" ", base_style));
1632 current_col += 3;
1633
1634 let word_hovered = hover == SearchOptionsHover::WholeWord;
1636 let word_start = current_col;
1637 let word_label = format!("{} {}", word_checkbox, t!("search.whole_word"));
1638 let word_shortcut_text = word_shortcut
1639 .as_ref()
1640 .map(|s| format!(" ({})", s))
1641 .unwrap_or_default();
1642 let word_full_width = str_width(&word_label) + str_width(&word_shortcut_text);
1643
1644 spans.push(Span::styled(
1645 word_label,
1646 get_checkbox_style(word_hovered, whole_word),
1647 ));
1648 if !word_shortcut_text.is_empty() {
1649 spans.push(Span::styled(
1650 word_shortcut_text,
1651 if word_hovered {
1652 hover_shortcut_style
1653 } else {
1654 shortcut_style
1655 },
1656 ));
1657 }
1658 current_col += word_full_width as u16;
1659 layout.whole_word = Some((word_start, current_col));
1660
1661 spans.push(Span::styled(" ", base_style));
1663 current_col += 3;
1664
1665 let regex_hovered = hover == SearchOptionsHover::Regex;
1667 let regex_start = current_col;
1668 let regex_label = format!("{} {}", regex_checkbox, t!("search.regex"));
1669 let regex_shortcut_text = regex_shortcut
1670 .as_ref()
1671 .map(|s| format!(" ({})", s))
1672 .unwrap_or_default();
1673 let regex_full_width = str_width(®ex_label) + str_width(®ex_shortcut_text);
1674
1675 spans.push(Span::styled(
1676 regex_label,
1677 get_checkbox_style(regex_hovered, use_regex),
1678 ));
1679 if !regex_shortcut_text.is_empty() {
1680 spans.push(Span::styled(
1681 regex_shortcut_text,
1682 if regex_hovered {
1683 hover_shortcut_style
1684 } else {
1685 shortcut_style
1686 },
1687 ));
1688 }
1689 current_col += regex_full_width as u16;
1690 layout.regex = Some((regex_start, current_col));
1691
1692 if use_regex && confirm_each.is_some() {
1694 let hint = " \u{2502} $1,$2,…";
1695 spans.push(Span::styled(hint, shortcut_style));
1696 current_col += str_width(hint) as u16;
1697 }
1698
1699 if let Some(confirm_value) = confirm_each {
1701 let confirm_shortcut =
1702 get_shortcut(&crate::input::keybindings::Action::ToggleSearchConfirmEach);
1703 let confirm_checkbox = if confirm_value { "[x]" } else { "[ ]" };
1704
1705 spans.push(Span::styled(" ", base_style));
1707 current_col += 3;
1708
1709 let confirm_hovered = hover == SearchOptionsHover::ConfirmEach;
1710 let confirm_start = current_col;
1711 let confirm_label = format!("{} {}", confirm_checkbox, t!("search.confirm_each"));
1712 let confirm_shortcut_text = confirm_shortcut
1713 .as_ref()
1714 .map(|s| format!(" ({})", s))
1715 .unwrap_or_default();
1716 let confirm_full_width = str_width(&confirm_label) + str_width(&confirm_shortcut_text);
1717
1718 spans.push(Span::styled(
1719 confirm_label,
1720 get_checkbox_style(confirm_hovered, confirm_value),
1721 ));
1722 if !confirm_shortcut_text.is_empty() {
1723 spans.push(Span::styled(
1724 confirm_shortcut_text,
1725 if confirm_hovered {
1726 hover_shortcut_style
1727 } else {
1728 shortcut_style
1729 },
1730 ));
1731 }
1732 current_col += confirm_full_width as u16;
1733 layout.confirm_each = Some((confirm_start, current_col));
1734 }
1735
1736 let current_width = (current_col - area.x) as usize;
1738 let available_width = area.width as usize;
1739 if current_width < available_width {
1740 spans.push(Span::styled(
1741 " ".repeat(available_width.saturating_sub(current_width)),
1742 base_style,
1743 ));
1744 }
1745
1746 let options_line = Paragraph::new(Line::from(spans));
1747 frame.render_widget(options_line, area);
1748
1749 layout
1750 }
1751}
1752
1753#[cfg(test)]
1754mod tests {
1755 use super::*;
1756 use std::path::PathBuf;
1757
1758 #[test]
1759 fn test_truncate_path_short_path() {
1760 let path = PathBuf::from("/home/user/project");
1761 let result = truncate_path(&path, 50);
1762
1763 assert!(!result.truncated);
1764 assert_eq!(result.suffix, "/home/user/project");
1765 assert!(result.prefix.is_empty());
1766 }
1767
1768 #[test]
1769 fn test_truncate_path_long_path() {
1770 let path = PathBuf::from(
1771 "/private/var/folders/p6/nlmq3k8146990kpkxl73mq340000gn/T/.tmpNYt4Fc/project_root",
1772 );
1773 let result = truncate_path(&path, 40);
1774
1775 assert!(result.truncated, "Path should be truncated");
1776 assert_eq!(result.prefix, "/private");
1777 assert!(
1778 result.suffix.contains("project_root"),
1779 "Suffix should contain project_root"
1780 );
1781 }
1782
1783 #[test]
1784 fn test_truncate_path_preserves_last_components() {
1785 let path = PathBuf::from("/a/b/c/d/e/f/g/h/i/j/project/src");
1786 let result = truncate_path(&path, 30);
1787
1788 assert!(result.truncated);
1789 assert!(
1791 result.suffix.contains("src"),
1792 "Should preserve last component 'src', got: {}",
1793 result.suffix
1794 );
1795 }
1796
1797 #[test]
1798 fn test_truncate_path_display_len() {
1799 let path = PathBuf::from("/private/var/folders/deep/nested/path/here");
1800 let result = truncate_path(&path, 30);
1801
1802 let display = result.to_string_plain();
1804 assert!(
1805 display.len() <= 35, "Display should be truncated to around 30 chars, got {} chars: {}",
1807 display.len(),
1808 display
1809 );
1810 }
1811
1812 #[test]
1813 fn test_truncate_path_root_only() {
1814 let path = PathBuf::from("/");
1815 let result = truncate_path(&path, 50);
1816
1817 assert!(!result.truncated);
1818 assert_eq!(result.suffix, "/");
1819 }
1820
1821 #[test]
1822 fn test_truncate_path_multibyte_single_component_does_not_panic() {
1823 let path = PathBuf::from("/ユーザーのプロジェクト名前/file");
1829 let result = truncate_path(&path, 5);
1830 let display = result.to_string_plain();
1831 assert!(display.is_char_boundary(display.len()));
1832 assert!(display.ends_with("..."));
1833 }
1834
1835 #[test]
1836 fn test_truncate_path_multibyte_last_component_does_not_panic() {
1837 let path = PathBuf::from("/a/ユーザーのプロジェクト名前");
1844 let result = truncate_path(&path, 13);
1845 let display = result.to_string_plain();
1846 assert!(display.is_char_boundary(display.len()));
1847 }
1848
1849 #[test]
1850 fn test_truncated_path_to_string_plain() {
1851 let truncated = TruncatedPath {
1852 prefix: "/home".to_string(),
1853 truncated: true,
1854 suffix: "/project/src".to_string(),
1855 sep: '/',
1856 };
1857
1858 assert_eq!(truncated.to_string_plain(), "/home/[...]/project/src");
1859 }
1860
1861 #[test]
1862 fn test_truncated_path_to_string_plain_no_truncation() {
1863 let truncated = TruncatedPath {
1864 prefix: String::new(),
1865 truncated: false,
1866 suffix: "/home/user/project".to_string(),
1867 sep: '/',
1868 };
1869
1870 assert_eq!(truncated.to_string_plain(), "/home/user/project");
1871 }
1872
1873 #[test]
1879 fn test_truncate_path_windows_backslashes() {
1880 let path = Path::new(r"C:\Users\me\projects\fresh\crates\editor\src\main.rs");
1881 let t = truncate_path(path, 34);
1882 assert!(t.truncated, "long backslash path should middle-truncate");
1883 assert_eq!(t.sep, '\\', "should re-join with backslashes");
1884 let shown = t.to_string_plain();
1885 assert!(
1886 shown.starts_with(r"C:\Users"),
1887 "keeps drive + first dir: {shown}"
1888 );
1889 assert!(
1890 shown.contains(r"\[...]\"),
1891 "uses a backslash ellipsis: {shown}"
1892 );
1893 assert!(shown.ends_with("main.rs"), "keeps the tail: {shown}");
1894 assert!(!shown.contains('/'), "no forward slashes leak in: {shown}");
1895 assert!(shown.len() <= 34, "respects max_len: {shown}");
1896 }
1897
1898 #[test]
1900 fn test_truncate_path_windows_short_unchanged() {
1901 let path = Path::new(r"C:\a\b");
1902 let t = truncate_path(path, 80);
1903 assert!(!t.truncated);
1904 assert_eq!(t.to_string_plain(), r"C:\a\b");
1905 }
1906
1907 #[test]
1908 fn test_remote_indicator_element_kind_equality() {
1909 assert_eq!(
1913 ElementKind::RemoteIndicator(RemoteIndicatorState::Local),
1914 ElementKind::RemoteIndicator(RemoteIndicatorState::Local)
1915 );
1916 let distinct = [
1917 RemoteIndicatorState::Local,
1918 RemoteIndicatorState::Connecting,
1919 RemoteIndicatorState::Connected,
1920 RemoteIndicatorState::FailedAttach,
1921 RemoteIndicatorState::Disconnected,
1922 ];
1923 for (i, a) in distinct.iter().enumerate() {
1924 for (j, b) in distinct.iter().enumerate() {
1925 if i == j {
1926 continue;
1927 }
1928 assert_ne!(
1929 ElementKind::RemoteIndicator(*a),
1930 ElementKind::RemoteIndicator(*b),
1931 "expected {:?} != {:?}",
1932 a,
1933 b
1934 );
1935 }
1936 }
1937 }
1938
1939 #[test]
1940 fn test_remote_indicator_state_default_is_local() {
1941 assert_eq!(RemoteIndicatorState::default(), RemoteIndicatorState::Local);
1944 }
1945
1946 #[test]
1947 fn test_remote_indicator_override_deserializes_kind_tags() {
1948 let cases: &[(&str, RemoteIndicatorOverride)] = &[
1952 (r#"{"kind":"local"}"#, RemoteIndicatorOverride::Local),
1953 (
1954 r#"{"kind":"connecting","label":"Building"}"#,
1955 RemoteIndicatorOverride::Connecting {
1956 label: Some("Building".into()),
1957 },
1958 ),
1959 (
1960 r#"{"kind":"connecting"}"#,
1961 RemoteIndicatorOverride::Connecting { label: None },
1962 ),
1963 (
1964 r#"{"kind":"connected","label":"Container:abc"}"#,
1965 RemoteIndicatorOverride::Connected {
1966 label: Some("Container:abc".into()),
1967 },
1968 ),
1969 (
1970 r#"{"kind":"failed_attach","error":"exit 1"}"#,
1971 RemoteIndicatorOverride::FailedAttach {
1972 error: Some("exit 1".into()),
1973 },
1974 ),
1975 (
1976 r#"{"kind":"disconnected","label":"Container:abc"}"#,
1977 RemoteIndicatorOverride::Disconnected {
1978 label: Some("Container:abc".into()),
1979 },
1980 ),
1981 ];
1982 for (json, expected) in cases {
1983 let parsed: RemoteIndicatorOverride = serde_json::from_str(json)
1984 .unwrap_or_else(|e| panic!("failed to parse {}: {}", json, e));
1985 assert_eq!(&parsed, expected, "wire shape mismatch for {}", json);
1986 }
1987 }
1988
1989 #[test]
1990 fn test_remote_indicator_override_labels() {
1991 let connecting = RemoteIndicatorOverride::Connecting { label: None };
1995 assert!(
1996 connecting.label().contains("Connecting"),
1997 "connecting default label should mention Connecting, got {:?}",
1998 connecting.label()
1999 );
2000
2001 let connecting_labeled = RemoteIndicatorOverride::Connecting {
2002 label: Some("Building".into()),
2003 };
2004 assert!(
2005 connecting_labeled.label().contains("Building"),
2006 "labeled connecting should include the label, got {:?}",
2007 connecting_labeled.label()
2008 );
2009
2010 let failed_bare = RemoteIndicatorOverride::FailedAttach { error: None };
2011 assert_eq!(failed_bare.label(), "Attach failed");
2012
2013 let failed_detail = RemoteIndicatorOverride::FailedAttach {
2014 error: Some("exit 1".into()),
2015 };
2016 assert!(
2017 failed_detail.label().contains("exit 1"),
2018 "failed with error should include the error, got {:?}",
2019 failed_detail.label()
2020 );
2021 }
2022
2023 #[test]
2024 fn test_palette_and_lsp_on_use_dedicated_theme_keys() {
2025 let theme = crate::view::theme::Theme::from_json(
2037 r#"{"name":"t","editor":{},"ui":{},"search":{},"diagnostic":{},"syntax":{}}"#,
2038 )
2039 .expect("minimal theme should parse");
2040
2041 assert_eq!(theme.status_palette_fg, theme.status_bar_fg);
2043 assert_eq!(theme.status_palette_bg, theme.status_bar_bg);
2044 assert_eq!(theme.status_lsp_on_fg, theme.status_bar_fg);
2045 assert_eq!(theme.status_lsp_on_bg, theme.status_bar_bg);
2046
2047 let palette_style = StatusBarRenderer::element_style(
2048 ElementKind::Palette,
2049 &theme,
2050 StatusBarHover::None,
2051 WarningLevel::None,
2052 LspIndicatorState::None,
2053 );
2054 assert_eq!(palette_style.fg, Some(theme.status_palette_fg));
2055 assert_eq!(palette_style.bg, Some(theme.status_palette_bg));
2056
2057 let lsp_on_style = StatusBarRenderer::element_style(
2058 ElementKind::Lsp,
2059 &theme,
2060 StatusBarHover::None,
2061 WarningLevel::None,
2062 LspIndicatorState::On,
2063 );
2064 assert_eq!(lsp_on_style.fg, Some(theme.status_lsp_on_fg));
2065 assert_eq!(lsp_on_style.bg, Some(theme.status_lsp_on_bg));
2066
2067 let lsp_off_style = StatusBarRenderer::element_style(
2070 ElementKind::Lsp,
2071 &theme,
2072 StatusBarHover::None,
2073 WarningLevel::None,
2074 LspIndicatorState::Off,
2075 );
2076 assert_eq!(lsp_off_style.fg, Some(theme.status_lsp_actionable_fg));
2077 assert_eq!(lsp_off_style.bg, Some(theme.status_lsp_actionable_bg));
2078
2079 let lsp_error_style = StatusBarRenderer::element_style(
2080 ElementKind::Lsp,
2081 &theme,
2082 StatusBarHover::None,
2083 WarningLevel::None,
2084 LspIndicatorState::Error,
2085 );
2086 assert_eq!(lsp_error_style.fg, Some(theme.diagnostic_error_fg));
2087 assert_eq!(lsp_error_style.bg, Some(theme.diagnostic_error_bg));
2088 }
2089
2090 #[test]
2091 fn test_status_palette_and_lsp_on_keys_override_independently() {
2092 let theme_json = r#"{
2098 "name":"t",
2099 "editor":{},
2100 "ui":{
2101 "status_bar_fg":"White",
2102 "status_bar_bg":"DarkGray",
2103 "status_palette_fg":"Black",
2104 "status_palette_bg":"Yellow",
2105 "status_lsp_on_fg":"Black",
2106 "status_lsp_on_bg":"Cyan"
2107 },
2108 "search":{},
2109 "diagnostic":{},
2110 "syntax":{}
2111 }"#;
2112 let theme = crate::view::theme::Theme::from_json(theme_json).expect("theme should parse");
2113 assert_ne!(theme.status_palette_fg, theme.status_bar_fg);
2114 assert_ne!(theme.status_palette_bg, theme.status_bar_bg);
2115 assert_ne!(theme.status_lsp_on_fg, theme.status_bar_fg);
2116 assert_ne!(theme.status_lsp_on_bg, theme.status_bar_bg);
2117 }
2118
2119 #[test]
2120 fn test_remote_indicator_override_state_projection() {
2121 assert_eq!(
2122 RemoteIndicatorOverride::Local.state(),
2123 RemoteIndicatorState::Local
2124 );
2125 assert_eq!(
2126 RemoteIndicatorOverride::Connecting { label: None }.state(),
2127 RemoteIndicatorState::Connecting
2128 );
2129 assert_eq!(
2130 RemoteIndicatorOverride::Connected { label: None }.state(),
2131 RemoteIndicatorState::Connected
2132 );
2133 assert_eq!(
2134 RemoteIndicatorOverride::FailedAttach { error: None }.state(),
2135 RemoteIndicatorState::FailedAttach
2136 );
2137 assert_eq!(
2138 RemoteIndicatorOverride::Disconnected { label: None }.state(),
2139 RemoteIndicatorState::Disconnected
2140 );
2141 }
2142
2143 #[test]
2152 fn test_cursor_position_widths_stable_across_cursor_movement() {
2153 let line_count = 50;
2154 let widths: Vec<usize> = [(1, 1), (5, 12), (12, 5), (50, 100), (1, 1)]
2157 .into_iter()
2158 .map(|(ln, col)| format_cursor_position(ln, col, line_count).len())
2159 .collect();
2160 assert!(
2161 widths.windows(2).all(|w| w[0] == w[1]),
2162 "rendered widths drift across cursor movements: {widths:?}"
2163 );
2164 }
2165
2166 #[test]
2167 fn test_cursor_position_preserves_natural_number_text() {
2168 let text = format_cursor_position(1, 1, 50);
2172 assert!(
2173 text.starts_with("Ln 1, Col 1"),
2174 "expected text to start with natural numbers, got {text:?}"
2175 );
2176 assert!(
2177 text.ends_with(' '),
2178 "expected trailing padding, got {text:?}"
2179 );
2180 }
2181
2182 #[test]
2183 fn test_cursor_position_no_padding_for_single_line_buffer() {
2184 let text = format_cursor_position(1, 1, 1);
2188 assert_eq!(text.len(), 13);
2190 assert!(text.starts_with("Ln 1, Col 1"));
2191 }
2192
2193 #[test]
2194 fn test_cursor_position_does_not_shrink_below_actual() {
2195 let text = format_cursor_position(99, 99999, 50);
2198 assert_eq!(text, "Ln 99, Col 99999");
2199 }
2200
2201 #[test]
2202 fn test_cursor_position_compact_widths_stable() {
2203 let line_count = 50;
2204 let widths: Vec<usize> = [(1, 1), (5, 12), (12, 5), (50, 100), (1, 1)]
2205 .into_iter()
2206 .map(|(ln, col)| format_cursor_position_compact(ln, col, line_count).len())
2207 .collect();
2208 assert!(
2209 widths.windows(2).all(|w| w[0] == w[1]),
2210 "compact widths drift across cursor movements: {widths:?}"
2211 );
2212 }
2213
2214 #[test]
2215 fn test_cursor_position_compact_preserves_natural_text() {
2216 let text = format_cursor_position_compact(1, 1, 50);
2217 assert!(
2218 text.starts_with("1:1"),
2219 "expected text to start with natural numbers, got {text:?}"
2220 );
2221 }
2222
2223 #[test]
2224 fn test_cursor_position_scales_with_line_count() {
2225 let short = format_cursor_position(1, 1, 9);
2228 let long = format_cursor_position(1, 1, 10_000);
2229 assert!(
2230 long.len() > short.len(),
2231 "wider buffers should reserve more width: {short:?} vs {long:?}"
2232 );
2233 let top = format_cursor_position(1, 1, 10_000);
2236 let high = format_cursor_position(9_999, 999, 10_000);
2237 assert_eq!(top.len(), high.len());
2238 }
2239}