1use std::collections::HashMap;
4use std::path::Path;
5
6use crate::app::types::CellThemeRecorder;
7use crate::app::WarningLevel;
8use crate::config::{StatusBarConfig, StatusBarElement};
9use crate::primitives::display_width::{char_width, str_width};
10use crate::state::EditorState;
11use crate::view::prompt::Prompt;
12use chrono::Timelike;
13use ratatui::layout::Rect;
14use ratatui::style::{Modifier, Style};
15use ratatui::text::{Line, Span};
16use ratatui::widgets::Paragraph;
17use ratatui::Frame;
18use rust_i18n::t;
19
20const SSH_PREFIX: &str = "[SSH:";
24const SSH_PREFIX_TERMINATOR: &str = "] ";
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28enum ElementKind {
29 Normal,
31 LineEnding,
33 Encoding,
35 Language,
37 Lsp,
39 WarningBadge,
41 Update,
43 Palette,
45 Messages,
47 RemoteDisconnected,
49 Clock,
51 RemoteIndicator(RemoteIndicatorState),
53 WorkspaceTrust(crate::services::workspace_trust::TrustLevel),
56 Custom,
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
74pub enum RemoteIndicatorState {
75 #[default]
77 Local,
78 Connecting,
83 Connected,
85 FailedAttach,
89 Disconnected,
92}
93
94#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
104#[serde(tag = "kind", rename_all = "snake_case")]
105pub enum RemoteIndicatorOverride {
106 Local,
109 Connecting {
112 #[serde(default)]
113 label: Option<String>,
114 },
115 Connected {
118 #[serde(default)]
119 label: Option<String>,
120 },
121 FailedAttach {
124 #[serde(default)]
125 error: Option<String>,
126 },
127 Disconnected {
130 #[serde(default)]
131 label: Option<String>,
132 },
133}
134
135impl RemoteIndicatorOverride {
136 pub fn state(&self) -> RemoteIndicatorState {
138 match self {
139 Self::Local => RemoteIndicatorState::Local,
140 Self::Connecting { .. } => RemoteIndicatorState::Connecting,
141 Self::Connected { .. } => RemoteIndicatorState::Connected,
142 Self::FailedAttach { .. } => RemoteIndicatorState::FailedAttach,
143 Self::Disconnected { .. } => RemoteIndicatorState::Disconnected,
144 }
145 }
146
147 pub fn label(&self) -> String {
151 match self {
152 Self::Local => "Local".to_string(),
153 Self::Connecting { label } => match label {
154 Some(s) if !s.is_empty() => format!("⠿ {}", s),
155 _ => "⠿ Connecting".to_string(),
156 },
157 Self::Connected { label } => label
158 .as_deref()
159 .filter(|s| !s.is_empty())
160 .unwrap_or("Connected")
161 .to_string(),
162 Self::FailedAttach { error } => match error {
163 Some(s) if !s.is_empty() => format!("Attach failed: {}", s),
164 _ => "Attach failed".to_string(),
165 },
166 Self::Disconnected { label } => match label {
167 Some(s) if !s.is_empty() => format!("{} (Disconnected)", s),
168 _ => "Disconnected".to_string(),
169 },
170 }
171 }
172}
173
174struct RenderedElement {
176 text: String,
177 kind: ElementKind,
178 token_key: Option<String>,
183}
184
185#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
201pub enum LspIndicatorState {
202 #[default]
203 None,
204 On,
205 Off,
206 OffDismissed,
207 Error,
208}
209
210pub struct StatusBarContext<'a> {
212 pub state: &'a mut EditorState,
213 pub cursors: &'a crate::model::cursor::Cursors,
214 pub status_message: &'a Option<String>,
215 pub plugin_status_message: &'a Option<String>,
216 pub lsp_status: &'a str,
217 pub lsp_indicator_state: LspIndicatorState,
222 pub theme: &'a crate::view::theme::Theme,
223 pub display_name: &'a str,
224 pub keybindings: &'a crate::input::keybindings::KeybindingResolver,
225 pub chord_state: &'a [(crossterm::event::KeyCode, crossterm::event::KeyModifiers)],
226 pub update_available: Option<&'a str>,
227 pub warning_level: WarningLevel,
228 pub general_warning_count: usize,
229 pub hover: StatusBarHover,
230 pub remote_connection: Option<&'a str>,
231 pub session_name: Option<&'a str>,
232 pub read_only: bool,
233 pub remote_state_override: Option<&'a RemoteIndicatorOverride>,
240 pub is_synthetic_placeholder: bool,
246 pub remote_indicator_on_bar: bool,
255 pub dynamic_status_bar_elements: HashMap<String, String>,
259 pub workspace_trust_level: crate::services::workspace_trust::TrustLevel,
263}
264
265#[derive(Debug, Clone, Default)]
267pub struct StatusBarLayout {
268 pub lsp_indicator: Option<(u16, u16, u16)>,
270 pub warning_badge: Option<(u16, u16, u16)>,
272 pub line_ending_indicator: Option<(u16, u16, u16)>,
274 pub encoding_indicator: Option<(u16, u16, u16)>,
276 pub language_indicator: Option<(u16, u16, u16)>,
278 pub message_area: Option<(u16, u16, u16)>,
280 pub remote_indicator: Option<(u16, u16, u16)>,
283 pub trust_indicator: Option<(u16, u16, u16)>,
286 pub plugin_token_areas: std::collections::HashMap<String, (u16, u16, u16)>,
298 pub segments: Vec<StatusSegmentInfo>,
303}
304
305#[derive(Debug, Clone)]
308pub struct StatusSegmentInfo {
309 pub name: &'static str,
312 pub key: Option<String>,
314 pub text: String,
315 pub x: u16,
316 pub w: u16,
317 pub side: &'static str,
322}
323
324fn element_kind_name(kind: ElementKind) -> &'static str {
326 match kind {
327 ElementKind::Lsp => "lsp",
328 ElementKind::WarningBadge => "warning",
329 ElementKind::Language => "language",
330 ElementKind::Encoding => "encoding",
331 ElementKind::LineEnding => "lineEnding",
332 ElementKind::RemoteIndicator(_) => "remote",
333 ElementKind::WorkspaceTrust(_) => "trust",
334 ElementKind::Messages => "message",
335 ElementKind::Custom => "plugin",
336 _ => "text",
337 }
338}
339
340#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
342pub enum StatusBarHover {
343 #[default]
344 None,
345 LspIndicator,
347 WarningBadge,
349 LineEndingIndicator,
351 EncodingIndicator,
353 LanguageIndicator,
355 MessageArea,
357 RemoteIndicator,
359 WorkspaceTrust,
361}
362
363#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
365pub enum SearchOptionsHover {
366 #[default]
367 None,
368 CaseSensitive,
369 WholeWord,
370 Regex,
371 ConfirmEach,
372}
373
374#[derive(Debug, Clone, Default)]
376pub struct SearchOptionsLayout {
377 pub row: u16,
379 pub case_sensitive: Option<(u16, u16)>,
381 pub whole_word: Option<(u16, u16)>,
383 pub regex: Option<(u16, u16)>,
385 pub confirm_each: Option<(u16, u16)>,
387}
388
389impl SearchOptionsLayout {
390 pub fn checkbox_at(&self, x: u16, y: u16) -> Option<SearchOptionsHover> {
392 if y != self.row {
393 return None;
394 }
395
396 if let Some((start, end)) = self.case_sensitive {
397 if x >= start && x < end {
398 return Some(SearchOptionsHover::CaseSensitive);
399 }
400 }
401 if let Some((start, end)) = self.whole_word {
402 if x >= start && x < end {
403 return Some(SearchOptionsHover::WholeWord);
404 }
405 }
406 if let Some((start, end)) = self.regex {
407 if x >= start && x < end {
408 return Some(SearchOptionsHover::Regex);
409 }
410 }
411 if let Some((start, end)) = self.confirm_each {
412 if x >= start && x < end {
413 return Some(SearchOptionsHover::ConfirmEach);
414 }
415 }
416 None
417 }
418}
419
420#[derive(Debug, Clone)]
422pub struct TruncatedPath {
423 pub prefix: String,
425 pub truncated: bool,
427 pub suffix: String,
429 pub sep: char,
432}
433
434impl TruncatedPath {
435 pub fn to_string_plain(&self) -> String {
437 if self.truncated {
438 format!("{}{}[...]{}", self.prefix, self.sep, self.suffix)
439 } else {
440 format!("{}{}", self.prefix, self.suffix)
441 }
442 }
443
444 pub fn display_len(&self) -> usize {
447 if self.truncated {
448 self.prefix.len() + self.sep.len_utf8() + "[...]".len() + self.suffix.len()
449 } else {
450 self.prefix.len() + self.suffix.len()
451 }
452 }
453}
454
455fn path_display_sep(path_str: &str) -> char {
459 if path_str.contains('\\') {
460 '\\'
461 } else {
462 '/'
463 }
464}
465
466pub fn truncate_path(path: &Path, max_len: usize) -> TruncatedPath {
478 let path_str = path.to_string_lossy();
479 let sep = path_display_sep(&path_str);
484
485 if path_str.len() <= max_len {
487 return TruncatedPath {
488 prefix: String::new(),
489 truncated: false,
490 suffix: path_str.to_string(),
491 sep,
492 };
493 }
494
495 let components: Vec<&str> = path_str
496 .split(['/', '\\'])
497 .filter(|s| !s.is_empty())
498 .collect();
499
500 if components.is_empty() {
501 return TruncatedPath {
502 prefix: sep.to_string(),
503 truncated: false,
504 suffix: String::new(),
505 sep,
506 };
507 }
508
509 let leading_sep = path_str.starts_with('/') || path_str.starts_with('\\');
514 let is_drive = |c: &str| {
515 let b = c.as_bytes();
516 b.len() == 2 && b[1] == b':' && b[0].is_ascii_alphabetic()
517 };
518 let prefix_count = if !leading_sep && is_drive(components[0]) {
519 2
520 } else {
521 1
522 }
523 .min(components.len());
524 let sep_str = sep.to_string();
525 let prefix = {
526 let joined = components[..prefix_count].join(&sep_str);
527 if leading_sep {
528 format!("{}{}", sep, joined)
529 } else {
530 joined
531 }
532 };
533
534 let ellipsis_len = sep.len_utf8() + "[...]".len();
536
537 let available_for_suffix = max_len.saturating_sub(prefix.len() + ellipsis_len);
539
540 if available_for_suffix < 5 || components.len() <= prefix_count {
541 let truncated_path = if path_str.len() > max_len.saturating_sub(3) {
547 let cut = path_str.floor_char_boundary(max_len.saturating_sub(3));
548 format!("{}...", &path_str[..cut])
549 } else {
550 path_str.to_string()
551 };
552 return TruncatedPath {
553 prefix: String::new(),
554 truncated: false,
555 suffix: truncated_path,
556 sep,
557 };
558 }
559
560 let mut suffix_parts: Vec<&str> = Vec::new();
562 let mut suffix_len = 0;
563
564 for component in components.iter().skip(prefix_count).rev() {
565 let component_len = component.len() + 1; if suffix_len + component_len <= available_for_suffix {
567 suffix_parts.push(component);
568 suffix_len += component_len;
569 } else {
570 break;
571 }
572 }
573
574 suffix_parts.reverse();
575
576 if suffix_parts.len() == components.len() - prefix_count {
578 return TruncatedPath {
579 prefix: String::new(),
580 truncated: false,
581 suffix: path_str.to_string(),
582 sep,
583 };
584 }
585
586 let suffix = if suffix_parts.is_empty() {
587 let last = components.last().unwrap_or(&"");
591 let truncate_to = available_for_suffix.saturating_sub(4); if truncate_to > 0 && last.len() > truncate_to {
593 let cut = last.floor_char_boundary(truncate_to);
594 format!("{}{}...", sep, &last[..cut])
595 } else {
596 format!("{}{}", sep, last)
597 }
598 } else {
599 format!("{}{}", sep, suffix_parts.join(&sep_str))
600 };
601
602 TruncatedPath {
603 prefix,
604 truncated: true,
605 suffix,
606 sep,
607 }
608}
609
610fn truncate_to_width(s: &str, max_width: usize) -> String {
612 let width = str_width(s);
613 if width <= max_width {
614 return s.to_string();
615 }
616 let truncate_at = max_width.saturating_sub(3);
617 if truncate_at == 0 {
618 return if max_width >= 3 {
619 "...".to_string()
620 } else {
621 s.chars().take(max_width).collect()
622 };
623 }
624 let mut w = 0;
625 let truncated: String = s
626 .chars()
627 .take_while(|ch| {
628 let cw = char_width(*ch);
629 if w + cw <= truncate_at {
630 w += cw;
631 true
632 } else {
633 false
634 }
635 })
636 .collect();
637 format!("{}...", truncated)
638}
639
640const CURSOR_COL_RESERVE: usize = 3;
645
646fn cursor_column(buffer: &mut crate::model::buffer::TextBuffer, cursor_position: usize) -> usize {
653 let mut iter = buffer.line_iterator(cursor_position, 80);
654 let line_start = iter.current_position();
655 let byte_col = cursor_position.saturating_sub(line_start);
656 if byte_col == 0 {
657 return 0;
658 }
659 match iter.next_line() {
665 Some((_, text)) if text.len() >= byte_col => {
666 let mut end = byte_col;
667 while end > 0 && !text.is_char_boundary(end) {
668 end -= 1;
669 }
670 crate::primitives::grapheme::grapheme_count(&text[..end])
671 }
672 _ => byte_col,
673 }
674}
675
676fn format_cursor_position(line: usize, col: usize, line_count: usize) -> String {
684 let text = format!("Ln {line}, Col {col}");
685 let line_digits = line_count.max(1).to_string().len();
686 let min_width = 9 + line_digits + CURSOR_COL_RESERVE;
688 if text.len() < min_width {
689 format!("{text:<min_width$}")
690 } else {
691 text
692 }
693}
694
695fn format_cursor_position_compact(line: usize, col: usize, line_count: usize) -> String {
699 let text = format!("{line}:{col}");
700 let line_digits = line_count.max(1).to_string().len();
701 let min_width = 1 + line_digits + CURSOR_COL_RESERVE;
703 if text.len() < min_width {
704 format!("{text:<min_width$}")
705 } else {
706 text
707 }
708}
709
710pub struct StatusBarRenderer;
712
713impl StatusBarRenderer {
714 pub fn render_status_bar(
718 frame: &mut Frame,
719 area: Rect,
720 ctx: &mut StatusBarContext<'_>,
721 config: &StatusBarConfig,
722 rec: Option<&mut CellThemeRecorder>,
723 draw: bool,
727 ) -> StatusBarLayout {
728 Self::render_status(frame, area, ctx, config, rec, draw)
729 }
730
731 pub fn render_prompt(
733 frame: &mut Frame,
734 area: Rect,
735 prompt: &Prompt,
736 theme: &crate::view::theme::Theme,
737 ) {
738 let base_style = Style::default().fg(theme.prompt_fg).bg(theme.prompt_bg);
739
740 let mut spans = vec![Span::styled(prompt.message.clone(), base_style)];
742
743 if let Some((sel_start, sel_end)) = prompt.selection_range() {
745 let input = &prompt.input;
746
747 if sel_start > 0 {
749 spans.push(Span::styled(input[..sel_start].to_string(), base_style));
750 }
751
752 if sel_start < sel_end {
754 let selection_style = Style::default()
756 .fg(theme.prompt_selection_fg)
757 .bg(theme.prompt_selection_bg);
758 spans.push(Span::styled(
759 input[sel_start..sel_end].to_string(),
760 selection_style,
761 ));
762 }
763
764 if sel_end < input.len() {
766 spans.push(Span::styled(input[sel_end..].to_string(), base_style));
767 }
768 } else {
769 spans.push(Span::styled(prompt.input.clone(), base_style));
771 }
772
773 let line = Line::from(spans);
774 let prompt_line = Paragraph::new(line).style(base_style);
775
776 frame.render_widget(prompt_line, area);
777
778 let message_width = str_width(&prompt.message);
783 let input_width_before_cursor = str_width(&prompt.input[..prompt.cursor_pos]);
784 let cursor_x = (message_width + input_width_before_cursor) as u16;
785 if cursor_x < area.width {
786 frame.set_cursor_position((area.x + cursor_x, area.y));
787 }
788 }
789
790 pub fn render_file_open_prompt(
794 frame: &mut Frame,
795 area: Rect,
796 prompt: &Prompt,
797 file_open_state: &crate::app::file_open::FileOpenState,
798 theme: &crate::view::theme::Theme,
799 ) {
800 let base_style = Style::default().fg(theme.prompt_fg).bg(theme.prompt_bg);
801 let dir_style = Style::default()
802 .fg(theme.help_separator_fg)
803 .bg(theme.prompt_bg);
804 let ellipsis_style = Style::default()
806 .fg(theme.menu_highlight_fg)
807 .bg(theme.prompt_bg);
808
809 let mut spans = Vec::new();
810
811 let open_prompt = t!("file.open_prompt").to_string();
813 spans.push(Span::styled(open_prompt.clone(), base_style));
814
815 let prefix_len = str_width(&open_prompt);
818 let dir_path = file_open_state.current_dir.to_string_lossy();
819 let dir_path_len = dir_path.len() + 1; let input_len = prompt.input.len();
821 let total_len = prefix_len + dir_path_len + input_len;
822 let threshold = (area.width as usize * 90) / 100;
823
824 let truncated = if total_len > threshold {
826 let available_for_path = threshold
828 .saturating_sub(prefix_len)
829 .saturating_sub(input_len);
830 truncate_path(&file_open_state.current_dir, available_for_path)
831 } else {
832 TruncatedPath {
834 prefix: String::new(),
835 truncated: false,
836 suffix: dir_path.to_string(),
837 sep: path_display_sep(&dir_path),
838 }
839 };
840
841 if truncated.truncated {
843 spans.push(Span::styled(truncated.prefix.clone(), dir_style));
845 spans.push(Span::styled(
847 format!("{}[...]", truncated.sep),
848 ellipsis_style,
849 ));
850 let suffix_with_slash = if truncated.suffix.ends_with('/') {
852 truncated.suffix.clone()
853 } else {
854 format!("{}/", truncated.suffix)
855 };
856 spans.push(Span::styled(suffix_with_slash, dir_style));
857 } else {
858 let path_display = if truncated.suffix.ends_with('/') {
860 truncated.suffix.clone()
861 } else {
862 format!("{}/", truncated.suffix)
863 };
864 spans.push(Span::styled(path_display, dir_style));
865 }
866
867 spans.push(Span::styled(prompt.input.clone(), base_style));
869
870 let line = Line::from(spans);
871 let prompt_line = Paragraph::new(line).style(base_style);
872
873 frame.render_widget(prompt_line, area);
874
875 let prefix_width = str_width(&open_prompt);
879 let dir_display_width = if truncated.truncated {
880 let suffix_with_slash = if truncated.suffix.ends_with('/') {
881 &truncated.suffix
882 } else {
883 &truncated.suffix
885 };
886 str_width(&truncated.prefix) + str_width("/[...]") + str_width(suffix_with_slash) + 1
887 } else {
888 str_width(&truncated.suffix) + 1 };
890 let input_width_before_cursor = str_width(&prompt.input[..prompt.cursor_pos]);
891 let cursor_x = (prefix_width + dir_display_width + input_width_before_cursor) as u16;
892 if cursor_x < area.width {
893 frame.set_cursor_position((area.x + cursor_x, area.y));
894 }
895 }
896
897 fn render_element(
900 element: &StatusBarElement,
901 ctx: &mut StatusBarContext<'_>,
902 ) -> Option<RenderedElement> {
903 if ctx.is_synthetic_placeholder
908 && matches!(
909 element,
910 StatusBarElement::Filename
911 | StatusBarElement::Cursor
912 | StatusBarElement::CursorCompact
913 | StatusBarElement::CursorCount
914 | StatusBarElement::Diagnostics
915 | StatusBarElement::LineEnding
916 | StatusBarElement::Encoding
917 | StatusBarElement::Language
918 )
919 {
920 return None;
921 }
922 match element {
923 StatusBarElement::Filename => {
924 let modified = if ctx.state.buffer.is_modified() {
925 " [+]"
926 } else {
927 ""
928 };
929 let read_only_indicator = if ctx.read_only { " [RO]" } else { "" };
930 let remote_disconnected = ctx
931 .remote_connection
932 .map(|conn| conn.contains("(Disconnected)"))
933 .unwrap_or(false);
934 let remote_prefix = if ctx.remote_indicator_on_bar {
941 String::new()
942 } else {
943 ctx.remote_connection
944 .map(|conn| {
945 if conn.starts_with("Container:") {
946 format!("[{}] ", conn)
947 } else {
948 format!("{SSH_PREFIX}{conn}{SSH_PREFIX_TERMINATOR}")
949 }
950 })
951 .unwrap_or_default()
952 };
953 let session_prefix = ctx
954 .session_name
955 .map(|name| format!("[{}] ", name))
956 .unwrap_or_default();
957 let display_name = ctx.display_name;
958 let text = format!(
959 "{session_prefix}{remote_prefix}{display_name}{modified}{read_only_indicator}"
960 );
961 let kind = if remote_disconnected {
962 ElementKind::RemoteDisconnected
963 } else {
964 ElementKind::Normal
965 };
966 Some(RenderedElement {
967 text,
968 kind,
969 token_key: None,
970 })
971 }
972 StatusBarElement::Cursor => {
973 if !ctx.state.show_cursors {
974 return None;
975 }
976 let cursor = *ctx.cursors.primary();
977 let line_count = ctx.state.buffer.line_count();
978 let text = if let Some(lc) = line_count {
979 let line = ctx.state.primary_cursor_line_number.value();
980 let col = cursor_column(&mut ctx.state.buffer, cursor.position);
981 format_cursor_position(line + 1, col + 1, lc)
982 } else {
983 format!("Byte {}", cursor.position)
984 };
985 Some(RenderedElement {
986 text,
987 kind: ElementKind::Normal,
988 token_key: None,
989 })
990 }
991 StatusBarElement::CursorCompact => {
992 if !ctx.state.show_cursors {
993 return None;
994 }
995 let cursor = *ctx.cursors.primary();
996 let line_count = ctx.state.buffer.line_count();
997 let text = if let Some(lc) = line_count {
998 let line = ctx.state.primary_cursor_line_number.value();
999 let col = cursor_column(&mut ctx.state.buffer, cursor.position);
1000 format_cursor_position_compact(line + 1, col + 1, lc)
1001 } else {
1002 format!("{}", cursor.position)
1003 };
1004 Some(RenderedElement {
1005 text,
1006 kind: ElementKind::Normal,
1007 token_key: None,
1008 })
1009 }
1010 StatusBarElement::Diagnostics => {
1011 let diagnostics = ctx.state.overlays.all();
1012 let mut error_count = 0usize;
1013 let mut warning_count = 0usize;
1014 let mut info_count = 0usize;
1015 let diagnostic_ns = crate::services::lsp::diagnostics::lsp_diagnostic_namespace();
1016 for overlay in diagnostics {
1017 if overlay.namespace.as_ref() == Some(&diagnostic_ns) {
1018 match overlay.priority {
1019 100 => error_count += 1,
1020 50 => warning_count += 1,
1021 _ => info_count += 1,
1022 }
1023 }
1024 }
1025 if error_count + warning_count + info_count == 0 {
1026 return None;
1027 }
1028 let mut parts = Vec::new();
1029 if error_count > 0 {
1030 parts.push(format!("E:{}", error_count));
1031 }
1032 if warning_count > 0 {
1033 parts.push(format!("W:{}", warning_count));
1034 }
1035 if info_count > 0 {
1036 parts.push(format!("I:{}", info_count));
1037 }
1038 Some(RenderedElement {
1039 text: parts.join(" "),
1040 kind: ElementKind::Normal,
1041 token_key: None,
1042 })
1043 }
1044 StatusBarElement::CursorCount => {
1045 if ctx.cursors.count() <= 1 {
1046 return None;
1047 }
1048 Some(RenderedElement {
1049 text: t!("status.cursors", count = ctx.cursors.count()).to_string(),
1050 kind: ElementKind::Normal,
1051 token_key: None,
1052 })
1053 }
1054 StatusBarElement::Messages => {
1055 let mut parts: Vec<&str> = Vec::new();
1056 if let Some(msg) = ctx.status_message {
1057 if !msg.is_empty() {
1058 parts.push(msg);
1059 }
1060 }
1061 if let Some(msg) = ctx.plugin_status_message {
1062 if !msg.is_empty() {
1063 parts.push(msg);
1064 }
1065 }
1066 if parts.is_empty() {
1067 return None;
1068 }
1069 Some(RenderedElement {
1070 text: parts.join(" | "),
1071 kind: ElementKind::Messages,
1072 token_key: None,
1073 })
1074 }
1075 StatusBarElement::Chord => {
1076 if ctx.chord_state.is_empty() {
1077 return None;
1078 }
1079 let chord_str = ctx
1080 .chord_state
1081 .iter()
1082 .map(|(code, modifiers)| {
1083 crate::input::keybindings::format_keybinding(code, modifiers)
1084 })
1085 .collect::<Vec<_>>()
1086 .join(" ");
1087 Some(RenderedElement {
1088 text: format!("[{}]", chord_str),
1089 kind: ElementKind::Normal,
1090 token_key: None,
1091 })
1092 }
1093 StatusBarElement::LineEnding => Some(RenderedElement {
1094 text: ctx.state.buffer.line_ending().display_name().to_string(),
1095 kind: ElementKind::LineEnding,
1096 token_key: None,
1097 }),
1098 StatusBarElement::Encoding => Some(RenderedElement {
1099 text: ctx.state.buffer.encoding().display_name().to_string(),
1100 kind: ElementKind::Encoding,
1101 token_key: None,
1102 }),
1103 StatusBarElement::Language => {
1104 let text = if ctx.state.language == "text"
1105 && ctx.state.display_name != "Text"
1106 && ctx.state.display_name != "Plain Text"
1107 && ctx.state.display_name != "text"
1108 {
1109 format!("{} [syntax only]", &ctx.state.display_name)
1110 } else {
1111 ctx.state.display_name.to_string()
1112 };
1113 Some(RenderedElement {
1114 text,
1115 kind: ElementKind::Language,
1116 token_key: None,
1117 })
1118 }
1119 StatusBarElement::Lsp => {
1120 if ctx.lsp_status.is_empty() {
1121 return None;
1122 }
1123 Some(RenderedElement {
1124 text: ctx.lsp_status.to_string(),
1125 kind: ElementKind::Lsp,
1126 token_key: None,
1127 })
1128 }
1129 StatusBarElement::Warnings => {
1130 if ctx.general_warning_count == 0 {
1131 return None;
1132 }
1133 Some(RenderedElement {
1134 text: format!("[\u{26a0} {}]", ctx.general_warning_count),
1135 kind: ElementKind::WarningBadge,
1136 token_key: None,
1137 })
1138 }
1139 StatusBarElement::Update => {
1140 let version = ctx.update_available?;
1141 Some(RenderedElement {
1142 text: t!("status.update_available", version = version).to_string(),
1143 kind: ElementKind::Update,
1144 token_key: None,
1145 })
1146 }
1147 StatusBarElement::Palette => {
1148 let shortcut = ctx
1149 .keybindings
1150 .get_keybinding_for_action(
1151 &crate::input::keybindings::Action::QuickOpen,
1152 crate::input::keybindings::KeyContext::Global,
1153 )
1154 .unwrap_or_else(|| "?".to_string());
1155 Some(RenderedElement {
1156 text: t!("status.palette", shortcut = shortcut).to_string(),
1157 kind: ElementKind::Palette,
1158 token_key: None,
1159 })
1160 }
1161 StatusBarElement::Clock => {
1162 let now = chrono::Local::now();
1163 let text = format!("{:02}:{:02}", now.hour(), now.minute());
1164 Some(RenderedElement {
1165 text,
1166 kind: ElementKind::Clock,
1167 token_key: None,
1168 })
1169 }
1170 StatusBarElement::RemoteIndicator => {
1171 let (text, state) = if let Some(over) = ctx.remote_state_override {
1181 (over.label(), over.state())
1182 } else {
1183 match ctx.remote_connection {
1184 None => ("Local".to_string(), RemoteIndicatorState::Local),
1185 Some(conn) if conn.contains("(Disconnected)") => {
1186 (conn.to_string(), RemoteIndicatorState::Disconnected)
1187 }
1188 Some(conn) => (conn.to_string(), RemoteIndicatorState::Connected),
1189 }
1190 };
1191 Some(RenderedElement {
1192 text,
1193 kind: ElementKind::RemoteIndicator(state),
1194 token_key: None,
1195 })
1196 }
1197 StatusBarElement::WorkspaceTrust => {
1198 use crate::services::workspace_trust::TrustLevel;
1203 let level = ctx.workspace_trust_level;
1204 let text = match level {
1205 TrustLevel::Trusted => t!("statusbar.trust.trusted"),
1206 TrustLevel::Restricted => t!("statusbar.trust.restricted"),
1207 TrustLevel::Blocked => t!("statusbar.trust.blocked"),
1208 }
1209 .to_string();
1210 Some(RenderedElement {
1211 text,
1212 kind: ElementKind::WorkspaceTrust(level),
1213 token_key: None,
1214 })
1215 }
1216 StatusBarElement::CustomToken(key) => {
1217 if let Some(value) = ctx.dynamic_status_bar_elements.get(key) {
1218 Some(RenderedElement {
1219 text: value.clone(),
1220 kind: ElementKind::Custom,
1221 token_key: Some(key.clone()),
1222 })
1223 } else {
1224 None }
1226 }
1227 }
1228 }
1229
1230 fn element_style(
1232 kind: ElementKind,
1233 theme: &crate::view::theme::Theme,
1234 hover: StatusBarHover,
1235 _warning_level: WarningLevel,
1236 lsp_state: LspIndicatorState,
1237 ) -> Style {
1238 match kind {
1239 ElementKind::Normal | ElementKind::Messages | ElementKind::Clock => Style::default()
1240 .fg(theme.status_bar_fg)
1241 .bg(theme.status_bar_bg),
1242 ElementKind::RemoteDisconnected => Style::default()
1243 .fg(theme.status_error_indicator_fg)
1244 .bg(theme.status_error_indicator_bg),
1245 ElementKind::LineEnding => {
1246 let is_hovering = hover == StatusBarHover::LineEndingIndicator;
1247 let (fg, bg) = if is_hovering {
1248 (theme.menu_hover_fg, theme.menu_hover_bg)
1249 } else {
1250 (theme.status_bar_fg, theme.status_bar_bg)
1251 };
1252 let mut style = Style::default().fg(fg).bg(bg);
1253 if is_hovering {
1254 style = style.add_modifier(Modifier::UNDERLINED);
1255 }
1256 style
1257 }
1258 ElementKind::Encoding => {
1259 let is_hovering = hover == StatusBarHover::EncodingIndicator;
1260 let (fg, bg) = if is_hovering {
1261 (theme.menu_hover_fg, theme.menu_hover_bg)
1262 } else {
1263 (theme.status_bar_fg, theme.status_bar_bg)
1264 };
1265 let mut style = Style::default().fg(fg).bg(bg);
1266 if is_hovering {
1267 style = style.add_modifier(Modifier::UNDERLINED);
1268 }
1269 style
1270 }
1271 ElementKind::Language => {
1272 let is_hovering = hover == StatusBarHover::LanguageIndicator;
1273 let (fg, bg) = if is_hovering {
1274 (theme.menu_hover_fg, theme.menu_hover_bg)
1275 } else {
1276 (theme.status_bar_fg, theme.status_bar_bg)
1277 };
1278 let mut style = Style::default().fg(fg).bg(bg);
1279 if is_hovering {
1280 style = style.add_modifier(Modifier::UNDERLINED);
1281 }
1282 style
1283 }
1284 ElementKind::Lsp => {
1285 let is_hovering = hover == StatusBarHover::LspIndicator;
1286 let (fg, bg) = match lsp_state {
1297 LspIndicatorState::Error => {
1298 (theme.diagnostic_error_fg, theme.diagnostic_error_bg)
1299 }
1300 LspIndicatorState::Off => (
1301 theme.status_lsp_actionable_fg,
1302 theme.status_lsp_actionable_bg,
1303 ),
1304 LspIndicatorState::On => (theme.status_lsp_on_fg, theme.status_lsp_on_bg),
1305 LspIndicatorState::OffDismissed => (theme.status_bar_fg, theme.status_bar_bg),
1306 LspIndicatorState::None => (theme.status_bar_fg, theme.status_bar_bg),
1307 };
1308 let mut style = Style::default().fg(fg).bg(bg);
1309 if is_hovering && lsp_state != LspIndicatorState::None {
1314 style = style.add_modifier(Modifier::UNDERLINED);
1315 }
1316 style
1317 }
1318 ElementKind::WarningBadge => {
1319 let is_hovering = hover == StatusBarHover::WarningBadge;
1320 let (fg, bg) = if is_hovering {
1321 (
1322 theme.status_warning_indicator_hover_fg,
1323 theme.status_warning_indicator_hover_bg,
1324 )
1325 } else {
1326 (
1327 theme.status_warning_indicator_fg,
1328 theme.status_warning_indicator_bg,
1329 )
1330 };
1331 let mut style = Style::default().fg(fg).bg(bg);
1332 if is_hovering {
1333 style = style.add_modifier(Modifier::UNDERLINED);
1334 }
1335 style
1336 }
1337 ElementKind::Update => Style::default()
1338 .fg(theme.menu_highlight_fg)
1339 .bg(theme.menu_dropdown_bg),
1340 ElementKind::Palette => Style::default()
1345 .fg(theme.status_palette_fg)
1346 .bg(theme.status_palette_bg),
1347 ElementKind::Custom => Style::default()
1348 .fg(theme.status_bar_fg)
1349 .bg(theme.status_bar_bg),
1350 ElementKind::RemoteIndicator(state) => {
1351 let is_hovering = hover == StatusBarHover::RemoteIndicator;
1352 let (fg, bg) = match state {
1353 RemoteIndicatorState::Connecting | RemoteIndicatorState::Connected => {
1359 (theme.help_indicator_fg, theme.help_indicator_bg)
1360 }
1361 RemoteIndicatorState::FailedAttach | RemoteIndicatorState::Disconnected => (
1365 theme.status_error_indicator_fg,
1366 theme.status_error_indicator_bg,
1367 ),
1368 RemoteIndicatorState::Local => (theme.status_bar_fg, theme.status_bar_bg),
1370 };
1371 let mut style = Style::default().fg(fg).bg(bg);
1372 if is_hovering {
1373 style = style.add_modifier(Modifier::UNDERLINED);
1374 }
1375 style
1376 }
1377 ElementKind::WorkspaceTrust(level) => {
1378 use crate::services::workspace_trust::TrustLevel;
1379 let is_hovering = hover == StatusBarHover::WorkspaceTrust;
1380 let (fg, bg) = match level {
1381 TrustLevel::Restricted | TrustLevel::Blocked => (
1384 theme.status_warning_indicator_fg,
1385 theme.status_warning_indicator_bg,
1386 ),
1387 TrustLevel::Trusted => (theme.status_bar_fg, theme.status_bar_bg),
1389 };
1390 let mut style = Style::default().fg(fg).bg(bg);
1391 if is_hovering {
1392 style = style.add_modifier(Modifier::UNDERLINED);
1393 }
1394 style
1395 }
1396 }
1397 }
1398
1399 fn element_keys(
1403 kind: ElementKind,
1404 lsp_state: LspIndicatorState,
1405 ) -> (&'static str, &'static str) {
1406 match kind {
1407 ElementKind::Normal
1408 | ElementKind::Messages
1409 | ElementKind::Clock
1410 | ElementKind::Custom
1411 | ElementKind::LineEnding
1412 | ElementKind::Encoding
1413 | ElementKind::Language => ("ui.status_bar_fg", "ui.status_bar_bg"),
1414 ElementKind::RemoteDisconnected => (
1415 "ui.status_error_indicator_fg",
1416 "ui.status_error_indicator_bg",
1417 ),
1418 ElementKind::Lsp => match lsp_state {
1419 LspIndicatorState::Error => ("diagnostic.error_fg", "diagnostic.error_bg"),
1420 LspIndicatorState::Off => {
1421 ("ui.status_lsp_actionable_fg", "ui.status_lsp_actionable_bg")
1422 }
1423 LspIndicatorState::On => ("ui.status_lsp_on_fg", "ui.status_lsp_on_bg"),
1424 LspIndicatorState::OffDismissed | LspIndicatorState::None => {
1425 ("ui.status_bar_fg", "ui.status_bar_bg")
1426 }
1427 },
1428 ElementKind::WarningBadge => (
1429 "ui.status_warning_indicator_fg",
1430 "ui.status_warning_indicator_bg",
1431 ),
1432 ElementKind::Update => ("ui.menu_highlight_fg", "ui.menu_dropdown_bg"),
1433 ElementKind::Palette => ("ui.status_palette_fg", "ui.status_palette_bg"),
1434 ElementKind::RemoteIndicator(state) => match state {
1435 RemoteIndicatorState::Connecting | RemoteIndicatorState::Connected => {
1436 ("ui.help_indicator_fg", "ui.help_indicator_bg")
1437 }
1438 RemoteIndicatorState::FailedAttach | RemoteIndicatorState::Disconnected => (
1439 "ui.status_error_indicator_fg",
1440 "ui.status_error_indicator_bg",
1441 ),
1442 RemoteIndicatorState::Local => ("ui.status_bar_fg", "ui.status_bar_bg"),
1443 },
1444 ElementKind::WorkspaceTrust(level) => {
1445 use crate::services::workspace_trust::TrustLevel;
1446 match level {
1447 TrustLevel::Restricted | TrustLevel::Blocked => (
1448 "ui.status_warning_indicator_fg",
1449 "ui.status_warning_indicator_bg",
1450 ),
1451 TrustLevel::Trusted => ("ui.status_bar_fg", "ui.status_bar_bg"),
1452 }
1453 }
1454 }
1455 }
1456
1457 fn update_layout_for_element(
1464 layout: &mut StatusBarLayout,
1465 kind: ElementKind,
1466 token_key: Option<&str>,
1467 row: u16,
1468 start_col: u16,
1469 end_col: u16,
1470 ) {
1471 match kind {
1472 ElementKind::LineEnding => {
1473 layout.line_ending_indicator = Some((row, start_col, end_col))
1474 }
1475 ElementKind::Encoding => layout.encoding_indicator = Some((row, start_col, end_col)),
1476 ElementKind::Language => layout.language_indicator = Some((row, start_col, end_col)),
1477 ElementKind::Lsp => layout.lsp_indicator = Some((row, start_col, end_col)),
1478 ElementKind::WarningBadge => layout.warning_badge = Some((row, start_col, end_col)),
1479 ElementKind::Messages => layout.message_area = Some((row, start_col, end_col)),
1480 ElementKind::RemoteIndicator(_) => {
1481 layout.remote_indicator = Some((row, start_col, end_col))
1482 }
1483 ElementKind::WorkspaceTrust(_) => {
1484 layout.trust_indicator = Some((row, start_col, end_col))
1485 }
1486 ElementKind::Custom => {
1487 if let Some(key) = token_key {
1488 layout
1489 .plugin_token_areas
1490 .insert(key.to_string(), (row, start_col, end_col));
1491 }
1492 }
1493 _ => {}
1494 }
1495 }
1496
1497 fn element_spans(
1502 rendered: &RenderedElement,
1503 theme: &crate::view::theme::Theme,
1504 hover: StatusBarHover,
1505 warning_level: WarningLevel,
1506 lsp_state: LspIndicatorState,
1507 ) -> (Vec<Span<'static>>, usize) {
1508 let base_style = Style::default()
1509 .fg(theme.status_bar_fg)
1510 .bg(theme.status_bar_bg);
1511 let width = str_width(&rendered.text) + 2;
1516
1517 if rendered.kind == ElementKind::RemoteDisconnected && rendered.text.starts_with(SSH_PREFIX)
1518 {
1519 let error_style = Style::default()
1520 .fg(theme.status_error_indicator_fg)
1521 .bg(theme.status_error_indicator_bg);
1522 if let Some(term_off) = rendered.text.find(SSH_PREFIX_TERMINATOR) {
1523 let split_at = term_off + SSH_PREFIX_TERMINATOR.len();
1524 let prefix = rendered.text[..split_at].to_string();
1525 let rest = rendered.text[split_at..].to_string();
1526 return (
1527 vec![
1528 Span::styled(" ", error_style),
1529 Span::styled(prefix, error_style),
1530 Span::styled(rest, base_style),
1531 Span::styled(" ", base_style),
1532 ],
1533 width,
1534 );
1535 }
1536 return (
1537 vec![
1538 Span::styled(" ", error_style),
1539 Span::styled(rendered.text.clone(), error_style),
1540 Span::styled(" ", error_style),
1541 ],
1542 width,
1543 );
1544 }
1545
1546 let style = Self::element_style(rendered.kind, theme, hover, warning_level, lsp_state);
1547 let mut spans = vec![Span::styled(" ", style)];
1548 if rendered.kind == ElementKind::Clock {
1549 spans.push(Span::styled(rendered.text[..2].to_string(), style));
1551 spans.push(Span::styled(
1552 ":".to_string(),
1553 style.add_modifier(Modifier::SLOW_BLINK),
1554 ));
1555 spans.push(Span::styled(rendered.text[3..].to_string(), style));
1556 } else {
1557 spans.push(Span::styled(rendered.text.clone(), style));
1558 }
1559 spans.push(Span::styled(" ", style));
1560 (spans, width)
1561 }
1562
1563 fn render_side(
1570 config_side: &[StatusBarElement],
1571 ctx: &mut StatusBarContext<'_>,
1572 ) -> Vec<(Vec<Span<'static>>, usize, ElementKind, Option<String>)> {
1573 let rendered: Vec<RenderedElement> = config_side
1574 .iter()
1575 .filter_map(|elem| Self::render_element(elem, ctx))
1576 .filter(|e| !e.text.is_empty())
1577 .collect();
1578
1579 let theme = ctx.theme;
1580 let hover = ctx.hover;
1581 let warning_level = ctx.warning_level;
1582 let lsp_state = ctx.lsp_indicator_state;
1583 rendered
1584 .into_iter()
1585 .map(|r| {
1586 let kind = r.kind;
1587 let token_key = r.token_key.clone();
1588 let (spans, width) =
1589 Self::element_spans(&r, theme, hover, warning_level, lsp_state);
1590 (spans, width, kind, token_key)
1591 })
1592 .collect()
1593 }
1594
1595 fn render_status(
1597 frame: &mut Frame,
1598 area: Rect,
1599 ctx: &mut StatusBarContext<'_>,
1600 config: &StatusBarConfig,
1601 mut rec: Option<&mut CellThemeRecorder>,
1602 draw: bool,
1603 ) -> StatusBarLayout {
1604 let mut layout = StatusBarLayout::default();
1605 let base_style = Style::default()
1606 .fg(ctx.theme.status_bar_fg)
1607 .bg(ctx.theme.status_bar_bg);
1608 let available_width = area.width as usize;
1609
1610 if available_width == 0 || area.height == 0 {
1611 return layout;
1612 }
1613
1614 let lsp_state = ctx.lsp_indicator_state;
1618 if let Some(r) = rec.as_deref_mut() {
1619 r.run(
1620 area.x,
1621 area.y,
1622 area.width,
1623 Some("ui.status_bar_fg"),
1624 Some("ui.status_bar_bg"),
1625 "Status Bar",
1626 );
1627 }
1628
1629 ctx.remote_indicator_on_bar = config
1634 .left
1635 .iter()
1636 .chain(config.right.iter())
1637 .any(|e| matches!(e, StatusBarElement::RemoteIndicator));
1638
1639 let left_items = Self::render_side(&config.left, ctx);
1640 let mut right_items = Self::render_side(&config.right, ctx);
1641
1642 let separator: &str = &config.separator;
1645 let separator_width = str_width(separator);
1646 let separator_style = Style::default()
1649 .fg(ctx.theme.status_separator_fg)
1650 .bg(ctx.theme.status_separator_bg);
1651
1652 let total_right_width: usize = right_items.iter().map(|(_, w, _, _)| *w).sum::<usize>()
1661 + separator_width * right_items.len().saturating_sub(1);
1662 let left_min_target = available_width
1663 .saturating_mul(2)
1664 .saturating_div(5) .min(40); let right_budget = available_width.saturating_sub(left_min_target + 1);
1667 if total_right_width > right_budget && right_items.len() > 1 {
1668 let mut current = total_right_width;
1669 while current > right_budget && right_items.len() > 1 {
1670 if let Some(dropped) = right_items.pop() {
1671 current = current.saturating_sub(dropped.1);
1672 current = current.saturating_sub(separator_width);
1675 } else {
1676 break;
1677 }
1678 }
1679 }
1680
1681 let right_width: usize = right_items.iter().map(|(_, w, _, _)| *w).sum::<usize>()
1682 + separator_width * right_items.len().saturating_sub(1);
1683
1684 let narrow = available_width < 15;
1685 let left_max_width = if narrow {
1686 available_width
1687 } else if available_width > right_width + 1 {
1688 available_width - right_width - 1
1689 } else {
1690 1
1691 };
1692
1693 let mut spans: Vec<Span<'static>> = Vec::new();
1697 let mut used_left: usize = 0;
1698
1699 for (idx, (item_spans, width, kind, token_key)) in left_items.into_iter().enumerate() {
1700 let sep_width = if idx == 0 { 0 } else { separator_width };
1701 if used_left + sep_width >= left_max_width {
1702 break;
1703 }
1704 if sep_width > 0 {
1705 if let Some(r) = rec.as_deref_mut() {
1706 r.run(
1707 area.x + used_left as u16,
1708 area.y,
1709 sep_width as u16,
1710 Some("ui.status_separator_fg"),
1711 Some("ui.status_separator_bg"),
1712 "Status Bar",
1713 );
1714 }
1715 spans.push(Span::styled(separator.to_string(), separator_style));
1716 used_left += sep_width;
1717 }
1718
1719 let remaining = left_max_width - used_left;
1720 let start_col = used_left;
1721
1722 if width <= remaining {
1723 let seg_text = (!draw).then(|| {
1727 item_spans
1728 .iter()
1729 .map(|s| s.content.as_ref())
1730 .collect::<String>()
1731 });
1732 spans.extend(item_spans);
1733 used_left += width;
1734
1735 if let Some(r) = rec.as_deref_mut() {
1736 let (fg, bg) = Self::element_keys(kind, lsp_state);
1737 r.run(
1738 area.x + start_col as u16,
1739 area.y,
1740 width as u16,
1741 Some(fg),
1742 Some(bg),
1743 "Status Bar",
1744 );
1745 }
1746
1747 Self::update_layout_for_element(
1748 &mut layout,
1749 kind,
1750 token_key.as_deref(),
1751 area.y,
1752 area.x + start_col as u16,
1753 area.x + (start_col + width) as u16,
1754 );
1755 if let Some(text) = seg_text {
1756 layout.segments.push(StatusSegmentInfo {
1757 name: element_kind_name(kind),
1758 key: token_key.clone(),
1759 text,
1760 x: area.x + start_col as u16,
1761 w: width as u16,
1762 side: "left",
1763 });
1764 }
1765 } else {
1766 let group_text: String = item_spans.iter().map(|s| s.content.as_ref()).collect();
1770 let truncated = truncate_to_width(&group_text, remaining);
1771 let truncated_width = str_width(&truncated);
1772 let overflow_style = Self::element_style(
1773 kind,
1774 ctx.theme,
1775 ctx.hover,
1776 ctx.warning_level,
1777 ctx.lsp_indicator_state,
1778 );
1779 let seg_text = (!draw).then(|| truncated.clone());
1780 spans.push(Span::styled(truncated, overflow_style));
1781
1782 if let Some(r) = rec.as_deref_mut() {
1783 let (fg, bg) = Self::element_keys(kind, lsp_state);
1784 r.run(
1785 area.x + start_col as u16,
1786 area.y,
1787 truncated_width as u16,
1788 Some(fg),
1789 Some(bg),
1790 "Status Bar",
1791 );
1792 }
1793 used_left += truncated_width;
1794
1795 Self::update_layout_for_element(
1796 &mut layout,
1797 kind,
1798 token_key.as_deref(),
1799 area.y,
1800 area.x + start_col as u16,
1801 area.x + (start_col + truncated_width) as u16,
1802 );
1803 if let Some(text) = seg_text {
1804 layout.segments.push(StatusSegmentInfo {
1805 name: element_kind_name(kind),
1806 key: token_key.clone(),
1807 text,
1808 x: area.x + start_col as u16,
1809 w: truncated_width as u16,
1810 side: "left",
1811 });
1812 }
1813 break;
1814 }
1815 }
1816
1817 if narrow {
1818 if used_left < available_width {
1819 spans.push(Span::styled(
1820 " ".repeat(available_width - used_left),
1821 base_style,
1822 ));
1823 }
1824 if draw {
1825 frame.render_widget(Paragraph::new(Line::from(spans)), area);
1826 }
1827 return layout;
1828 }
1829
1830 let mut col_offset = used_left;
1831 if col_offset + right_width < available_width {
1832 let padding = available_width - col_offset - right_width;
1833 spans.push(Span::styled(" ".repeat(padding), base_style));
1834 col_offset = available_width - right_width;
1835 } else if col_offset < available_width {
1836 spans.push(Span::styled(" ", base_style));
1837 col_offset += 1;
1838 }
1839
1840 let mut current_col = area.x + col_offset as u16;
1841 for (idx, (item_spans, width, kind, token_key)) in right_items.into_iter().enumerate() {
1842 if idx > 0 && separator_width > 0 {
1843 if let Some(r) = rec.as_deref_mut() {
1844 r.run(
1845 current_col,
1846 area.y,
1847 separator_width as u16,
1848 Some("ui.status_separator_fg"),
1849 Some("ui.status_separator_bg"),
1850 "Status Bar",
1851 );
1852 }
1853 spans.push(Span::styled(separator.to_string(), separator_style));
1854 current_col += separator_width as u16;
1855 }
1856 if let Some(r) = rec.as_deref_mut() {
1857 let (fg, bg) = Self::element_keys(kind, lsp_state);
1858 r.run(
1859 current_col,
1860 area.y,
1861 width as u16,
1862 Some(fg),
1863 Some(bg),
1864 "Status Bar",
1865 );
1866 }
1867 Self::update_layout_for_element(
1868 &mut layout,
1869 kind,
1870 token_key.as_deref(),
1871 area.y,
1872 current_col,
1873 current_col + width as u16,
1874 );
1875 if !draw {
1876 let seg_text: String = item_spans.iter().map(|s| s.content.as_ref()).collect();
1878 layout.segments.push(StatusSegmentInfo {
1879 name: element_kind_name(kind),
1880 key: token_key.clone(),
1881 text: seg_text,
1882 x: current_col,
1883 w: width as u16,
1884 side: "right",
1885 });
1886 }
1887 spans.extend(item_spans);
1888 current_col += width as u16;
1889 }
1890
1891 if draw {
1892 frame.render_widget(Paragraph::new(Line::from(spans)), area);
1893 }
1894 layout
1895 }
1896
1897 #[allow(clippy::too_many_arguments)]
1908 pub fn render_search_options(
1909 frame: &mut Frame,
1910 area: Rect,
1911 case_sensitive: bool,
1912 whole_word: bool,
1913 use_regex: bool,
1914 confirm_each: Option<bool>, theme: &crate::view::theme::Theme,
1916 keybindings: &crate::input::keybindings::KeybindingResolver,
1917 hover: SearchOptionsHover,
1918 ) -> SearchOptionsLayout {
1919 use crate::primitives::display_width::str_width;
1920
1921 let mut layout = SearchOptionsLayout {
1922 row: area.y,
1923 ..Default::default()
1924 };
1925
1926 let base_style = Style::default()
1928 .fg(theme.menu_dropdown_fg)
1929 .bg(theme.menu_dropdown_bg);
1930
1931 let hover_style = Style::default()
1933 .fg(theme.menu_hover_fg)
1934 .bg(theme.menu_hover_bg);
1935
1936 let get_shortcut = |action: &crate::input::keybindings::Action| -> Option<String> {
1940 keybindings
1941 .get_keybinding_for_action(
1942 action,
1943 crate::input::keybindings::KeyContext::SearchPrompt,
1944 )
1945 .or_else(|| {
1946 keybindings.get_keybinding_for_action(
1947 action,
1948 crate::input::keybindings::KeyContext::Prompt,
1949 )
1950 })
1951 .or_else(|| {
1952 keybindings.get_keybinding_for_action(
1953 action,
1954 crate::input::keybindings::KeyContext::Global,
1955 )
1956 })
1957 };
1958
1959 let case_shortcut =
1961 get_shortcut(&crate::input::keybindings::Action::ToggleSearchCaseSensitive);
1962 let word_shortcut = get_shortcut(&crate::input::keybindings::Action::ToggleSearchWholeWord);
1963 let regex_shortcut = get_shortcut(&crate::input::keybindings::Action::ToggleSearchRegex);
1964
1965 let case_checkbox = if case_sensitive { "[x]" } else { "[ ]" };
1967 let word_checkbox = if whole_word { "[x]" } else { "[ ]" };
1968 let regex_checkbox = if use_regex { "[x]" } else { "[ ]" };
1969
1970 let active_style = Style::default()
1981 .fg(theme.menu_active_fg)
1982 .bg(theme.menu_active_bg);
1983
1984 let shortcut_style = Style::default()
1986 .fg(theme.help_separator_fg)
1987 .bg(theme.menu_dropdown_bg);
1988
1989 let hover_shortcut_style = Style::default()
1991 .fg(theme.menu_hover_fg)
1992 .bg(theme.menu_hover_bg);
1993
1994 let mut spans = Vec::new();
1995 let mut current_col = area.x;
1996
1997 spans.push(Span::styled(" ", base_style));
1999 current_col += 1;
2000
2001 let get_checkbox_style = |is_hovered: bool, is_checked: bool| -> Style {
2003 if is_hovered {
2004 hover_style
2005 } else if is_checked {
2006 active_style
2007 } else {
2008 base_style
2009 }
2010 };
2011
2012 let case_hovered = hover == SearchOptionsHover::CaseSensitive;
2014 let case_start = current_col;
2015 let case_label = format!("{} {}", case_checkbox, t!("search.case_sensitive"));
2016 let case_shortcut_text = case_shortcut
2017 .as_ref()
2018 .map(|s| format!(" ({})", s))
2019 .unwrap_or_default();
2020 let case_full_width = str_width(&case_label) + str_width(&case_shortcut_text);
2021
2022 spans.push(Span::styled(
2023 case_label,
2024 get_checkbox_style(case_hovered, case_sensitive),
2025 ));
2026 if !case_shortcut_text.is_empty() {
2027 spans.push(Span::styled(
2028 case_shortcut_text,
2029 if case_hovered {
2030 hover_shortcut_style
2031 } else {
2032 shortcut_style
2033 },
2034 ));
2035 }
2036 current_col += case_full_width as u16;
2037 layout.case_sensitive = Some((case_start, current_col));
2038
2039 spans.push(Span::styled(" ", base_style));
2041 current_col += 3;
2042
2043 let word_hovered = hover == SearchOptionsHover::WholeWord;
2045 let word_start = current_col;
2046 let word_label = format!("{} {}", word_checkbox, t!("search.whole_word"));
2047 let word_shortcut_text = word_shortcut
2048 .as_ref()
2049 .map(|s| format!(" ({})", s))
2050 .unwrap_or_default();
2051 let word_full_width = str_width(&word_label) + str_width(&word_shortcut_text);
2052
2053 spans.push(Span::styled(
2054 word_label,
2055 get_checkbox_style(word_hovered, whole_word),
2056 ));
2057 if !word_shortcut_text.is_empty() {
2058 spans.push(Span::styled(
2059 word_shortcut_text,
2060 if word_hovered {
2061 hover_shortcut_style
2062 } else {
2063 shortcut_style
2064 },
2065 ));
2066 }
2067 current_col += word_full_width as u16;
2068 layout.whole_word = Some((word_start, current_col));
2069
2070 spans.push(Span::styled(" ", base_style));
2072 current_col += 3;
2073
2074 let regex_hovered = hover == SearchOptionsHover::Regex;
2076 let regex_start = current_col;
2077 let regex_label = format!("{} {}", regex_checkbox, t!("search.regex"));
2078 let regex_shortcut_text = regex_shortcut
2079 .as_ref()
2080 .map(|s| format!(" ({})", s))
2081 .unwrap_or_default();
2082 let regex_full_width = str_width(®ex_label) + str_width(®ex_shortcut_text);
2083
2084 spans.push(Span::styled(
2085 regex_label,
2086 get_checkbox_style(regex_hovered, use_regex),
2087 ));
2088 if !regex_shortcut_text.is_empty() {
2089 spans.push(Span::styled(
2090 regex_shortcut_text,
2091 if regex_hovered {
2092 hover_shortcut_style
2093 } else {
2094 shortcut_style
2095 },
2096 ));
2097 }
2098 current_col += regex_full_width as u16;
2099 layout.regex = Some((regex_start, current_col));
2100
2101 if use_regex && confirm_each.is_some() {
2103 let hint = " \u{2502} $1,$2,…";
2104 spans.push(Span::styled(hint, shortcut_style));
2105 current_col += str_width(hint) as u16;
2106 }
2107
2108 if let Some(confirm_value) = confirm_each {
2110 let confirm_shortcut =
2111 get_shortcut(&crate::input::keybindings::Action::ToggleSearchConfirmEach);
2112 let confirm_checkbox = if confirm_value { "[x]" } else { "[ ]" };
2113
2114 spans.push(Span::styled(" ", base_style));
2116 current_col += 3;
2117
2118 let confirm_hovered = hover == SearchOptionsHover::ConfirmEach;
2119 let confirm_start = current_col;
2120 let confirm_label = format!("{} {}", confirm_checkbox, t!("search.confirm_each"));
2121 let confirm_shortcut_text = confirm_shortcut
2122 .as_ref()
2123 .map(|s| format!(" ({})", s))
2124 .unwrap_or_default();
2125 let confirm_full_width = str_width(&confirm_label) + str_width(&confirm_shortcut_text);
2126
2127 spans.push(Span::styled(
2128 confirm_label,
2129 get_checkbox_style(confirm_hovered, confirm_value),
2130 ));
2131 if !confirm_shortcut_text.is_empty() {
2132 spans.push(Span::styled(
2133 confirm_shortcut_text,
2134 if confirm_hovered {
2135 hover_shortcut_style
2136 } else {
2137 shortcut_style
2138 },
2139 ));
2140 }
2141 current_col += confirm_full_width as u16;
2142 layout.confirm_each = Some((confirm_start, current_col));
2143 }
2144
2145 let current_width = (current_col - area.x) as usize;
2147 let available_width = area.width as usize;
2148 if current_width < available_width {
2149 spans.push(Span::styled(
2150 " ".repeat(available_width.saturating_sub(current_width)),
2151 base_style,
2152 ));
2153 }
2154
2155 let options_line = Paragraph::new(Line::from(spans));
2156 frame.render_widget(options_line, area);
2157
2158 layout
2159 }
2160}
2161
2162#[cfg(test)]
2163mod tests {
2164 use super::*;
2165 use std::path::PathBuf;
2166
2167 #[test]
2168 fn test_truncate_path_short_path() {
2169 let path = PathBuf::from("/home/user/project");
2170 let result = truncate_path(&path, 50);
2171
2172 assert!(!result.truncated);
2173 assert_eq!(result.suffix, "/home/user/project");
2174 assert!(result.prefix.is_empty());
2175 }
2176
2177 #[test]
2178 fn test_truncate_path_long_path() {
2179 let path = PathBuf::from(
2180 "/private/var/folders/p6/nlmq3k8146990kpkxl73mq340000gn/T/.tmpNYt4Fc/project_root",
2181 );
2182 let result = truncate_path(&path, 40);
2183
2184 assert!(result.truncated, "Path should be truncated");
2185 assert_eq!(result.prefix, "/private");
2186 assert!(
2187 result.suffix.contains("project_root"),
2188 "Suffix should contain project_root"
2189 );
2190 }
2191
2192 #[test]
2193 fn test_truncate_path_preserves_last_components() {
2194 let path = PathBuf::from("/a/b/c/d/e/f/g/h/i/j/project/src");
2195 let result = truncate_path(&path, 30);
2196
2197 assert!(result.truncated);
2198 assert!(
2200 result.suffix.contains("src"),
2201 "Should preserve last component 'src', got: {}",
2202 result.suffix
2203 );
2204 }
2205
2206 #[test]
2207 fn test_truncate_path_display_len() {
2208 let path = PathBuf::from("/private/var/folders/deep/nested/path/here");
2209 let result = truncate_path(&path, 30);
2210
2211 let display = result.to_string_plain();
2213 assert!(
2214 display.len() <= 35, "Display should be truncated to around 30 chars, got {} chars: {}",
2216 display.len(),
2217 display
2218 );
2219 }
2220
2221 #[test]
2222 fn test_truncate_path_root_only() {
2223 let path = PathBuf::from("/");
2224 let result = truncate_path(&path, 50);
2225
2226 assert!(!result.truncated);
2227 assert_eq!(result.suffix, "/");
2228 }
2229
2230 #[test]
2231 fn test_truncate_path_multibyte_single_component_does_not_panic() {
2232 let path = PathBuf::from("/ユーザーのプロジェクト名前/file");
2238 let result = truncate_path(&path, 5);
2239 let display = result.to_string_plain();
2240 assert!(display.is_char_boundary(display.len()));
2241 assert!(display.ends_with("..."));
2242 }
2243
2244 #[test]
2245 fn test_truncate_path_multibyte_last_component_does_not_panic() {
2246 let path = PathBuf::from("/a/ユーザーのプロジェクト名前");
2253 let result = truncate_path(&path, 13);
2254 let display = result.to_string_plain();
2255 assert!(display.is_char_boundary(display.len()));
2256 }
2257
2258 #[test]
2259 fn test_truncated_path_to_string_plain() {
2260 let truncated = TruncatedPath {
2261 prefix: "/home".to_string(),
2262 truncated: true,
2263 suffix: "/project/src".to_string(),
2264 sep: '/',
2265 };
2266
2267 assert_eq!(truncated.to_string_plain(), "/home/[...]/project/src");
2268 }
2269
2270 #[test]
2271 fn test_truncated_path_to_string_plain_no_truncation() {
2272 let truncated = TruncatedPath {
2273 prefix: String::new(),
2274 truncated: false,
2275 suffix: "/home/user/project".to_string(),
2276 sep: '/',
2277 };
2278
2279 assert_eq!(truncated.to_string_plain(), "/home/user/project");
2280 }
2281
2282 #[test]
2288 fn test_truncate_path_windows_backslashes() {
2289 let path = Path::new(r"C:\Users\me\projects\fresh\crates\editor\src\main.rs");
2290 let t = truncate_path(path, 34);
2291 assert!(t.truncated, "long backslash path should middle-truncate");
2292 assert_eq!(t.sep, '\\', "should re-join with backslashes");
2293 let shown = t.to_string_plain();
2294 assert!(
2295 shown.starts_with(r"C:\Users"),
2296 "keeps drive + first dir: {shown}"
2297 );
2298 assert!(
2299 shown.contains(r"\[...]\"),
2300 "uses a backslash ellipsis: {shown}"
2301 );
2302 assert!(shown.ends_with("main.rs"), "keeps the tail: {shown}");
2303 assert!(!shown.contains('/'), "no forward slashes leak in: {shown}");
2304 assert!(shown.len() <= 34, "respects max_len: {shown}");
2305 }
2306
2307 #[test]
2309 fn test_truncate_path_windows_short_unchanged() {
2310 let path = Path::new(r"C:\a\b");
2311 let t = truncate_path(path, 80);
2312 assert!(!t.truncated);
2313 assert_eq!(t.to_string_plain(), r"C:\a\b");
2314 }
2315
2316 #[test]
2317 fn test_remote_indicator_element_kind_equality() {
2318 assert_eq!(
2322 ElementKind::RemoteIndicator(RemoteIndicatorState::Local),
2323 ElementKind::RemoteIndicator(RemoteIndicatorState::Local)
2324 );
2325 let distinct = [
2326 RemoteIndicatorState::Local,
2327 RemoteIndicatorState::Connecting,
2328 RemoteIndicatorState::Connected,
2329 RemoteIndicatorState::FailedAttach,
2330 RemoteIndicatorState::Disconnected,
2331 ];
2332 for (i, a) in distinct.iter().enumerate() {
2333 for (j, b) in distinct.iter().enumerate() {
2334 if i == j {
2335 continue;
2336 }
2337 assert_ne!(
2338 ElementKind::RemoteIndicator(*a),
2339 ElementKind::RemoteIndicator(*b),
2340 "expected {:?} != {:?}",
2341 a,
2342 b
2343 );
2344 }
2345 }
2346 }
2347
2348 #[test]
2349 fn test_remote_indicator_state_default_is_local() {
2350 assert_eq!(RemoteIndicatorState::default(), RemoteIndicatorState::Local);
2353 }
2354
2355 #[test]
2356 fn test_remote_indicator_override_deserializes_kind_tags() {
2357 let cases: &[(&str, RemoteIndicatorOverride)] = &[
2361 (r#"{"kind":"local"}"#, RemoteIndicatorOverride::Local),
2362 (
2363 r#"{"kind":"connecting","label":"Building"}"#,
2364 RemoteIndicatorOverride::Connecting {
2365 label: Some("Building".into()),
2366 },
2367 ),
2368 (
2369 r#"{"kind":"connecting"}"#,
2370 RemoteIndicatorOverride::Connecting { label: None },
2371 ),
2372 (
2373 r#"{"kind":"connected","label":"Container:abc"}"#,
2374 RemoteIndicatorOverride::Connected {
2375 label: Some("Container:abc".into()),
2376 },
2377 ),
2378 (
2379 r#"{"kind":"failed_attach","error":"exit 1"}"#,
2380 RemoteIndicatorOverride::FailedAttach {
2381 error: Some("exit 1".into()),
2382 },
2383 ),
2384 (
2385 r#"{"kind":"disconnected","label":"Container:abc"}"#,
2386 RemoteIndicatorOverride::Disconnected {
2387 label: Some("Container:abc".into()),
2388 },
2389 ),
2390 ];
2391 for (json, expected) in cases {
2392 let parsed: RemoteIndicatorOverride = serde_json::from_str(json)
2393 .unwrap_or_else(|e| panic!("failed to parse {}: {}", json, e));
2394 assert_eq!(&parsed, expected, "wire shape mismatch for {}", json);
2395 }
2396 }
2397
2398 #[test]
2399 fn test_remote_indicator_override_labels() {
2400 let connecting = RemoteIndicatorOverride::Connecting { label: None };
2404 assert!(
2405 connecting.label().contains("Connecting"),
2406 "connecting default label should mention Connecting, got {:?}",
2407 connecting.label()
2408 );
2409
2410 let connecting_labeled = RemoteIndicatorOverride::Connecting {
2411 label: Some("Building".into()),
2412 };
2413 assert!(
2414 connecting_labeled.label().contains("Building"),
2415 "labeled connecting should include the label, got {:?}",
2416 connecting_labeled.label()
2417 );
2418
2419 let failed_bare = RemoteIndicatorOverride::FailedAttach { error: None };
2420 assert_eq!(failed_bare.label(), "Attach failed");
2421
2422 let failed_detail = RemoteIndicatorOverride::FailedAttach {
2423 error: Some("exit 1".into()),
2424 };
2425 assert!(
2426 failed_detail.label().contains("exit 1"),
2427 "failed with error should include the error, got {:?}",
2428 failed_detail.label()
2429 );
2430 }
2431
2432 #[test]
2433 fn test_palette_and_lsp_on_use_dedicated_theme_keys() {
2434 let theme = crate::view::theme::Theme::from_json(
2446 r#"{"name":"t","editor":{},"ui":{},"search":{},"diagnostic":{},"syntax":{}}"#,
2447 )
2448 .expect("minimal theme should parse");
2449
2450 assert_eq!(theme.status_palette_fg, theme.status_bar_fg);
2452 assert_eq!(theme.status_palette_bg, theme.status_bar_bg);
2453 assert_eq!(theme.status_lsp_on_fg, theme.status_bar_fg);
2454 assert_eq!(theme.status_lsp_on_bg, theme.status_bar_bg);
2455
2456 let palette_style = StatusBarRenderer::element_style(
2457 ElementKind::Palette,
2458 &theme,
2459 StatusBarHover::None,
2460 WarningLevel::None,
2461 LspIndicatorState::None,
2462 );
2463 assert_eq!(palette_style.fg, Some(theme.status_palette_fg));
2464 assert_eq!(palette_style.bg, Some(theme.status_palette_bg));
2465
2466 let lsp_on_style = StatusBarRenderer::element_style(
2467 ElementKind::Lsp,
2468 &theme,
2469 StatusBarHover::None,
2470 WarningLevel::None,
2471 LspIndicatorState::On,
2472 );
2473 assert_eq!(lsp_on_style.fg, Some(theme.status_lsp_on_fg));
2474 assert_eq!(lsp_on_style.bg, Some(theme.status_lsp_on_bg));
2475
2476 let lsp_off_style = StatusBarRenderer::element_style(
2479 ElementKind::Lsp,
2480 &theme,
2481 StatusBarHover::None,
2482 WarningLevel::None,
2483 LspIndicatorState::Off,
2484 );
2485 assert_eq!(lsp_off_style.fg, Some(theme.status_lsp_actionable_fg));
2486 assert_eq!(lsp_off_style.bg, Some(theme.status_lsp_actionable_bg));
2487
2488 let lsp_error_style = StatusBarRenderer::element_style(
2489 ElementKind::Lsp,
2490 &theme,
2491 StatusBarHover::None,
2492 WarningLevel::None,
2493 LspIndicatorState::Error,
2494 );
2495 assert_eq!(lsp_error_style.fg, Some(theme.diagnostic_error_fg));
2496 assert_eq!(lsp_error_style.bg, Some(theme.diagnostic_error_bg));
2497 }
2498
2499 #[test]
2500 fn test_status_palette_and_lsp_on_keys_override_independently() {
2501 let theme_json = r#"{
2507 "name":"t",
2508 "editor":{},
2509 "ui":{
2510 "status_bar_fg":"White",
2511 "status_bar_bg":"DarkGray",
2512 "status_palette_fg":"Black",
2513 "status_palette_bg":"Yellow",
2514 "status_lsp_on_fg":"Black",
2515 "status_lsp_on_bg":"Cyan"
2516 },
2517 "search":{},
2518 "diagnostic":{},
2519 "syntax":{}
2520 }"#;
2521 let theme = crate::view::theme::Theme::from_json(theme_json).expect("theme should parse");
2522 assert_ne!(theme.status_palette_fg, theme.status_bar_fg);
2523 assert_ne!(theme.status_palette_bg, theme.status_bar_bg);
2524 assert_ne!(theme.status_lsp_on_fg, theme.status_bar_fg);
2525 assert_ne!(theme.status_lsp_on_bg, theme.status_bar_bg);
2526 }
2527
2528 #[test]
2529 fn test_status_separator_keys_default_and_override() {
2530 let theme = crate::view::theme::Theme::from_json(
2534 r#"{"name":"t","editor":{},"ui":{},"search":{},"diagnostic":{},"syntax":{}}"#,
2535 )
2536 .expect("minimal theme should parse");
2537 assert_eq!(theme.status_separator_fg, theme.status_bar_fg);
2538 assert_eq!(theme.status_separator_bg, theme.status_bar_bg);
2539
2540 let theme = crate::view::theme::Theme::from_json(
2543 r#"{
2544 "name":"t",
2545 "editor":{},
2546 "ui":{
2547 "status_bar_fg":"White",
2548 "status_bar_bg":"DarkGray",
2549 "status_separator_fg":"Gray",
2550 "status_separator_bg":"Black"
2551 },
2552 "search":{},
2553 "diagnostic":{},
2554 "syntax":{}
2555 }"#,
2556 )
2557 .expect("theme should parse");
2558 assert_ne!(theme.status_separator_fg, theme.status_bar_fg);
2559 assert_ne!(theme.status_separator_bg, theme.status_bar_bg);
2560 }
2561
2562 #[test]
2563 fn test_remote_indicator_override_state_projection() {
2564 assert_eq!(
2565 RemoteIndicatorOverride::Local.state(),
2566 RemoteIndicatorState::Local
2567 );
2568 assert_eq!(
2569 RemoteIndicatorOverride::Connecting { label: None }.state(),
2570 RemoteIndicatorState::Connecting
2571 );
2572 assert_eq!(
2573 RemoteIndicatorOverride::Connected { label: None }.state(),
2574 RemoteIndicatorState::Connected
2575 );
2576 assert_eq!(
2577 RemoteIndicatorOverride::FailedAttach { error: None }.state(),
2578 RemoteIndicatorState::FailedAttach
2579 );
2580 assert_eq!(
2581 RemoteIndicatorOverride::Disconnected { label: None }.state(),
2582 RemoteIndicatorState::Disconnected
2583 );
2584 }
2585
2586 #[test]
2595 fn test_cursor_position_widths_stable_across_cursor_movement() {
2596 let line_count = 50;
2597 let widths: Vec<usize> = [(1, 1), (5, 12), (12, 5), (50, 100), (1, 1)]
2600 .into_iter()
2601 .map(|(ln, col)| format_cursor_position(ln, col, line_count).len())
2602 .collect();
2603 assert!(
2604 widths.windows(2).all(|w| w[0] == w[1]),
2605 "rendered widths drift across cursor movements: {widths:?}"
2606 );
2607 }
2608
2609 #[test]
2610 fn test_cursor_position_preserves_natural_number_text() {
2611 let text = format_cursor_position(1, 1, 50);
2615 assert!(
2616 text.starts_with("Ln 1, Col 1"),
2617 "expected text to start with natural numbers, got {text:?}"
2618 );
2619 assert!(
2620 text.ends_with(' '),
2621 "expected trailing padding, got {text:?}"
2622 );
2623 }
2624
2625 #[test]
2626 fn test_cursor_position_no_padding_for_single_line_buffer() {
2627 let text = format_cursor_position(1, 1, 1);
2631 assert_eq!(text.len(), 13);
2633 assert!(text.starts_with("Ln 1, Col 1"));
2634 }
2635
2636 #[test]
2637 fn test_cursor_position_does_not_shrink_below_actual() {
2638 let text = format_cursor_position(99, 99999, 50);
2641 assert_eq!(text, "Ln 99, Col 99999");
2642 }
2643
2644 #[test]
2645 fn test_cursor_position_compact_widths_stable() {
2646 let line_count = 50;
2647 let widths: Vec<usize> = [(1, 1), (5, 12), (12, 5), (50, 100), (1, 1)]
2648 .into_iter()
2649 .map(|(ln, col)| format_cursor_position_compact(ln, col, line_count).len())
2650 .collect();
2651 assert!(
2652 widths.windows(2).all(|w| w[0] == w[1]),
2653 "compact widths drift across cursor movements: {widths:?}"
2654 );
2655 }
2656
2657 #[test]
2658 fn test_cursor_position_compact_preserves_natural_text() {
2659 let text = format_cursor_position_compact(1, 1, 50);
2660 assert!(
2661 text.starts_with("1:1"),
2662 "expected text to start with natural numbers, got {text:?}"
2663 );
2664 }
2665
2666 #[test]
2667 fn test_cursor_position_scales_with_line_count() {
2668 let short = format_cursor_position(1, 1, 9);
2671 let long = format_cursor_position(1, 1, 10_000);
2672 assert!(
2673 long.len() > short.len(),
2674 "wider buffers should reserve more width: {short:?} vs {long:?}"
2675 );
2676 let top = format_cursor_position(1, 1, 10_000);
2679 let high = format_cursor_position(9_999, 999, 10_000);
2680 assert_eq!(top.len(), high.len());
2681 }
2682
2683 #[test]
2684 fn test_cursor_column_counts_chars_not_bytes() {
2685 let mut buf =
2686 crate::model::buffer::TextBuffer::from_str_test("hello\ncafé résumé\nworld\n");
2687 let line_start = buf.line_start_offset(1).unwrap();
2688
2689 let col = cursor_column(&mut buf, line_start + 6);
2691 assert_eq!(
2692 col, 5,
2693 "cursor at 'r' should be column 5, not byte offset 6"
2694 );
2695
2696 let col = cursor_column(&mut buf, line_start + 3);
2698 assert_eq!(col, 3, "cursor at 'é' should be column 3");
2699
2700 let col = cursor_column(&mut buf, line_start + 10);
2702 assert_eq!(col, 8, "cursor at 'u' should be column 8");
2703 }
2704
2705 #[test]
2706 fn test_cursor_column_counts_grapheme_clusters() {
2707 let mut buf = crate::model::buffer::TextBuffer::from_str_test("ab\ne\u{0301}x\n");
2711 let line_start = buf.line_start_offset(1).unwrap();
2712
2713 let col = cursor_column(&mut buf, line_start + 3);
2716 assert_eq!(
2717 col, 1,
2718 "accented 'e' is one grapheme; 'x' should be column 1, not 2"
2719 );
2720 }
2721
2722 #[test]
2723 fn test_cursor_column_zwj_emoji_is_one_grapheme() {
2724 let mut buf = crate::model::buffer::TextBuffer::from_str_test("👨\u{200D}👩\u{200D}👧z\n");
2727 let line_start = buf.line_start_offset(0).unwrap();
2728
2729 let col = cursor_column(&mut buf, line_start + 18);
2730 assert_eq!(col, 1, "ZWJ family emoji should count as one column");
2731 }
2732
2733 #[test]
2734 fn test_cursor_column_at_line_start_is_zero() {
2735 let mut buf = crate::model::buffer::TextBuffer::from_str_test("hello\nworld\n");
2736 let line_start = buf.line_start_offset(1).unwrap();
2737 assert_eq!(cursor_column(&mut buf, line_start), 0);
2738 }
2739}