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)]
40pub enum StatusBarClickable {
41 LineEnding,
42 Encoding,
43 Language,
44 Lsp,
45 Warnings,
46 Messages,
47 RemoteIndicator,
48 WorkspaceTrust,
49 ReadOnly,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54enum ElementKind {
55 Normal,
57 LineEnding,
59 Encoding,
61 Language,
63 Lsp,
65 WarningBadge,
67 Update,
69 Palette,
71 Messages,
73 ReadOnly,
75 RemoteDisconnected,
77 Clock,
79 RemoteIndicator(RemoteIndicatorState),
81 WorkspaceTrust(crate::services::workspace_trust::TrustLevel),
84 Custom,
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
102pub enum RemoteIndicatorState {
103 #[default]
105 Local,
106 Connecting,
111 Connected,
113 FailedAttach,
117 Disconnected,
120}
121
122#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
132#[serde(tag = "kind", rename_all = "snake_case")]
133pub enum RemoteIndicatorOverride {
134 Local,
137 Connecting {
140 #[serde(default)]
141 label: Option<String>,
142 },
143 Connected {
146 #[serde(default)]
147 label: Option<String>,
148 },
149 FailedAttach {
152 #[serde(default)]
153 error: Option<String>,
154 },
155 Disconnected {
158 #[serde(default)]
159 label: Option<String>,
160 },
161}
162
163impl RemoteIndicatorOverride {
164 pub fn state(&self) -> RemoteIndicatorState {
166 match self {
167 Self::Local => RemoteIndicatorState::Local,
168 Self::Connecting { .. } => RemoteIndicatorState::Connecting,
169 Self::Connected { .. } => RemoteIndicatorState::Connected,
170 Self::FailedAttach { .. } => RemoteIndicatorState::FailedAttach,
171 Self::Disconnected { .. } => RemoteIndicatorState::Disconnected,
172 }
173 }
174
175 pub fn label(&self) -> String {
179 match self {
180 Self::Local => "Local".to_string(),
181 Self::Connecting { label } => match label {
182 Some(s) if !s.is_empty() => format!("⠿ {}", s),
183 _ => "⠿ Connecting".to_string(),
184 },
185 Self::Connected { label } => label
186 .as_deref()
187 .filter(|s| !s.is_empty())
188 .unwrap_or("Connected")
189 .to_string(),
190 Self::FailedAttach { error } => match error {
191 Some(s) if !s.is_empty() => format!("Attach failed: {}", s),
192 _ => "Attach failed".to_string(),
193 },
194 Self::Disconnected { label } => match label {
195 Some(s) if !s.is_empty() => format!("{} (Disconnected)", s),
196 _ => "Disconnected".to_string(),
197 },
198 }
199 }
200}
201
202struct RenderedElement {
204 text: String,
205 kind: ElementKind,
206 token_key: Option<String>,
211}
212
213#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
229pub enum LspIndicatorState {
230 #[default]
231 None,
232 On,
233 Off,
234 OffDismissed,
235 Error,
236}
237
238pub struct StatusBarContext<'a> {
240 pub state: &'a mut EditorState,
241 pub cursors: &'a crate::model::cursor::Cursors,
242 pub status_message: &'a Option<String>,
243 pub plugin_status_message: &'a Option<String>,
244 pub lsp_status: &'a str,
245 pub lsp_indicator_state: LspIndicatorState,
250 pub theme: &'a crate::view::theme::Theme,
251 pub display_name: &'a str,
252 pub keybindings: &'a crate::input::keybindings::KeybindingResolver,
253 pub chord_state: &'a [(crossterm::event::KeyCode, crossterm::event::KeyModifiers)],
254 pub update_available: Option<&'a str>,
255 pub warning_level: WarningLevel,
256 pub general_warning_count: usize,
257 pub hovered: Option<StatusBarClickable>,
261 pub remote_connection: Option<&'a str>,
262 pub session_name: Option<&'a str>,
263 pub read_only: bool,
264 pub remote_state_override: Option<&'a RemoteIndicatorOverride>,
271 pub remote_reconnect_error: Option<&'a str>,
278 pub is_synthetic_placeholder: bool,
284 pub remote_indicator_on_bar: bool,
293 pub dynamic_status_bar_elements: HashMap<String, String>,
297 pub workspace_trust_level: crate::services::workspace_trust::TrustLevel,
301}
302
303#[derive(Debug, Clone, Default)]
305pub struct StatusBarLayout {
306 pub clickable: Vec<(StatusBarClickable, u16, u16, u16)>,
311 pub plugin_token_areas: std::collections::HashMap<String, (u16, u16, u16)>,
323 pub segments: Vec<StatusSegmentInfo>,
328}
329
330#[derive(Debug, Clone)]
333pub struct StatusSegmentInfo {
334 pub name: &'static str,
337 pub key: Option<String>,
339 pub text: String,
340 pub x: u16,
341 pub w: u16,
342 pub side: &'static str,
347}
348
349fn element_kind_name(kind: ElementKind) -> &'static str {
351 match kind {
352 ElementKind::Lsp => "lsp",
353 ElementKind::WarningBadge => "warning",
354 ElementKind::Language => "language",
355 ElementKind::Encoding => "encoding",
356 ElementKind::LineEnding => "lineEnding",
357 ElementKind::RemoteIndicator(_) => "remote",
358 ElementKind::WorkspaceTrust(_) => "trust",
359 ElementKind::Messages => "message",
360 ElementKind::Custom => "plugin",
361 _ => "text",
362 }
363}
364
365#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
367pub enum SearchOptionsHover {
368 #[default]
369 None,
370 CaseSensitive,
371 WholeWord,
372 Regex,
373 ConfirmEach,
374}
375
376#[derive(Debug, Clone, Default)]
378pub struct SearchOptionsLayout {
379 pub row: u16,
381 pub case_sensitive: Option<(u16, u16)>,
383 pub whole_word: Option<(u16, u16)>,
385 pub regex: Option<(u16, u16)>,
387 pub confirm_each: Option<(u16, u16)>,
389}
390
391impl SearchOptionsLayout {
392 pub fn checkbox_at(&self, x: u16, y: u16) -> Option<SearchOptionsHover> {
394 if y != self.row {
395 return None;
396 }
397
398 if let Some((start, end)) = self.case_sensitive {
399 if x >= start && x < end {
400 return Some(SearchOptionsHover::CaseSensitive);
401 }
402 }
403 if let Some((start, end)) = self.whole_word {
404 if x >= start && x < end {
405 return Some(SearchOptionsHover::WholeWord);
406 }
407 }
408 if let Some((start, end)) = self.regex {
409 if x >= start && x < end {
410 return Some(SearchOptionsHover::Regex);
411 }
412 }
413 if let Some((start, end)) = self.confirm_each {
414 if x >= start && x < end {
415 return Some(SearchOptionsHover::ConfirmEach);
416 }
417 }
418 None
419 }
420}
421
422#[derive(Debug, Clone)]
424pub struct TruncatedPath {
425 pub prefix: String,
427 pub truncated: bool,
429 pub suffix: String,
431 pub sep: char,
434}
435
436impl TruncatedPath {
437 pub fn to_string_plain(&self) -> String {
439 if self.truncated {
440 format!("{}{}[...]{}", self.prefix, self.sep, self.suffix)
441 } else {
442 format!("{}{}", self.prefix, self.suffix)
443 }
444 }
445
446 pub fn display_len(&self) -> usize {
449 if self.truncated {
450 self.prefix.len() + self.sep.len_utf8() + "[...]".len() + self.suffix.len()
451 } else {
452 self.prefix.len() + self.suffix.len()
453 }
454 }
455}
456
457fn path_display_sep(path_str: &str) -> char {
461 if path_str.contains('\\') {
462 '\\'
463 } else {
464 '/'
465 }
466}
467
468pub fn truncate_path(path: &Path, max_len: usize) -> TruncatedPath {
480 let path_str = path.to_string_lossy();
481 let sep = path_display_sep(&path_str);
486
487 if path_str.len() <= max_len {
489 return TruncatedPath {
490 prefix: String::new(),
491 truncated: false,
492 suffix: path_str.to_string(),
493 sep,
494 };
495 }
496
497 let components: Vec<&str> = path_str
498 .split(['/', '\\'])
499 .filter(|s| !s.is_empty())
500 .collect();
501
502 if components.is_empty() {
503 return TruncatedPath {
504 prefix: sep.to_string(),
505 truncated: false,
506 suffix: String::new(),
507 sep,
508 };
509 }
510
511 let leading_sep = path_str.starts_with('/') || path_str.starts_with('\\');
516 let is_drive = |c: &str| {
517 let b = c.as_bytes();
518 b.len() == 2 && b[1] == b':' && b[0].is_ascii_alphabetic()
519 };
520 let prefix_count = if !leading_sep && is_drive(components[0]) {
521 2
522 } else {
523 1
524 }
525 .min(components.len());
526 let sep_str = sep.to_string();
527 let prefix = {
528 let joined = components[..prefix_count].join(&sep_str);
529 if leading_sep {
530 format!("{}{}", sep, joined)
531 } else {
532 joined
533 }
534 };
535
536 let ellipsis_len = sep.len_utf8() + "[...]".len();
538
539 let available_for_suffix = max_len.saturating_sub(prefix.len() + ellipsis_len);
541
542 if available_for_suffix < 5 || components.len() <= prefix_count {
543 let truncated_path = if path_str.len() > max_len.saturating_sub(3) {
549 let cut = path_str.floor_char_boundary(max_len.saturating_sub(3));
550 format!("{}...", &path_str[..cut])
551 } else {
552 path_str.to_string()
553 };
554 return TruncatedPath {
555 prefix: String::new(),
556 truncated: false,
557 suffix: truncated_path,
558 sep,
559 };
560 }
561
562 let mut suffix_parts: Vec<&str> = Vec::new();
564 let mut suffix_len = 0;
565
566 for component in components.iter().skip(prefix_count).rev() {
567 let component_len = component.len() + 1; if suffix_len + component_len <= available_for_suffix {
569 suffix_parts.push(component);
570 suffix_len += component_len;
571 } else {
572 break;
573 }
574 }
575
576 suffix_parts.reverse();
577
578 if suffix_parts.len() == components.len() - prefix_count {
580 return TruncatedPath {
581 prefix: String::new(),
582 truncated: false,
583 suffix: path_str.to_string(),
584 sep,
585 };
586 }
587
588 let suffix = if suffix_parts.is_empty() {
589 let last = components.last().unwrap_or(&"");
593 let truncate_to = available_for_suffix.saturating_sub(4); if truncate_to > 0 && last.len() > truncate_to {
595 let cut = last.floor_char_boundary(truncate_to);
596 format!("{}{}...", sep, &last[..cut])
597 } else {
598 format!("{}{}", sep, last)
599 }
600 } else {
601 format!("{}{}", sep, suffix_parts.join(&sep_str))
602 };
603
604 TruncatedPath {
605 prefix,
606 truncated: true,
607 suffix,
608 sep,
609 }
610}
611
612fn truncate_to_width(s: &str, max_width: usize) -> String {
614 let width = str_width(s);
615 if width <= max_width {
616 return s.to_string();
617 }
618 let truncate_at = max_width.saturating_sub(3);
619 if truncate_at == 0 {
620 return if max_width >= 3 {
621 "...".to_string()
622 } else {
623 s.chars().take(max_width).collect()
624 };
625 }
626 let mut w = 0;
627 let truncated: String = s
628 .chars()
629 .take_while(|ch| {
630 let cw = char_width(*ch);
631 if w + cw <= truncate_at {
632 w += cw;
633 true
634 } else {
635 false
636 }
637 })
638 .collect();
639 format!("{}...", truncated)
640}
641
642const CURSOR_COL_RESERVE: usize = 3;
647
648fn cursor_column(buffer: &mut crate::model::buffer::TextBuffer, cursor_position: usize) -> usize {
655 let mut iter = buffer.line_iterator(cursor_position, 80);
656 let line_start = iter.current_position();
657 let byte_col = cursor_position.saturating_sub(line_start);
658 if byte_col == 0 {
659 return 0;
660 }
661 match iter.next_line() {
667 Some((_, text)) if text.len() >= byte_col => {
668 let mut end = byte_col;
669 while end > 0 && !text.is_char_boundary(end) {
670 end -= 1;
671 }
672 crate::primitives::grapheme::grapheme_count(&text[..end])
673 }
674 _ => byte_col,
675 }
676}
677
678fn format_cursor_position(line: usize, col: usize, line_count: usize) -> String {
686 let text = format!("Ln {line}, Col {col}");
687 let line_digits = line_count.max(1).to_string().len();
688 let min_width = 9 + line_digits + CURSOR_COL_RESERVE;
690 if text.len() < min_width {
691 format!("{text:<min_width$}")
692 } else {
693 text
694 }
695}
696
697fn format_cursor_position_compact(line: usize, col: usize, line_count: usize) -> String {
701 let text = format!("{line}:{col}");
702 let line_digits = line_count.max(1).to_string().len();
703 let min_width = 1 + line_digits + CURSOR_COL_RESERVE;
705 if text.len() < min_width {
706 format!("{text:<min_width$}")
707 } else {
708 text
709 }
710}
711
712pub struct StatusBarRenderer;
714
715impl StatusBarRenderer {
716 pub fn render_status_bar(
720 frame: &mut Frame,
721 area: Rect,
722 ctx: &mut StatusBarContext<'_>,
723 config: &StatusBarConfig,
724 rec: Option<&mut CellThemeRecorder>,
725 draw: bool,
729 ) -> StatusBarLayout {
730 Self::render_status(frame, area, ctx, config, rec, draw)
731 }
732
733 pub fn render_prompt(
735 frame: &mut Frame,
736 area: Rect,
737 prompt: &Prompt,
738 theme: &crate::view::theme::Theme,
739 ) {
740 let base_style = Style::default().fg(theme.prompt_fg).bg(theme.prompt_bg);
741
742 let mut spans = vec![Span::styled(prompt.message.clone(), base_style)];
744
745 if let Some((sel_start, sel_end)) = prompt.selection_range() {
747 let input = &prompt.input;
748
749 if sel_start > 0 {
751 spans.push(Span::styled(input[..sel_start].to_string(), base_style));
752 }
753
754 if sel_start < sel_end {
756 let selection_style = Style::default()
758 .fg(theme.prompt_selection_fg)
759 .bg(theme.prompt_selection_bg);
760 spans.push(Span::styled(
761 input[sel_start..sel_end].to_string(),
762 selection_style,
763 ));
764 }
765
766 if sel_end < input.len() {
768 spans.push(Span::styled(input[sel_end..].to_string(), base_style));
769 }
770 } else {
771 spans.push(Span::styled(prompt.input.clone(), base_style));
773 }
774
775 let line = Line::from(spans);
776 let prompt_line = Paragraph::new(line).style(base_style);
777
778 frame.render_widget(prompt_line, area);
779
780 let message_width = str_width(&prompt.message);
785 let input_width_before_cursor = str_width(&prompt.input[..prompt.cursor_pos]);
786 let cursor_x = (message_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 pub fn render_file_open_prompt(
796 frame: &mut Frame,
797 area: Rect,
798 prompt: &Prompt,
799 file_open_state: &crate::app::file_open::FileOpenState,
800 theme: &crate::view::theme::Theme,
801 ) {
802 let base_style = Style::default().fg(theme.prompt_fg).bg(theme.prompt_bg);
803 let dir_style = Style::default()
804 .fg(theme.help_separator_fg)
805 .bg(theme.prompt_bg);
806 let ellipsis_style = Style::default()
808 .fg(theme.menu_highlight_fg)
809 .bg(theme.prompt_bg);
810
811 let mut spans = Vec::new();
812
813 let open_prompt = t!("file.open_prompt").to_string();
815 spans.push(Span::styled(open_prompt.clone(), base_style));
816
817 let prefix_len = str_width(&open_prompt);
820 let dir_path = file_open_state.current_dir.to_string_lossy();
821 let dir_path_len = dir_path.len() + 1; let input_len = prompt.input.len();
823 let total_len = prefix_len + dir_path_len + input_len;
824 let threshold = (area.width as usize * 90) / 100;
825
826 let truncated = if total_len > threshold {
828 let available_for_path = threshold
830 .saturating_sub(prefix_len)
831 .saturating_sub(input_len);
832 truncate_path(&file_open_state.current_dir, available_for_path)
833 } else {
834 TruncatedPath {
836 prefix: String::new(),
837 truncated: false,
838 suffix: dir_path.to_string(),
839 sep: path_display_sep(&dir_path),
840 }
841 };
842
843 if truncated.truncated {
845 spans.push(Span::styled(truncated.prefix.clone(), dir_style));
847 spans.push(Span::styled(
849 format!("{}[...]", truncated.sep),
850 ellipsis_style,
851 ));
852 let suffix_with_slash = if truncated.suffix.ends_with('/') {
854 truncated.suffix.clone()
855 } else {
856 format!("{}/", truncated.suffix)
857 };
858 spans.push(Span::styled(suffix_with_slash, dir_style));
859 } else {
860 let path_display = if truncated.suffix.ends_with('/') {
862 truncated.suffix.clone()
863 } else {
864 format!("{}/", truncated.suffix)
865 };
866 spans.push(Span::styled(path_display, dir_style));
867 }
868
869 spans.push(Span::styled(prompt.input.clone(), base_style));
871
872 let line = Line::from(spans);
873 let prompt_line = Paragraph::new(line).style(base_style);
874
875 frame.render_widget(prompt_line, area);
876
877 let prefix_width = str_width(&open_prompt);
881 let dir_display_width = if truncated.truncated {
882 let suffix_with_slash = if truncated.suffix.ends_with('/') {
883 &truncated.suffix
884 } else {
885 &truncated.suffix
887 };
888 str_width(&truncated.prefix) + str_width("/[...]") + str_width(suffix_with_slash) + 1
889 } else {
890 str_width(&truncated.suffix) + 1 };
892 let input_width_before_cursor = str_width(&prompt.input[..prompt.cursor_pos]);
893 let cursor_x = (prefix_width + dir_display_width + input_width_before_cursor) as u16;
894 if cursor_x < area.width {
895 frame.set_cursor_position((area.x + cursor_x, area.y));
896 }
897 }
898
899 fn render_element(
902 element: &StatusBarElement,
903 ctx: &mut StatusBarContext<'_>,
904 ) -> Option<RenderedElement> {
905 if ctx.is_synthetic_placeholder
910 && matches!(
911 element,
912 StatusBarElement::Filename
913 | StatusBarElement::Cursor
914 | StatusBarElement::CursorCompact
915 | StatusBarElement::CursorCount
916 | StatusBarElement::Diagnostics
917 | StatusBarElement::LineEnding
918 | StatusBarElement::Encoding
919 | StatusBarElement::Language
920 )
921 {
922 return None;
923 }
924 match element {
925 StatusBarElement::Filename => {
926 let modified = if ctx.state.buffer.is_modified() {
927 " [+]"
928 } else {
929 ""
930 };
931 let read_only_indicator = if ctx.read_only { " [RO]" } else { "" };
932 let remote_disconnected = ctx
933 .remote_connection
934 .map(|conn| conn.contains("(Disconnected)"))
935 .unwrap_or(false);
936 let remote_prefix = if ctx.remote_indicator_on_bar {
943 String::new()
944 } else {
945 ctx.remote_connection
946 .map(|conn| {
947 if conn.starts_with("Container:") {
948 format!("[{}] ", conn)
949 } else {
950 format!("{SSH_PREFIX}{conn}{SSH_PREFIX_TERMINATOR}")
951 }
952 })
953 .unwrap_or_default()
954 };
955 let session_prefix = ctx
956 .session_name
957 .map(|name| format!("[{}] ", name))
958 .unwrap_or_default();
959 let display_name = ctx.display_name;
960 let text = format!(
961 "{session_prefix}{remote_prefix}{display_name}{modified}{read_only_indicator}"
962 );
963 let kind = if remote_disconnected {
964 ElementKind::RemoteDisconnected
965 } else {
966 ElementKind::Normal
967 };
968 Some(RenderedElement {
969 text,
970 kind,
971 token_key: None,
972 })
973 }
974 StatusBarElement::ReadOnly => {
975 if !ctx.read_only {
982 return None;
983 }
984 Some(RenderedElement {
985 text: "[RO]".to_string(),
986 kind: ElementKind::ReadOnly,
987 token_key: None,
988 })
989 }
990 StatusBarElement::Cursor => {
991 if !ctx.state.show_cursors {
992 return None;
993 }
994 let cursor = *ctx.cursors.primary();
995 let line_count = ctx.state.buffer.line_count();
996 let text = if let Some(lc) = line_count {
997 let line = ctx.state.primary_cursor_line_number.value();
998 let col = cursor_column(&mut ctx.state.buffer, cursor.position);
999 format_cursor_position(line + 1, col + 1, lc)
1000 } else {
1001 format!("Byte {}", cursor.position)
1002 };
1003 Some(RenderedElement {
1004 text,
1005 kind: ElementKind::Normal,
1006 token_key: None,
1007 })
1008 }
1009 StatusBarElement::CursorCompact => {
1010 if !ctx.state.show_cursors {
1011 return None;
1012 }
1013 let cursor = *ctx.cursors.primary();
1014 let line_count = ctx.state.buffer.line_count();
1015 let text = if let Some(lc) = line_count {
1016 let line = ctx.state.primary_cursor_line_number.value();
1017 let col = cursor_column(&mut ctx.state.buffer, cursor.position);
1018 format_cursor_position_compact(line + 1, col + 1, lc)
1019 } else {
1020 format!("{}", cursor.position)
1021 };
1022 Some(RenderedElement {
1023 text,
1024 kind: ElementKind::Normal,
1025 token_key: None,
1026 })
1027 }
1028 StatusBarElement::Diagnostics => {
1029 let diagnostics = ctx.state.overlays.all();
1030 let mut error_count = 0usize;
1031 let mut warning_count = 0usize;
1032 let mut info_count = 0usize;
1033 let diagnostic_ns = crate::services::lsp::diagnostics::lsp_diagnostic_namespace();
1034 for overlay in diagnostics {
1035 if overlay.namespace.as_ref() == Some(&diagnostic_ns) {
1036 match overlay.priority {
1037 100 => error_count += 1,
1038 50 => warning_count += 1,
1039 _ => info_count += 1,
1040 }
1041 }
1042 }
1043 if error_count + warning_count + info_count == 0 {
1044 return None;
1045 }
1046 let mut parts = Vec::new();
1047 if error_count > 0 {
1048 parts.push(format!("E:{}", error_count));
1049 }
1050 if warning_count > 0 {
1051 parts.push(format!("W:{}", warning_count));
1052 }
1053 if info_count > 0 {
1054 parts.push(format!("I:{}", info_count));
1055 }
1056 Some(RenderedElement {
1057 text: parts.join(" "),
1058 kind: ElementKind::Normal,
1059 token_key: None,
1060 })
1061 }
1062 StatusBarElement::CursorCount => {
1063 if ctx.cursors.count() <= 1 {
1064 return None;
1065 }
1066 Some(RenderedElement {
1067 text: t!("status.cursors", count = ctx.cursors.count()).to_string(),
1068 kind: ElementKind::Normal,
1069 token_key: None,
1070 })
1071 }
1072 StatusBarElement::Messages => {
1073 let mut parts: Vec<&str> = Vec::new();
1074 if let Some(msg) = ctx.status_message {
1075 if !msg.is_empty() {
1076 parts.push(msg);
1077 }
1078 }
1079 if let Some(msg) = ctx.plugin_status_message {
1080 if !msg.is_empty() {
1081 parts.push(msg);
1082 }
1083 }
1084 if parts.is_empty() {
1085 return None;
1086 }
1087 Some(RenderedElement {
1088 text: parts.join(" | "),
1089 kind: ElementKind::Messages,
1090 token_key: None,
1091 })
1092 }
1093 StatusBarElement::Chord => {
1094 if ctx.chord_state.is_empty() {
1095 return None;
1096 }
1097 let chord_str = ctx
1098 .chord_state
1099 .iter()
1100 .map(|(code, modifiers)| {
1101 crate::input::keybindings::format_keybinding(code, modifiers)
1102 })
1103 .collect::<Vec<_>>()
1104 .join(" ");
1105 Some(RenderedElement {
1106 text: format!("[{}]", chord_str),
1107 kind: ElementKind::Normal,
1108 token_key: None,
1109 })
1110 }
1111 StatusBarElement::LineEnding => Some(RenderedElement {
1112 text: ctx.state.buffer.line_ending().display_name().to_string(),
1113 kind: ElementKind::LineEnding,
1114 token_key: None,
1115 }),
1116 StatusBarElement::Encoding => Some(RenderedElement {
1117 text: ctx.state.buffer.encoding().display_name().to_string(),
1118 kind: ElementKind::Encoding,
1119 token_key: None,
1120 }),
1121 StatusBarElement::Language => {
1122 let text = if ctx.state.language == "text"
1123 && ctx.state.display_name != "Text"
1124 && ctx.state.display_name != "Plain Text"
1125 && ctx.state.display_name != "text"
1126 {
1127 format!("{} [syntax only]", &ctx.state.display_name)
1128 } else {
1129 ctx.state.display_name.to_string()
1130 };
1131 Some(RenderedElement {
1132 text,
1133 kind: ElementKind::Language,
1134 token_key: None,
1135 })
1136 }
1137 StatusBarElement::Lsp => {
1138 if ctx.lsp_status.is_empty() {
1139 return None;
1140 }
1141 Some(RenderedElement {
1142 text: ctx.lsp_status.to_string(),
1143 kind: ElementKind::Lsp,
1144 token_key: None,
1145 })
1146 }
1147 StatusBarElement::Warnings => {
1148 if ctx.general_warning_count == 0 {
1149 return None;
1150 }
1151 Some(RenderedElement {
1152 text: format!("[\u{26a0} {}]", ctx.general_warning_count),
1153 kind: ElementKind::WarningBadge,
1154 token_key: None,
1155 })
1156 }
1157 StatusBarElement::Update => {
1158 let version = ctx.update_available?;
1159 Some(RenderedElement {
1160 text: t!("status.update_available", version = version).to_string(),
1161 kind: ElementKind::Update,
1162 token_key: None,
1163 })
1164 }
1165 StatusBarElement::Palette => {
1166 let shortcut = ctx
1167 .keybindings
1168 .get_keybinding_for_action(
1169 &crate::input::keybindings::Action::QuickOpen,
1170 crate::input::keybindings::KeyContext::Global,
1171 )
1172 .unwrap_or_else(|| "?".to_string());
1173 Some(RenderedElement {
1174 text: t!("status.palette", shortcut = shortcut).to_string(),
1175 kind: ElementKind::Palette,
1176 token_key: None,
1177 })
1178 }
1179 StatusBarElement::Clock => {
1180 let now = chrono::Local::now();
1181 let text = format!("{:02}:{:02}", now.hour(), now.minute());
1182 Some(RenderedElement {
1183 text,
1184 kind: ElementKind::Clock,
1185 token_key: None,
1186 })
1187 }
1188 StatusBarElement::RemoteIndicator => {
1189 let (text, state) = if let Some(over) = ctx.remote_state_override {
1199 (over.label(), over.state())
1200 } else if let Some(err) = ctx.remote_reconnect_error {
1201 (
1207 format!("Reconnect failed: {err}"),
1208 RemoteIndicatorState::FailedAttach,
1209 )
1210 } else {
1211 match ctx.remote_connection {
1212 None => ("Local".to_string(), RemoteIndicatorState::Local),
1213 Some(conn) if conn.contains("(Disconnected)") => {
1214 (conn.to_string(), RemoteIndicatorState::Disconnected)
1215 }
1216 Some(conn) => (conn.to_string(), RemoteIndicatorState::Connected),
1217 }
1218 };
1219 Some(RenderedElement {
1220 text,
1221 kind: ElementKind::RemoteIndicator(state),
1222 token_key: None,
1223 })
1224 }
1225 StatusBarElement::WorkspaceTrust => {
1226 use crate::services::workspace_trust::TrustLevel;
1231 let level = ctx.workspace_trust_level;
1232 let text = match level {
1233 TrustLevel::Trusted => t!("statusbar.trust.trusted"),
1234 TrustLevel::Restricted => t!("statusbar.trust.restricted"),
1235 TrustLevel::Blocked => t!("statusbar.trust.blocked"),
1236 }
1237 .to_string();
1238 Some(RenderedElement {
1239 text,
1240 kind: ElementKind::WorkspaceTrust(level),
1241 token_key: None,
1242 })
1243 }
1244 StatusBarElement::CustomToken(key) => {
1245 if let Some(value) = ctx.dynamic_status_bar_elements.get(key) {
1246 Some(RenderedElement {
1247 text: value.clone(),
1248 kind: ElementKind::Custom,
1249 token_key: Some(key.clone()),
1250 })
1251 } else {
1252 None }
1254 }
1255 }
1256 }
1257
1258 fn element_style(
1262 kind: ElementKind,
1263 theme: &crate::view::theme::Theme,
1264 is_hovering: bool,
1265 _warning_level: WarningLevel,
1266 lsp_state: LspIndicatorState,
1267 ) -> Style {
1268 match kind {
1269 ElementKind::Normal | ElementKind::Messages | ElementKind::Clock => Style::default()
1270 .fg(theme.status_bar_fg)
1271 .bg(theme.status_bar_bg),
1272 ElementKind::RemoteDisconnected => Style::default()
1273 .fg(theme.status_error_indicator_fg)
1274 .bg(theme.status_error_indicator_bg),
1275 ElementKind::LineEnding => {
1276 let (fg, bg) = if is_hovering {
1277 (theme.menu_hover_fg, theme.menu_hover_bg)
1278 } else {
1279 (theme.status_bar_fg, theme.status_bar_bg)
1280 };
1281 let mut style = Style::default().fg(fg).bg(bg);
1282 if is_hovering {
1283 style = style.add_modifier(Modifier::UNDERLINED);
1284 }
1285 style
1286 }
1287 ElementKind::Encoding => {
1288 let (fg, bg) = if is_hovering {
1289 (theme.menu_hover_fg, theme.menu_hover_bg)
1290 } else {
1291 (theme.status_bar_fg, theme.status_bar_bg)
1292 };
1293 let mut style = Style::default().fg(fg).bg(bg);
1294 if is_hovering {
1295 style = style.add_modifier(Modifier::UNDERLINED);
1296 }
1297 style
1298 }
1299 ElementKind::Language => {
1300 let (fg, bg) = if is_hovering {
1301 (theme.menu_hover_fg, theme.menu_hover_bg)
1302 } else {
1303 (theme.status_bar_fg, theme.status_bar_bg)
1304 };
1305 let mut style = Style::default().fg(fg).bg(bg);
1306 if is_hovering {
1307 style = style.add_modifier(Modifier::UNDERLINED);
1308 }
1309 style
1310 }
1311 ElementKind::ReadOnly => {
1315 let mut style = Style::default()
1316 .fg(theme.status_bar_fg)
1317 .bg(theme.status_bar_bg);
1318 if is_hovering {
1319 style = style.add_modifier(Modifier::UNDERLINED);
1320 }
1321 style
1322 }
1323 ElementKind::Lsp => {
1324 let (fg, bg) = match lsp_state {
1335 LspIndicatorState::Error => {
1336 (theme.diagnostic_error_fg, theme.diagnostic_error_bg)
1337 }
1338 LspIndicatorState::Off => (
1339 theme.status_lsp_actionable_fg,
1340 theme.status_lsp_actionable_bg,
1341 ),
1342 LspIndicatorState::On => (theme.status_lsp_on_fg, theme.status_lsp_on_bg),
1343 LspIndicatorState::OffDismissed => (theme.status_bar_fg, theme.status_bar_bg),
1344 LspIndicatorState::None => (theme.status_bar_fg, theme.status_bar_bg),
1345 };
1346 let mut style = Style::default().fg(fg).bg(bg);
1347 if is_hovering && lsp_state != LspIndicatorState::None {
1352 style = style.add_modifier(Modifier::UNDERLINED);
1353 }
1354 style
1355 }
1356 ElementKind::WarningBadge => {
1357 let (fg, bg) = if is_hovering {
1358 (
1359 theme.status_warning_indicator_hover_fg,
1360 theme.status_warning_indicator_hover_bg,
1361 )
1362 } else {
1363 (
1364 theme.status_warning_indicator_fg,
1365 theme.status_warning_indicator_bg,
1366 )
1367 };
1368 let mut style = Style::default().fg(fg).bg(bg);
1369 if is_hovering {
1370 style = style.add_modifier(Modifier::UNDERLINED);
1371 }
1372 style
1373 }
1374 ElementKind::Update => Style::default()
1375 .fg(theme.menu_highlight_fg)
1376 .bg(theme.menu_dropdown_bg),
1377 ElementKind::Palette => Style::default()
1382 .fg(theme.status_palette_fg)
1383 .bg(theme.status_palette_bg),
1384 ElementKind::Custom => Style::default()
1385 .fg(theme.status_bar_fg)
1386 .bg(theme.status_bar_bg),
1387 ElementKind::RemoteIndicator(state) => {
1388 let (fg, bg) = match state {
1389 RemoteIndicatorState::Connecting | RemoteIndicatorState::Connected => {
1395 (theme.help_indicator_fg, theme.help_indicator_bg)
1396 }
1397 RemoteIndicatorState::FailedAttach | RemoteIndicatorState::Disconnected => (
1401 theme.status_error_indicator_fg,
1402 theme.status_error_indicator_bg,
1403 ),
1404 RemoteIndicatorState::Local => (theme.status_bar_fg, theme.status_bar_bg),
1406 };
1407 let mut style = Style::default().fg(fg).bg(bg);
1408 if is_hovering {
1409 style = style.add_modifier(Modifier::UNDERLINED);
1410 }
1411 style
1412 }
1413 ElementKind::WorkspaceTrust(level) => {
1414 use crate::services::workspace_trust::TrustLevel;
1415 let (fg, bg) = match level {
1416 TrustLevel::Restricted | TrustLevel::Blocked => (
1419 theme.status_warning_indicator_fg,
1420 theme.status_warning_indicator_bg,
1421 ),
1422 TrustLevel::Trusted => (theme.status_bar_fg, theme.status_bar_bg),
1424 };
1425 let mut style = Style::default().fg(fg).bg(bg);
1426 if is_hovering {
1427 style = style.add_modifier(Modifier::UNDERLINED);
1428 }
1429 style
1430 }
1431 }
1432 }
1433
1434 fn element_keys(
1438 kind: ElementKind,
1439 lsp_state: LspIndicatorState,
1440 ) -> (&'static str, &'static str) {
1441 match kind {
1442 ElementKind::Normal
1443 | ElementKind::Messages
1444 | ElementKind::Clock
1445 | ElementKind::Custom
1446 | ElementKind::LineEnding
1447 | ElementKind::Encoding
1448 | ElementKind::ReadOnly
1449 | ElementKind::Language => ("ui.status_bar_fg", "ui.status_bar_bg"),
1450 ElementKind::RemoteDisconnected => (
1451 "ui.status_error_indicator_fg",
1452 "ui.status_error_indicator_bg",
1453 ),
1454 ElementKind::Lsp => match lsp_state {
1455 LspIndicatorState::Error => ("diagnostic.error_fg", "diagnostic.error_bg"),
1456 LspIndicatorState::Off => {
1457 ("ui.status_lsp_actionable_fg", "ui.status_lsp_actionable_bg")
1458 }
1459 LspIndicatorState::On => ("ui.status_lsp_on_fg", "ui.status_lsp_on_bg"),
1460 LspIndicatorState::OffDismissed | LspIndicatorState::None => {
1461 ("ui.status_bar_fg", "ui.status_bar_bg")
1462 }
1463 },
1464 ElementKind::WarningBadge => (
1465 "ui.status_warning_indicator_fg",
1466 "ui.status_warning_indicator_bg",
1467 ),
1468 ElementKind::Update => ("ui.menu_highlight_fg", "ui.menu_dropdown_bg"),
1469 ElementKind::Palette => ("ui.status_palette_fg", "ui.status_palette_bg"),
1470 ElementKind::RemoteIndicator(state) => match state {
1471 RemoteIndicatorState::Connecting | RemoteIndicatorState::Connected => {
1472 ("ui.help_indicator_fg", "ui.help_indicator_bg")
1473 }
1474 RemoteIndicatorState::FailedAttach | RemoteIndicatorState::Disconnected => (
1475 "ui.status_error_indicator_fg",
1476 "ui.status_error_indicator_bg",
1477 ),
1478 RemoteIndicatorState::Local => ("ui.status_bar_fg", "ui.status_bar_bg"),
1479 },
1480 ElementKind::WorkspaceTrust(level) => {
1481 use crate::services::workspace_trust::TrustLevel;
1482 match level {
1483 TrustLevel::Restricted | TrustLevel::Blocked => (
1484 "ui.status_warning_indicator_fg",
1485 "ui.status_warning_indicator_bg",
1486 ),
1487 TrustLevel::Trusted => ("ui.status_bar_fg", "ui.status_bar_bg"),
1488 }
1489 }
1490 }
1491 }
1492
1493 fn clickable_for_kind(kind: ElementKind) -> Option<StatusBarClickable> {
1499 match kind {
1500 ElementKind::LineEnding => Some(StatusBarClickable::LineEnding),
1501 ElementKind::Encoding => Some(StatusBarClickable::Encoding),
1502 ElementKind::Language => Some(StatusBarClickable::Language),
1503 ElementKind::Lsp => Some(StatusBarClickable::Lsp),
1504 ElementKind::WarningBadge => Some(StatusBarClickable::Warnings),
1505 ElementKind::Messages => Some(StatusBarClickable::Messages),
1506 ElementKind::RemoteIndicator(_) => Some(StatusBarClickable::RemoteIndicator),
1507 ElementKind::WorkspaceTrust(_) => Some(StatusBarClickable::WorkspaceTrust),
1508 ElementKind::ReadOnly => Some(StatusBarClickable::ReadOnly),
1509 ElementKind::Normal
1510 | ElementKind::RemoteDisconnected
1511 | ElementKind::Update
1512 | ElementKind::Palette
1513 | ElementKind::Clock
1514 | ElementKind::Custom => None,
1515 }
1516 }
1517
1518 fn update_layout_for_element(
1523 layout: &mut StatusBarLayout,
1524 kind: ElementKind,
1525 token_key: Option<&str>,
1526 row: u16,
1527 start_col: u16,
1528 end_col: u16,
1529 ) {
1530 if let Some(id) = Self::clickable_for_kind(kind) {
1531 layout.clickable.push((id, row, start_col, end_col));
1532 }
1533 if kind == ElementKind::Custom {
1534 if let Some(key) = token_key {
1535 layout
1536 .plugin_token_areas
1537 .insert(key.to_string(), (row, start_col, end_col));
1538 }
1539 }
1540 }
1541
1542 fn element_spans(
1547 rendered: &RenderedElement,
1548 theme: &crate::view::theme::Theme,
1549 hovered: Option<StatusBarClickable>,
1550 warning_level: WarningLevel,
1551 lsp_state: LspIndicatorState,
1552 ) -> (Vec<Span<'static>>, usize) {
1553 let is_hovering =
1554 Self::clickable_for_kind(rendered.kind).is_some_and(|c| Some(c) == hovered);
1555 let base_style = Style::default()
1556 .fg(theme.status_bar_fg)
1557 .bg(theme.status_bar_bg);
1558 let width = str_width(&rendered.text) + 2;
1563
1564 if rendered.kind == ElementKind::RemoteDisconnected && rendered.text.starts_with(SSH_PREFIX)
1565 {
1566 let error_style = Style::default()
1567 .fg(theme.status_error_indicator_fg)
1568 .bg(theme.status_error_indicator_bg);
1569 if let Some(term_off) = rendered.text.find(SSH_PREFIX_TERMINATOR) {
1570 let split_at = term_off + SSH_PREFIX_TERMINATOR.len();
1571 let prefix = rendered.text[..split_at].to_string();
1572 let rest = rendered.text[split_at..].to_string();
1573 return (
1574 vec![
1575 Span::styled(" ", error_style),
1576 Span::styled(prefix, error_style),
1577 Span::styled(rest, base_style),
1578 Span::styled(" ", base_style),
1579 ],
1580 width,
1581 );
1582 }
1583 return (
1584 vec![
1585 Span::styled(" ", error_style),
1586 Span::styled(rendered.text.clone(), error_style),
1587 Span::styled(" ", error_style),
1588 ],
1589 width,
1590 );
1591 }
1592
1593 let style =
1594 Self::element_style(rendered.kind, theme, is_hovering, warning_level, lsp_state);
1595 let mut spans = vec![Span::styled(" ", style)];
1596 if rendered.kind == ElementKind::Clock {
1597 spans.push(Span::styled(rendered.text[..2].to_string(), style));
1599 spans.push(Span::styled(
1600 ":".to_string(),
1601 style.add_modifier(Modifier::SLOW_BLINK),
1602 ));
1603 spans.push(Span::styled(rendered.text[3..].to_string(), style));
1604 } else {
1605 spans.push(Span::styled(rendered.text.clone(), style));
1606 }
1607 spans.push(Span::styled(" ", style));
1608 (spans, width)
1609 }
1610
1611 fn render_side(
1618 config_side: &[StatusBarElement],
1619 ctx: &mut StatusBarContext<'_>,
1620 ) -> Vec<(Vec<Span<'static>>, usize, ElementKind, Option<String>)> {
1621 let rendered: Vec<RenderedElement> = config_side
1622 .iter()
1623 .filter_map(|elem| Self::render_element(elem, ctx))
1624 .filter(|e| !e.text.is_empty())
1625 .collect();
1626
1627 let theme = ctx.theme;
1628 let hovered = ctx.hovered;
1629 let warning_level = ctx.warning_level;
1630 let lsp_state = ctx.lsp_indicator_state;
1631 rendered
1632 .into_iter()
1633 .map(|r| {
1634 let kind = r.kind;
1635 let token_key = r.token_key.clone();
1636 let (spans, width) =
1637 Self::element_spans(&r, theme, hovered, warning_level, lsp_state);
1638 (spans, width, kind, token_key)
1639 })
1640 .collect()
1641 }
1642
1643 fn render_status(
1645 frame: &mut Frame,
1646 area: Rect,
1647 ctx: &mut StatusBarContext<'_>,
1648 config: &StatusBarConfig,
1649 mut rec: Option<&mut CellThemeRecorder>,
1650 draw: bool,
1651 ) -> StatusBarLayout {
1652 let mut layout = StatusBarLayout::default();
1653 let base_style = Style::default()
1654 .fg(ctx.theme.status_bar_fg)
1655 .bg(ctx.theme.status_bar_bg);
1656 let available_width = area.width as usize;
1657
1658 if available_width == 0 || area.height == 0 {
1659 return layout;
1660 }
1661
1662 let lsp_state = ctx.lsp_indicator_state;
1666 if let Some(r) = rec.as_deref_mut() {
1667 r.run(
1668 area.x,
1669 area.y,
1670 area.width,
1671 Some("ui.status_bar_fg"),
1672 Some("ui.status_bar_bg"),
1673 "Status Bar",
1674 );
1675 }
1676
1677 ctx.remote_indicator_on_bar = config
1682 .left
1683 .iter()
1684 .chain(config.right.iter())
1685 .any(|e| matches!(e, StatusBarElement::RemoteIndicator));
1686
1687 let left_items = Self::render_side(&config.left, ctx);
1688 let mut right_items = Self::render_side(&config.right, ctx);
1689
1690 let separator: &str = &config.separator;
1693 let separator_width = str_width(separator);
1694 let separator_style = Style::default()
1697 .fg(ctx.theme.status_separator_fg)
1698 .bg(ctx.theme.status_separator_bg);
1699
1700 let total_right_width: usize = right_items.iter().map(|(_, w, _, _)| *w).sum::<usize>()
1709 + separator_width * right_items.len().saturating_sub(1);
1710 let left_min_target = available_width
1711 .saturating_mul(2)
1712 .saturating_div(5) .min(40); let right_budget = available_width.saturating_sub(left_min_target + 1);
1715 if total_right_width > right_budget && right_items.len() > 1 {
1716 let mut current = total_right_width;
1717 while current > right_budget && right_items.len() > 1 {
1718 if let Some(dropped) = right_items.pop() {
1719 current = current.saturating_sub(dropped.1);
1720 current = current.saturating_sub(separator_width);
1723 } else {
1724 break;
1725 }
1726 }
1727 }
1728
1729 let right_width: usize = right_items.iter().map(|(_, w, _, _)| *w).sum::<usize>()
1730 + separator_width * right_items.len().saturating_sub(1);
1731
1732 let narrow = available_width < 15;
1733 let left_max_width = if narrow {
1734 available_width
1735 } else if available_width > right_width + 1 {
1736 available_width - right_width - 1
1737 } else {
1738 1
1739 };
1740
1741 let mut spans: Vec<Span<'static>> = Vec::new();
1745 let mut used_left: usize = 0;
1746
1747 for (idx, (item_spans, width, kind, token_key)) in left_items.into_iter().enumerate() {
1748 let sep_width = if idx == 0 { 0 } else { separator_width };
1749 if used_left + sep_width >= left_max_width {
1750 break;
1751 }
1752 if sep_width > 0 {
1753 if let Some(r) = rec.as_deref_mut() {
1754 r.run(
1755 area.x + used_left as u16,
1756 area.y,
1757 sep_width as u16,
1758 Some("ui.status_separator_fg"),
1759 Some("ui.status_separator_bg"),
1760 "Status Bar",
1761 );
1762 }
1763 spans.push(Span::styled(separator.to_string(), separator_style));
1764 used_left += sep_width;
1765 }
1766
1767 let remaining = left_max_width - used_left;
1768 let start_col = used_left;
1769
1770 if width <= remaining {
1771 let seg_text = (!draw).then(|| {
1775 item_spans
1776 .iter()
1777 .map(|s| s.content.as_ref())
1778 .collect::<String>()
1779 });
1780 spans.extend(item_spans);
1781 used_left += width;
1782
1783 if let Some(r) = rec.as_deref_mut() {
1784 let (fg, bg) = Self::element_keys(kind, lsp_state);
1785 r.run(
1786 area.x + start_col as u16,
1787 area.y,
1788 width as u16,
1789 Some(fg),
1790 Some(bg),
1791 "Status Bar",
1792 );
1793 }
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 + 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: width as u16,
1810 side: "left",
1811 });
1812 }
1813 } else {
1814 let group_text: String = item_spans.iter().map(|s| s.content.as_ref()).collect();
1818 let truncated = truncate_to_width(&group_text, remaining);
1819 let truncated_width = str_width(&truncated);
1820 let overflow_is_hovering =
1821 Self::clickable_for_kind(kind).is_some_and(|c| Some(c) == ctx.hovered);
1822 let overflow_style = Self::element_style(
1823 kind,
1824 ctx.theme,
1825 overflow_is_hovering,
1826 ctx.warning_level,
1827 ctx.lsp_indicator_state,
1828 );
1829 let seg_text = (!draw).then(|| truncated.clone());
1830 spans.push(Span::styled(truncated, overflow_style));
1831
1832 if let Some(r) = rec.as_deref_mut() {
1833 let (fg, bg) = Self::element_keys(kind, lsp_state);
1834 r.run(
1835 area.x + start_col as u16,
1836 area.y,
1837 truncated_width as u16,
1838 Some(fg),
1839 Some(bg),
1840 "Status Bar",
1841 );
1842 }
1843 used_left += truncated_width;
1844
1845 Self::update_layout_for_element(
1846 &mut layout,
1847 kind,
1848 token_key.as_deref(),
1849 area.y,
1850 area.x + start_col as u16,
1851 area.x + (start_col + truncated_width) as u16,
1852 );
1853 if let Some(text) = seg_text {
1854 layout.segments.push(StatusSegmentInfo {
1855 name: element_kind_name(kind),
1856 key: token_key.clone(),
1857 text,
1858 x: area.x + start_col as u16,
1859 w: truncated_width as u16,
1860 side: "left",
1861 });
1862 }
1863 break;
1864 }
1865 }
1866
1867 if narrow {
1868 if used_left < available_width {
1869 spans.push(Span::styled(
1870 " ".repeat(available_width - used_left),
1871 base_style,
1872 ));
1873 }
1874 if draw {
1875 frame.render_widget(Paragraph::new(Line::from(spans)), area);
1876 }
1877 return layout;
1878 }
1879
1880 let mut col_offset = used_left;
1881 if col_offset + right_width < available_width {
1882 let padding = available_width - col_offset - right_width;
1883 spans.push(Span::styled(" ".repeat(padding), base_style));
1884 col_offset = available_width - right_width;
1885 } else if col_offset < available_width {
1886 spans.push(Span::styled(" ", base_style));
1887 col_offset += 1;
1888 }
1889
1890 let mut current_col = area.x + col_offset as u16;
1891 for (idx, (item_spans, width, kind, token_key)) in right_items.into_iter().enumerate() {
1892 if idx > 0 && separator_width > 0 {
1893 if let Some(r) = rec.as_deref_mut() {
1894 r.run(
1895 current_col,
1896 area.y,
1897 separator_width as u16,
1898 Some("ui.status_separator_fg"),
1899 Some("ui.status_separator_bg"),
1900 "Status Bar",
1901 );
1902 }
1903 spans.push(Span::styled(separator.to_string(), separator_style));
1904 current_col += separator_width as u16;
1905 }
1906 if let Some(r) = rec.as_deref_mut() {
1907 let (fg, bg) = Self::element_keys(kind, lsp_state);
1908 r.run(
1909 current_col,
1910 area.y,
1911 width as u16,
1912 Some(fg),
1913 Some(bg),
1914 "Status Bar",
1915 );
1916 }
1917 Self::update_layout_for_element(
1918 &mut layout,
1919 kind,
1920 token_key.as_deref(),
1921 area.y,
1922 current_col,
1923 current_col + width as u16,
1924 );
1925 if !draw {
1926 let seg_text: String = item_spans.iter().map(|s| s.content.as_ref()).collect();
1928 layout.segments.push(StatusSegmentInfo {
1929 name: element_kind_name(kind),
1930 key: token_key.clone(),
1931 text: seg_text,
1932 x: current_col,
1933 w: width as u16,
1934 side: "right",
1935 });
1936 }
1937 spans.extend(item_spans);
1938 current_col += width as u16;
1939 }
1940
1941 if draw {
1942 frame.render_widget(Paragraph::new(Line::from(spans)), area);
1943 }
1944 layout
1945 }
1946
1947 #[allow(clippy::too_many_arguments)]
1958 pub fn render_search_options(
1959 frame: &mut Frame,
1960 area: Rect,
1961 case_sensitive: bool,
1962 whole_word: bool,
1963 use_regex: bool,
1964 confirm_each: Option<bool>, theme: &crate::view::theme::Theme,
1966 keybindings: &crate::input::keybindings::KeybindingResolver,
1967 hover: SearchOptionsHover,
1968 ) -> SearchOptionsLayout {
1969 use crate::primitives::display_width::str_width;
1970
1971 let mut layout = SearchOptionsLayout {
1972 row: area.y,
1973 ..Default::default()
1974 };
1975
1976 let base_style = Style::default()
1978 .fg(theme.menu_dropdown_fg)
1979 .bg(theme.menu_dropdown_bg);
1980
1981 let hover_style = Style::default()
1983 .fg(theme.menu_hover_fg)
1984 .bg(theme.menu_hover_bg);
1985
1986 let get_shortcut = |action: &crate::input::keybindings::Action| -> Option<String> {
1990 keybindings
1991 .get_keybinding_for_action(
1992 action,
1993 crate::input::keybindings::KeyContext::SearchPrompt,
1994 )
1995 .or_else(|| {
1996 keybindings.get_keybinding_for_action(
1997 action,
1998 crate::input::keybindings::KeyContext::Prompt,
1999 )
2000 })
2001 .or_else(|| {
2002 keybindings.get_keybinding_for_action(
2003 action,
2004 crate::input::keybindings::KeyContext::Global,
2005 )
2006 })
2007 };
2008
2009 let case_shortcut =
2011 get_shortcut(&crate::input::keybindings::Action::ToggleSearchCaseSensitive);
2012 let word_shortcut = get_shortcut(&crate::input::keybindings::Action::ToggleSearchWholeWord);
2013 let regex_shortcut = get_shortcut(&crate::input::keybindings::Action::ToggleSearchRegex);
2014
2015 let case_checkbox = if case_sensitive { "[x]" } else { "[ ]" };
2017 let word_checkbox = if whole_word { "[x]" } else { "[ ]" };
2018 let regex_checkbox = if use_regex { "[x]" } else { "[ ]" };
2019
2020 let active_style = Style::default()
2031 .fg(theme.menu_active_fg)
2032 .bg(theme.menu_active_bg);
2033
2034 let shortcut_style = Style::default()
2036 .fg(theme.help_separator_fg)
2037 .bg(theme.menu_dropdown_bg);
2038
2039 let hover_shortcut_style = Style::default()
2041 .fg(theme.menu_hover_fg)
2042 .bg(theme.menu_hover_bg);
2043
2044 let mut spans = Vec::new();
2045 let mut current_col = area.x;
2046
2047 spans.push(Span::styled(" ", base_style));
2049 current_col += 1;
2050
2051 let get_checkbox_style = |is_hovered: bool, is_checked: bool| -> Style {
2053 if is_hovered {
2054 hover_style
2055 } else if is_checked {
2056 active_style
2057 } else {
2058 base_style
2059 }
2060 };
2061
2062 let case_hovered = hover == SearchOptionsHover::CaseSensitive;
2064 let case_start = current_col;
2065 let case_label = format!("{} {}", case_checkbox, t!("search.case_sensitive"));
2066 let case_shortcut_text = case_shortcut
2067 .as_ref()
2068 .map(|s| format!(" ({})", s))
2069 .unwrap_or_default();
2070 let case_full_width = str_width(&case_label) + str_width(&case_shortcut_text);
2071
2072 spans.push(Span::styled(
2073 case_label,
2074 get_checkbox_style(case_hovered, case_sensitive),
2075 ));
2076 if !case_shortcut_text.is_empty() {
2077 spans.push(Span::styled(
2078 case_shortcut_text,
2079 if case_hovered {
2080 hover_shortcut_style
2081 } else {
2082 shortcut_style
2083 },
2084 ));
2085 }
2086 current_col += case_full_width as u16;
2087 layout.case_sensitive = Some((case_start, current_col));
2088
2089 spans.push(Span::styled(" ", base_style));
2091 current_col += 3;
2092
2093 let word_hovered = hover == SearchOptionsHover::WholeWord;
2095 let word_start = current_col;
2096 let word_label = format!("{} {}", word_checkbox, t!("search.whole_word"));
2097 let word_shortcut_text = word_shortcut
2098 .as_ref()
2099 .map(|s| format!(" ({})", s))
2100 .unwrap_or_default();
2101 let word_full_width = str_width(&word_label) + str_width(&word_shortcut_text);
2102
2103 spans.push(Span::styled(
2104 word_label,
2105 get_checkbox_style(word_hovered, whole_word),
2106 ));
2107 if !word_shortcut_text.is_empty() {
2108 spans.push(Span::styled(
2109 word_shortcut_text,
2110 if word_hovered {
2111 hover_shortcut_style
2112 } else {
2113 shortcut_style
2114 },
2115 ));
2116 }
2117 current_col += word_full_width as u16;
2118 layout.whole_word = Some((word_start, current_col));
2119
2120 spans.push(Span::styled(" ", base_style));
2122 current_col += 3;
2123
2124 let regex_hovered = hover == SearchOptionsHover::Regex;
2126 let regex_start = current_col;
2127 let regex_label = format!("{} {}", regex_checkbox, t!("search.regex"));
2128 let regex_shortcut_text = regex_shortcut
2129 .as_ref()
2130 .map(|s| format!(" ({})", s))
2131 .unwrap_or_default();
2132 let regex_full_width = str_width(®ex_label) + str_width(®ex_shortcut_text);
2133
2134 spans.push(Span::styled(
2135 regex_label,
2136 get_checkbox_style(regex_hovered, use_regex),
2137 ));
2138 if !regex_shortcut_text.is_empty() {
2139 spans.push(Span::styled(
2140 regex_shortcut_text,
2141 if regex_hovered {
2142 hover_shortcut_style
2143 } else {
2144 shortcut_style
2145 },
2146 ));
2147 }
2148 current_col += regex_full_width as u16;
2149 layout.regex = Some((regex_start, current_col));
2150
2151 if use_regex && confirm_each.is_some() {
2153 let hint = " \u{2502} $1,$2,…";
2154 spans.push(Span::styled(hint, shortcut_style));
2155 current_col += str_width(hint) as u16;
2156 }
2157
2158 if let Some(confirm_value) = confirm_each {
2160 let confirm_shortcut =
2161 get_shortcut(&crate::input::keybindings::Action::ToggleSearchConfirmEach);
2162 let confirm_checkbox = if confirm_value { "[x]" } else { "[ ]" };
2163
2164 spans.push(Span::styled(" ", base_style));
2166 current_col += 3;
2167
2168 let confirm_hovered = hover == SearchOptionsHover::ConfirmEach;
2169 let confirm_start = current_col;
2170 let confirm_label = format!("{} {}", confirm_checkbox, t!("search.confirm_each"));
2171 let confirm_shortcut_text = confirm_shortcut
2172 .as_ref()
2173 .map(|s| format!(" ({})", s))
2174 .unwrap_or_default();
2175 let confirm_full_width = str_width(&confirm_label) + str_width(&confirm_shortcut_text);
2176
2177 spans.push(Span::styled(
2178 confirm_label,
2179 get_checkbox_style(confirm_hovered, confirm_value),
2180 ));
2181 if !confirm_shortcut_text.is_empty() {
2182 spans.push(Span::styled(
2183 confirm_shortcut_text,
2184 if confirm_hovered {
2185 hover_shortcut_style
2186 } else {
2187 shortcut_style
2188 },
2189 ));
2190 }
2191 current_col += confirm_full_width as u16;
2192 layout.confirm_each = Some((confirm_start, current_col));
2193 }
2194
2195 let current_width = (current_col - area.x) as usize;
2197 let available_width = area.width as usize;
2198 if current_width < available_width {
2199 spans.push(Span::styled(
2200 " ".repeat(available_width.saturating_sub(current_width)),
2201 base_style,
2202 ));
2203 }
2204
2205 let options_line = Paragraph::new(Line::from(spans));
2206 frame.render_widget(options_line, area);
2207
2208 layout
2209 }
2210}
2211
2212#[cfg(test)]
2213mod tests {
2214 use super::*;
2215 use std::path::PathBuf;
2216
2217 #[test]
2218 fn test_truncate_path_short_path() {
2219 let path = PathBuf::from("/home/user/project");
2220 let result = truncate_path(&path, 50);
2221
2222 assert!(!result.truncated);
2223 assert_eq!(result.suffix, "/home/user/project");
2224 assert!(result.prefix.is_empty());
2225 }
2226
2227 #[test]
2228 fn test_truncate_path_long_path() {
2229 let path = PathBuf::from(
2230 "/private/var/folders/p6/nlmq3k8146990kpkxl73mq340000gn/T/.tmpNYt4Fc/project_root",
2231 );
2232 let result = truncate_path(&path, 40);
2233
2234 assert!(result.truncated, "Path should be truncated");
2235 assert_eq!(result.prefix, "/private");
2236 assert!(
2237 result.suffix.contains("project_root"),
2238 "Suffix should contain project_root"
2239 );
2240 }
2241
2242 #[test]
2243 fn test_truncate_path_preserves_last_components() {
2244 let path = PathBuf::from("/a/b/c/d/e/f/g/h/i/j/project/src");
2245 let result = truncate_path(&path, 30);
2246
2247 assert!(result.truncated);
2248 assert!(
2250 result.suffix.contains("src"),
2251 "Should preserve last component 'src', got: {}",
2252 result.suffix
2253 );
2254 }
2255
2256 #[test]
2257 fn test_truncate_path_display_len() {
2258 let path = PathBuf::from("/private/var/folders/deep/nested/path/here");
2259 let result = truncate_path(&path, 30);
2260
2261 let display = result.to_string_plain();
2263 assert!(
2264 display.len() <= 35, "Display should be truncated to around 30 chars, got {} chars: {}",
2266 display.len(),
2267 display
2268 );
2269 }
2270
2271 #[test]
2272 fn test_truncate_path_root_only() {
2273 let path = PathBuf::from("/");
2274 let result = truncate_path(&path, 50);
2275
2276 assert!(!result.truncated);
2277 assert_eq!(result.suffix, "/");
2278 }
2279
2280 #[test]
2281 fn test_truncate_path_multibyte_single_component_does_not_panic() {
2282 let path = PathBuf::from("/ユーザーのプロジェクト名前/file");
2288 let result = truncate_path(&path, 5);
2289 let display = result.to_string_plain();
2290 assert!(display.is_char_boundary(display.len()));
2291 assert!(display.ends_with("..."));
2292 }
2293
2294 #[test]
2295 fn test_truncate_path_multibyte_last_component_does_not_panic() {
2296 let path = PathBuf::from("/a/ユーザーのプロジェクト名前");
2303 let result = truncate_path(&path, 13);
2304 let display = result.to_string_plain();
2305 assert!(display.is_char_boundary(display.len()));
2306 }
2307
2308 #[test]
2309 fn test_truncated_path_to_string_plain() {
2310 let truncated = TruncatedPath {
2311 prefix: "/home".to_string(),
2312 truncated: true,
2313 suffix: "/project/src".to_string(),
2314 sep: '/',
2315 };
2316
2317 assert_eq!(truncated.to_string_plain(), "/home/[...]/project/src");
2318 }
2319
2320 #[test]
2321 fn test_truncated_path_to_string_plain_no_truncation() {
2322 let truncated = TruncatedPath {
2323 prefix: String::new(),
2324 truncated: false,
2325 suffix: "/home/user/project".to_string(),
2326 sep: '/',
2327 };
2328
2329 assert_eq!(truncated.to_string_plain(), "/home/user/project");
2330 }
2331
2332 #[test]
2338 fn test_truncate_path_windows_backslashes() {
2339 let path = Path::new(r"C:\Users\me\projects\fresh\crates\editor\src\main.rs");
2340 let t = truncate_path(path, 34);
2341 assert!(t.truncated, "long backslash path should middle-truncate");
2342 assert_eq!(t.sep, '\\', "should re-join with backslashes");
2343 let shown = t.to_string_plain();
2344 assert!(
2345 shown.starts_with(r"C:\Users"),
2346 "keeps drive + first dir: {shown}"
2347 );
2348 assert!(
2349 shown.contains(r"\[...]\"),
2350 "uses a backslash ellipsis: {shown}"
2351 );
2352 assert!(shown.ends_with("main.rs"), "keeps the tail: {shown}");
2353 assert!(!shown.contains('/'), "no forward slashes leak in: {shown}");
2354 assert!(shown.len() <= 34, "respects max_len: {shown}");
2355 }
2356
2357 #[test]
2359 fn test_truncate_path_windows_short_unchanged() {
2360 let path = Path::new(r"C:\a\b");
2361 let t = truncate_path(path, 80);
2362 assert!(!t.truncated);
2363 assert_eq!(t.to_string_plain(), r"C:\a\b");
2364 }
2365
2366 #[test]
2367 fn test_remote_indicator_element_kind_equality() {
2368 assert_eq!(
2372 ElementKind::RemoteIndicator(RemoteIndicatorState::Local),
2373 ElementKind::RemoteIndicator(RemoteIndicatorState::Local)
2374 );
2375 let distinct = [
2376 RemoteIndicatorState::Local,
2377 RemoteIndicatorState::Connecting,
2378 RemoteIndicatorState::Connected,
2379 RemoteIndicatorState::FailedAttach,
2380 RemoteIndicatorState::Disconnected,
2381 ];
2382 for (i, a) in distinct.iter().enumerate() {
2383 for (j, b) in distinct.iter().enumerate() {
2384 if i == j {
2385 continue;
2386 }
2387 assert_ne!(
2388 ElementKind::RemoteIndicator(*a),
2389 ElementKind::RemoteIndicator(*b),
2390 "expected {:?} != {:?}",
2391 a,
2392 b
2393 );
2394 }
2395 }
2396 }
2397
2398 #[test]
2399 fn test_remote_indicator_state_default_is_local() {
2400 assert_eq!(RemoteIndicatorState::default(), RemoteIndicatorState::Local);
2403 }
2404
2405 #[test]
2406 fn test_remote_indicator_override_deserializes_kind_tags() {
2407 let cases: &[(&str, RemoteIndicatorOverride)] = &[
2411 (r#"{"kind":"local"}"#, RemoteIndicatorOverride::Local),
2412 (
2413 r#"{"kind":"connecting","label":"Building"}"#,
2414 RemoteIndicatorOverride::Connecting {
2415 label: Some("Building".into()),
2416 },
2417 ),
2418 (
2419 r#"{"kind":"connecting"}"#,
2420 RemoteIndicatorOverride::Connecting { label: None },
2421 ),
2422 (
2423 r#"{"kind":"connected","label":"Container:abc"}"#,
2424 RemoteIndicatorOverride::Connected {
2425 label: Some("Container:abc".into()),
2426 },
2427 ),
2428 (
2429 r#"{"kind":"failed_attach","error":"exit 1"}"#,
2430 RemoteIndicatorOverride::FailedAttach {
2431 error: Some("exit 1".into()),
2432 },
2433 ),
2434 (
2435 r#"{"kind":"disconnected","label":"Container:abc"}"#,
2436 RemoteIndicatorOverride::Disconnected {
2437 label: Some("Container:abc".into()),
2438 },
2439 ),
2440 ];
2441 for (json, expected) in cases {
2442 let parsed: RemoteIndicatorOverride = serde_json::from_str(json)
2443 .unwrap_or_else(|e| panic!("failed to parse {}: {}", json, e));
2444 assert_eq!(&parsed, expected, "wire shape mismatch for {}", json);
2445 }
2446 }
2447
2448 #[test]
2449 fn test_remote_indicator_override_labels() {
2450 let connecting = RemoteIndicatorOverride::Connecting { label: None };
2454 assert!(
2455 connecting.label().contains("Connecting"),
2456 "connecting default label should mention Connecting, got {:?}",
2457 connecting.label()
2458 );
2459
2460 let connecting_labeled = RemoteIndicatorOverride::Connecting {
2461 label: Some("Building".into()),
2462 };
2463 assert!(
2464 connecting_labeled.label().contains("Building"),
2465 "labeled connecting should include the label, got {:?}",
2466 connecting_labeled.label()
2467 );
2468
2469 let failed_bare = RemoteIndicatorOverride::FailedAttach { error: None };
2470 assert_eq!(failed_bare.label(), "Attach failed");
2471
2472 let failed_detail = RemoteIndicatorOverride::FailedAttach {
2473 error: Some("exit 1".into()),
2474 };
2475 assert!(
2476 failed_detail.label().contains("exit 1"),
2477 "failed with error should include the error, got {:?}",
2478 failed_detail.label()
2479 );
2480 }
2481
2482 #[test]
2483 fn test_palette_and_lsp_on_use_dedicated_theme_keys() {
2484 let theme = crate::view::theme::Theme::from_json(
2496 r#"{"name":"t","editor":{},"ui":{},"search":{},"diagnostic":{},"syntax":{}}"#,
2497 )
2498 .expect("minimal theme should parse");
2499
2500 assert_eq!(theme.status_palette_fg, theme.status_bar_fg);
2502 assert_eq!(theme.status_palette_bg, theme.status_bar_bg);
2503 assert_eq!(theme.status_lsp_on_fg, theme.status_bar_fg);
2504 assert_eq!(theme.status_lsp_on_bg, theme.status_bar_bg);
2505
2506 let palette_style = StatusBarRenderer::element_style(
2507 ElementKind::Palette,
2508 &theme,
2509 false,
2510 WarningLevel::None,
2511 LspIndicatorState::None,
2512 );
2513 assert_eq!(palette_style.fg, Some(theme.status_palette_fg));
2514 assert_eq!(palette_style.bg, Some(theme.status_palette_bg));
2515
2516 let lsp_on_style = StatusBarRenderer::element_style(
2517 ElementKind::Lsp,
2518 &theme,
2519 false,
2520 WarningLevel::None,
2521 LspIndicatorState::On,
2522 );
2523 assert_eq!(lsp_on_style.fg, Some(theme.status_lsp_on_fg));
2524 assert_eq!(lsp_on_style.bg, Some(theme.status_lsp_on_bg));
2525
2526 let lsp_off_style = StatusBarRenderer::element_style(
2529 ElementKind::Lsp,
2530 &theme,
2531 false,
2532 WarningLevel::None,
2533 LspIndicatorState::Off,
2534 );
2535 assert_eq!(lsp_off_style.fg, Some(theme.status_lsp_actionable_fg));
2536 assert_eq!(lsp_off_style.bg, Some(theme.status_lsp_actionable_bg));
2537
2538 let lsp_error_style = StatusBarRenderer::element_style(
2539 ElementKind::Lsp,
2540 &theme,
2541 false,
2542 WarningLevel::None,
2543 LspIndicatorState::Error,
2544 );
2545 assert_eq!(lsp_error_style.fg, Some(theme.diagnostic_error_fg));
2546 assert_eq!(lsp_error_style.bg, Some(theme.diagnostic_error_bg));
2547 }
2548
2549 #[test]
2550 fn test_status_palette_and_lsp_on_keys_override_independently() {
2551 let theme_json = r#"{
2557 "name":"t",
2558 "editor":{},
2559 "ui":{
2560 "status_bar_fg":"White",
2561 "status_bar_bg":"DarkGray",
2562 "status_palette_fg":"Black",
2563 "status_palette_bg":"Yellow",
2564 "status_lsp_on_fg":"Black",
2565 "status_lsp_on_bg":"Cyan"
2566 },
2567 "search":{},
2568 "diagnostic":{},
2569 "syntax":{}
2570 }"#;
2571 let theme = crate::view::theme::Theme::from_json(theme_json).expect("theme should parse");
2572 assert_ne!(theme.status_palette_fg, theme.status_bar_fg);
2573 assert_ne!(theme.status_palette_bg, theme.status_bar_bg);
2574 assert_ne!(theme.status_lsp_on_fg, theme.status_bar_fg);
2575 assert_ne!(theme.status_lsp_on_bg, theme.status_bar_bg);
2576 }
2577
2578 #[test]
2579 fn test_status_separator_keys_default_and_override() {
2580 let theme = crate::view::theme::Theme::from_json(
2584 r#"{"name":"t","editor":{},"ui":{},"search":{},"diagnostic":{},"syntax":{}}"#,
2585 )
2586 .expect("minimal theme should parse");
2587 assert_eq!(theme.status_separator_fg, theme.status_bar_fg);
2588 assert_eq!(theme.status_separator_bg, theme.status_bar_bg);
2589
2590 let theme = crate::view::theme::Theme::from_json(
2593 r#"{
2594 "name":"t",
2595 "editor":{},
2596 "ui":{
2597 "status_bar_fg":"White",
2598 "status_bar_bg":"DarkGray",
2599 "status_separator_fg":"Gray",
2600 "status_separator_bg":"Black"
2601 },
2602 "search":{},
2603 "diagnostic":{},
2604 "syntax":{}
2605 }"#,
2606 )
2607 .expect("theme should parse");
2608 assert_ne!(theme.status_separator_fg, theme.status_bar_fg);
2609 assert_ne!(theme.status_separator_bg, theme.status_bar_bg);
2610 }
2611
2612 #[test]
2613 fn test_remote_indicator_override_state_projection() {
2614 assert_eq!(
2615 RemoteIndicatorOverride::Local.state(),
2616 RemoteIndicatorState::Local
2617 );
2618 assert_eq!(
2619 RemoteIndicatorOverride::Connecting { label: None }.state(),
2620 RemoteIndicatorState::Connecting
2621 );
2622 assert_eq!(
2623 RemoteIndicatorOverride::Connected { label: None }.state(),
2624 RemoteIndicatorState::Connected
2625 );
2626 assert_eq!(
2627 RemoteIndicatorOverride::FailedAttach { error: None }.state(),
2628 RemoteIndicatorState::FailedAttach
2629 );
2630 assert_eq!(
2631 RemoteIndicatorOverride::Disconnected { label: None }.state(),
2632 RemoteIndicatorState::Disconnected
2633 );
2634 }
2635
2636 #[test]
2645 fn test_cursor_position_widths_stable_across_cursor_movement() {
2646 let line_count = 50;
2647 let widths: Vec<usize> = [(1, 1), (5, 12), (12, 5), (50, 100), (1, 1)]
2650 .into_iter()
2651 .map(|(ln, col)| format_cursor_position(ln, col, line_count).len())
2652 .collect();
2653 assert!(
2654 widths.windows(2).all(|w| w[0] == w[1]),
2655 "rendered widths drift across cursor movements: {widths:?}"
2656 );
2657 }
2658
2659 #[test]
2660 fn test_cursor_position_preserves_natural_number_text() {
2661 let text = format_cursor_position(1, 1, 50);
2665 assert!(
2666 text.starts_with("Ln 1, Col 1"),
2667 "expected text to start with natural numbers, got {text:?}"
2668 );
2669 assert!(
2670 text.ends_with(' '),
2671 "expected trailing padding, got {text:?}"
2672 );
2673 }
2674
2675 #[test]
2676 fn test_cursor_position_no_padding_for_single_line_buffer() {
2677 let text = format_cursor_position(1, 1, 1);
2681 assert_eq!(text.len(), 13);
2683 assert!(text.starts_with("Ln 1, Col 1"));
2684 }
2685
2686 #[test]
2687 fn test_cursor_position_does_not_shrink_below_actual() {
2688 let text = format_cursor_position(99, 99999, 50);
2691 assert_eq!(text, "Ln 99, Col 99999");
2692 }
2693
2694 #[test]
2695 fn test_cursor_position_compact_widths_stable() {
2696 let line_count = 50;
2697 let widths: Vec<usize> = [(1, 1), (5, 12), (12, 5), (50, 100), (1, 1)]
2698 .into_iter()
2699 .map(|(ln, col)| format_cursor_position_compact(ln, col, line_count).len())
2700 .collect();
2701 assert!(
2702 widths.windows(2).all(|w| w[0] == w[1]),
2703 "compact widths drift across cursor movements: {widths:?}"
2704 );
2705 }
2706
2707 #[test]
2708 fn test_cursor_position_compact_preserves_natural_text() {
2709 let text = format_cursor_position_compact(1, 1, 50);
2710 assert!(
2711 text.starts_with("1:1"),
2712 "expected text to start with natural numbers, got {text:?}"
2713 );
2714 }
2715
2716 #[test]
2717 fn test_cursor_position_scales_with_line_count() {
2718 let short = format_cursor_position(1, 1, 9);
2721 let long = format_cursor_position(1, 1, 10_000);
2722 assert!(
2723 long.len() > short.len(),
2724 "wider buffers should reserve more width: {short:?} vs {long:?}"
2725 );
2726 let top = format_cursor_position(1, 1, 10_000);
2729 let high = format_cursor_position(9_999, 999, 10_000);
2730 assert_eq!(top.len(), high.len());
2731 }
2732
2733 #[test]
2734 fn test_cursor_column_counts_chars_not_bytes() {
2735 let mut buf =
2736 crate::model::buffer::TextBuffer::from_str_test("hello\ncafé résumé\nworld\n");
2737 let line_start = buf.line_start_offset(1).unwrap();
2738
2739 let col = cursor_column(&mut buf, line_start + 6);
2741 assert_eq!(
2742 col, 5,
2743 "cursor at 'r' should be column 5, not byte offset 6"
2744 );
2745
2746 let col = cursor_column(&mut buf, line_start + 3);
2748 assert_eq!(col, 3, "cursor at 'é' should be column 3");
2749
2750 let col = cursor_column(&mut buf, line_start + 10);
2752 assert_eq!(col, 8, "cursor at 'u' should be column 8");
2753 }
2754
2755 #[test]
2756 fn test_cursor_column_counts_grapheme_clusters() {
2757 let mut buf = crate::model::buffer::TextBuffer::from_str_test("ab\ne\u{0301}x\n");
2761 let line_start = buf.line_start_offset(1).unwrap();
2762
2763 let col = cursor_column(&mut buf, line_start + 3);
2766 assert_eq!(
2767 col, 1,
2768 "accented 'e' is one grapheme; 'x' should be column 1, not 2"
2769 );
2770 }
2771
2772 #[test]
2773 fn test_cursor_column_zwj_emoji_is_one_grapheme() {
2774 let mut buf = crate::model::buffer::TextBuffer::from_str_test("👨\u{200D}👩\u{200D}👧z\n");
2777 let line_start = buf.line_start_offset(0).unwrap();
2778
2779 let col = cursor_column(&mut buf, line_start + 18);
2780 assert_eq!(col, 1, "ZWJ family emoji should count as one column");
2781 }
2782
2783 #[test]
2784 fn test_cursor_column_at_line_start_is_zero() {
2785 let mut buf = crate::model::buffer::TextBuffer::from_str_test("hello\nworld\n");
2786 let line_start = buf.line_start_offset(1).unwrap();
2787 assert_eq!(cursor_column(&mut buf, line_start), 0);
2788 }
2789}