1use std::path::Path;
4
5use crate::app::WarningLevel;
6use crate::config::{StatusBarConfig, StatusBarElement};
7use crate::primitives::display_width::{char_width, str_width};
8use crate::state::EditorState;
9use crate::view::prompt::Prompt;
10use chrono::Timelike;
11use ratatui::layout::Rect;
12use ratatui::style::{Modifier, Style};
13use ratatui::text::{Line, Span};
14use ratatui::widgets::Paragraph;
15use ratatui::Frame;
16use rust_i18n::t;
17
18const SSH_PREFIX: &str = "[SSH:";
22const SSH_PREFIX_TERMINATOR: &str = "] ";
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26enum ElementKind {
27 Normal,
29 LineEnding,
31 Encoding,
33 Language,
35 Lsp,
37 WarningBadge,
39 Update,
41 Palette,
43 Messages,
45 RemoteDisconnected,
47 Clock,
49}
50
51struct RenderedElement {
53 text: String,
54 kind: ElementKind,
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
73pub enum LspIndicatorState {
74 #[default]
75 None,
76 On,
77 Off,
78 OffDismissed,
79 Error,
80}
81
82pub struct StatusBarContext<'a> {
84 pub state: &'a mut EditorState,
85 pub cursors: &'a crate::model::cursor::Cursors,
86 pub status_message: &'a Option<String>,
87 pub plugin_status_message: &'a Option<String>,
88 pub lsp_status: &'a str,
89 pub lsp_indicator_state: LspIndicatorState,
94 pub theme: &'a crate::view::theme::Theme,
95 pub display_name: &'a str,
96 pub keybindings: &'a crate::input::keybindings::KeybindingResolver,
97 pub chord_state: &'a [(crossterm::event::KeyCode, crossterm::event::KeyModifiers)],
98 pub update_available: Option<&'a str>,
99 pub warning_level: WarningLevel,
100 pub general_warning_count: usize,
101 pub hover: StatusBarHover,
102 pub remote_connection: Option<&'a str>,
103 pub session_name: Option<&'a str>,
104 pub read_only: bool,
105}
106
107#[derive(Debug, Clone, Default)]
109pub struct StatusBarLayout {
110 pub lsp_indicator: Option<(u16, u16, u16)>,
112 pub warning_badge: Option<(u16, u16, u16)>,
114 pub line_ending_indicator: Option<(u16, u16, u16)>,
116 pub encoding_indicator: Option<(u16, u16, u16)>,
118 pub language_indicator: Option<(u16, u16, u16)>,
120 pub message_area: Option<(u16, u16, u16)>,
122}
123
124#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
126pub enum StatusBarHover {
127 #[default]
128 None,
129 LspIndicator,
131 WarningBadge,
133 LineEndingIndicator,
135 EncodingIndicator,
137 LanguageIndicator,
139 MessageArea,
141}
142
143#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
145pub enum SearchOptionsHover {
146 #[default]
147 None,
148 CaseSensitive,
149 WholeWord,
150 Regex,
151 ConfirmEach,
152}
153
154#[derive(Debug, Clone, Default)]
156pub struct SearchOptionsLayout {
157 pub row: u16,
159 pub case_sensitive: Option<(u16, u16)>,
161 pub whole_word: Option<(u16, u16)>,
163 pub regex: Option<(u16, u16)>,
165 pub confirm_each: Option<(u16, u16)>,
167}
168
169impl SearchOptionsLayout {
170 pub fn checkbox_at(&self, x: u16, y: u16) -> Option<SearchOptionsHover> {
172 if y != self.row {
173 return None;
174 }
175
176 if let Some((start, end)) = self.case_sensitive {
177 if x >= start && x < end {
178 return Some(SearchOptionsHover::CaseSensitive);
179 }
180 }
181 if let Some((start, end)) = self.whole_word {
182 if x >= start && x < end {
183 return Some(SearchOptionsHover::WholeWord);
184 }
185 }
186 if let Some((start, end)) = self.regex {
187 if x >= start && x < end {
188 return Some(SearchOptionsHover::Regex);
189 }
190 }
191 if let Some((start, end)) = self.confirm_each {
192 if x >= start && x < end {
193 return Some(SearchOptionsHover::ConfirmEach);
194 }
195 }
196 None
197 }
198}
199
200#[derive(Debug, Clone)]
202pub struct TruncatedPath {
203 pub prefix: String,
205 pub truncated: bool,
207 pub suffix: String,
209}
210
211impl TruncatedPath {
212 pub fn to_string_plain(&self) -> String {
214 if self.truncated {
215 format!("{}/[...]{}", self.prefix, self.suffix)
216 } else {
217 format!("{}{}", self.prefix, self.suffix)
218 }
219 }
220
221 pub fn display_len(&self) -> usize {
223 if self.truncated {
224 self.prefix.len() + "/[...]".len() + self.suffix.len()
225 } else {
226 self.prefix.len() + self.suffix.len()
227 }
228 }
229}
230
231pub fn truncate_path(path: &Path, max_len: usize) -> TruncatedPath {
243 let path_str = path.to_string_lossy();
244
245 if path_str.len() <= max_len {
247 return TruncatedPath {
248 prefix: String::new(),
249 truncated: false,
250 suffix: path_str.to_string(),
251 };
252 }
253
254 let components: Vec<&str> = path_str.split('/').filter(|s| !s.is_empty()).collect();
255
256 if components.is_empty() {
257 return TruncatedPath {
258 prefix: "/".to_string(),
259 truncated: false,
260 suffix: String::new(),
261 };
262 }
263
264 let prefix = if path_str.starts_with('/') {
266 format!("/{}", components.first().unwrap_or(&""))
267 } else {
268 components.first().unwrap_or(&"").to_string()
269 };
270
271 let ellipsis_len = "/[...]".len();
273
274 let available_for_suffix = max_len.saturating_sub(prefix.len() + ellipsis_len);
276
277 if available_for_suffix < 5 || components.len() <= 1 {
278 let truncated_path = if path_str.len() > max_len.saturating_sub(3) {
280 format!("{}...", &path_str[..max_len.saturating_sub(3)])
281 } else {
282 path_str.to_string()
283 };
284 return TruncatedPath {
285 prefix: String::new(),
286 truncated: false,
287 suffix: truncated_path,
288 };
289 }
290
291 let mut suffix_parts: Vec<&str> = Vec::new();
293 let mut suffix_len = 0;
294
295 for component in components.iter().skip(1).rev() {
296 let component_len = component.len() + 1; if suffix_len + component_len <= available_for_suffix {
298 suffix_parts.push(component);
299 suffix_len += component_len;
300 } else {
301 break;
302 }
303 }
304
305 suffix_parts.reverse();
306
307 if suffix_parts.len() == components.len() - 1 {
309 return TruncatedPath {
310 prefix: String::new(),
311 truncated: false,
312 suffix: path_str.to_string(),
313 };
314 }
315
316 let suffix = if suffix_parts.is_empty() {
317 let last = components.last().unwrap_or(&"");
319 let truncate_to = available_for_suffix.saturating_sub(4); if truncate_to > 0 && last.len() > truncate_to {
321 format!("/{}...", &last[..truncate_to])
322 } else {
323 format!("/{}", last)
324 }
325 } else {
326 format!("/{}", suffix_parts.join("/"))
327 };
328
329 TruncatedPath {
330 prefix,
331 truncated: true,
332 suffix,
333 }
334}
335
336fn truncate_to_width(s: &str, max_width: usize) -> String {
338 let width = str_width(s);
339 if width <= max_width {
340 return s.to_string();
341 }
342 let truncate_at = max_width.saturating_sub(3);
343 if truncate_at == 0 {
344 return if max_width >= 3 {
345 "...".to_string()
346 } else {
347 s.chars().take(max_width).collect()
348 };
349 }
350 let mut w = 0;
351 let truncated: String = s
352 .chars()
353 .take_while(|ch| {
354 let cw = char_width(*ch);
355 if w + cw <= truncate_at {
356 w += cw;
357 true
358 } else {
359 false
360 }
361 })
362 .collect();
363 format!("{}...", truncated)
364}
365
366pub struct StatusBarRenderer;
368
369impl StatusBarRenderer {
370 pub fn render_status_bar(
374 frame: &mut Frame,
375 area: Rect,
376 ctx: &mut StatusBarContext<'_>,
377 config: &StatusBarConfig,
378 ) -> StatusBarLayout {
379 Self::render_status(frame, area, ctx, config)
380 }
381
382 pub fn render_prompt(
384 frame: &mut Frame,
385 area: Rect,
386 prompt: &Prompt,
387 theme: &crate::view::theme::Theme,
388 ) {
389 let base_style = Style::default().fg(theme.prompt_fg).bg(theme.prompt_bg);
390
391 let mut spans = vec![Span::styled(prompt.message.clone(), base_style)];
393
394 if let Some((sel_start, sel_end)) = prompt.selection_range() {
396 let input = &prompt.input;
397
398 if sel_start > 0 {
400 spans.push(Span::styled(input[..sel_start].to_string(), base_style));
401 }
402
403 if sel_start < sel_end {
405 let selection_style = Style::default()
407 .fg(theme.prompt_selection_fg)
408 .bg(theme.prompt_selection_bg);
409 spans.push(Span::styled(
410 input[sel_start..sel_end].to_string(),
411 selection_style,
412 ));
413 }
414
415 if sel_end < input.len() {
417 spans.push(Span::styled(input[sel_end..].to_string(), base_style));
418 }
419 } else {
420 spans.push(Span::styled(prompt.input.clone(), base_style));
422 }
423
424 let line = Line::from(spans);
425 let prompt_line = Paragraph::new(line).style(base_style);
426
427 frame.render_widget(prompt_line, area);
428
429 let message_width = str_width(&prompt.message);
434 let input_width_before_cursor = str_width(&prompt.input[..prompt.cursor_pos]);
435 let cursor_x = (message_width + input_width_before_cursor) as u16;
436 if cursor_x < area.width {
437 frame.set_cursor_position((area.x + cursor_x, area.y));
438 }
439 }
440
441 pub fn render_file_open_prompt(
445 frame: &mut Frame,
446 area: Rect,
447 prompt: &Prompt,
448 file_open_state: &crate::app::file_open::FileOpenState,
449 theme: &crate::view::theme::Theme,
450 ) {
451 let base_style = Style::default().fg(theme.prompt_fg).bg(theme.prompt_bg);
452 let dir_style = Style::default()
453 .fg(theme.help_separator_fg)
454 .bg(theme.prompt_bg);
455 let ellipsis_style = Style::default()
457 .fg(theme.menu_highlight_fg)
458 .bg(theme.prompt_bg);
459
460 let mut spans = Vec::new();
461
462 let open_prompt = t!("file.open_prompt").to_string();
464 spans.push(Span::styled(open_prompt.clone(), base_style));
465
466 let prefix_len = str_width(&open_prompt);
469 let dir_path = file_open_state.current_dir.to_string_lossy();
470 let dir_path_len = dir_path.len() + 1; let input_len = prompt.input.len();
472 let total_len = prefix_len + dir_path_len + input_len;
473 let threshold = (area.width as usize * 90) / 100;
474
475 let truncated = if total_len > threshold {
477 let available_for_path = threshold
479 .saturating_sub(prefix_len)
480 .saturating_sub(input_len);
481 truncate_path(&file_open_state.current_dir, available_for_path)
482 } else {
483 TruncatedPath {
485 prefix: String::new(),
486 truncated: false,
487 suffix: dir_path.to_string(),
488 }
489 };
490
491 if truncated.truncated {
493 spans.push(Span::styled(truncated.prefix.clone(), dir_style));
495 spans.push(Span::styled("/[...]", ellipsis_style));
497 let suffix_with_slash = if truncated.suffix.ends_with('/') {
499 truncated.suffix.clone()
500 } else {
501 format!("{}/", truncated.suffix)
502 };
503 spans.push(Span::styled(suffix_with_slash, dir_style));
504 } else {
505 let path_display = if truncated.suffix.ends_with('/') {
507 truncated.suffix.clone()
508 } else {
509 format!("{}/", truncated.suffix)
510 };
511 spans.push(Span::styled(path_display, dir_style));
512 }
513
514 spans.push(Span::styled(prompt.input.clone(), base_style));
516
517 let line = Line::from(spans);
518 let prompt_line = Paragraph::new(line).style(base_style);
519
520 frame.render_widget(prompt_line, area);
521
522 let prefix_width = str_width(&open_prompt);
526 let dir_display_width = if truncated.truncated {
527 let suffix_with_slash = if truncated.suffix.ends_with('/') {
528 &truncated.suffix
529 } else {
530 &truncated.suffix
532 };
533 str_width(&truncated.prefix) + str_width("/[...]") + str_width(suffix_with_slash) + 1
534 } else {
535 str_width(&truncated.suffix) + 1 };
537 let input_width_before_cursor = str_width(&prompt.input[..prompt.cursor_pos]);
538 let cursor_x = (prefix_width + dir_display_width + input_width_before_cursor) as u16;
539 if cursor_x < area.width {
540 frame.set_cursor_position((area.x + cursor_x, area.y));
541 }
542 }
543
544 fn render_element(
547 element: &StatusBarElement,
548 ctx: &mut StatusBarContext<'_>,
549 ) -> Option<RenderedElement> {
550 match element {
551 StatusBarElement::Filename => {
552 let modified = if ctx.state.buffer.is_modified() {
553 " [+]"
554 } else {
555 ""
556 };
557 let read_only_indicator = if ctx.read_only { " [RO]" } else { "" };
558 let remote_disconnected = ctx
559 .remote_connection
560 .map(|conn| conn.contains("(Disconnected)"))
561 .unwrap_or(false);
562 let remote_prefix = ctx
563 .remote_connection
564 .map(|conn| format!("{SSH_PREFIX}{conn}{SSH_PREFIX_TERMINATOR}"))
565 .unwrap_or_default();
566 let session_prefix = ctx
567 .session_name
568 .map(|name| format!("[{}] ", name))
569 .unwrap_or_default();
570 let display_name = ctx.display_name;
571 let text = format!(
572 "{session_prefix}{remote_prefix}{display_name}{modified}{read_only_indicator}"
573 );
574 let kind = if remote_disconnected {
575 ElementKind::RemoteDisconnected
576 } else {
577 ElementKind::Normal
578 };
579 Some(RenderedElement { text, kind })
580 }
581 StatusBarElement::Cursor => {
582 if !ctx.state.show_cursors {
583 return None;
584 }
585 let cursor = *ctx.cursors.primary();
586 let byte_offset_mode = ctx.state.buffer.line_count().is_none();
587 let text = if byte_offset_mode {
588 format!("Byte {}", cursor.position)
589 } else {
590 let cursor_iter = ctx.state.buffer.line_iterator(cursor.position, 80);
591 let line_start = cursor_iter.current_position();
592 let col = cursor.position.saturating_sub(line_start);
593 let line = ctx.state.primary_cursor_line_number.value();
594 format!("Ln {}, Col {}", line + 1, col + 1)
595 };
596 Some(RenderedElement {
597 text,
598 kind: ElementKind::Normal,
599 })
600 }
601 StatusBarElement::CursorCompact => {
602 if !ctx.state.show_cursors {
603 return None;
604 }
605 let cursor = *ctx.cursors.primary();
606 let byte_offset_mode = ctx.state.buffer.line_count().is_none();
607 let text = if byte_offset_mode {
608 format!("{}", cursor.position)
609 } else {
610 let cursor_iter = ctx.state.buffer.line_iterator(cursor.position, 80);
611 let line_start = cursor_iter.current_position();
612 let col = cursor.position.saturating_sub(line_start);
613 let line = ctx.state.primary_cursor_line_number.value();
614 format!("{}:{}", line + 1, col + 1)
615 };
616 Some(RenderedElement {
617 text,
618 kind: ElementKind::Normal,
619 })
620 }
621 StatusBarElement::Diagnostics => {
622 let diagnostics = ctx.state.overlays.all();
623 let mut error_count = 0usize;
624 let mut warning_count = 0usize;
625 let mut info_count = 0usize;
626 let diagnostic_ns = crate::services::lsp::diagnostics::lsp_diagnostic_namespace();
627 for overlay in diagnostics {
628 if overlay.namespace.as_ref() == Some(&diagnostic_ns) {
629 match overlay.priority {
630 100 => error_count += 1,
631 50 => warning_count += 1,
632 _ => info_count += 1,
633 }
634 }
635 }
636 if error_count + warning_count + info_count == 0 {
637 return None;
638 }
639 let mut parts = Vec::new();
640 if error_count > 0 {
641 parts.push(format!("E:{}", error_count));
642 }
643 if warning_count > 0 {
644 parts.push(format!("W:{}", warning_count));
645 }
646 if info_count > 0 {
647 parts.push(format!("I:{}", info_count));
648 }
649 Some(RenderedElement {
650 text: parts.join(" "),
651 kind: ElementKind::Normal,
652 })
653 }
654 StatusBarElement::CursorCount => {
655 if ctx.cursors.count() <= 1 {
656 return None;
657 }
658 Some(RenderedElement {
659 text: t!("status.cursors", count = ctx.cursors.count()).to_string(),
660 kind: ElementKind::Normal,
661 })
662 }
663 StatusBarElement::Messages => {
664 let mut parts: Vec<&str> = Vec::new();
665 if let Some(msg) = ctx.status_message {
666 if !msg.is_empty() {
667 parts.push(msg);
668 }
669 }
670 if let Some(msg) = ctx.plugin_status_message {
671 if !msg.is_empty() {
672 parts.push(msg);
673 }
674 }
675 if parts.is_empty() {
676 return None;
677 }
678 Some(RenderedElement {
679 text: parts.join(" | "),
680 kind: ElementKind::Messages,
681 })
682 }
683 StatusBarElement::Chord => {
684 if ctx.chord_state.is_empty() {
685 return None;
686 }
687 let chord_str = ctx
688 .chord_state
689 .iter()
690 .map(|(code, modifiers)| {
691 crate::input::keybindings::format_keybinding(code, modifiers)
692 })
693 .collect::<Vec<_>>()
694 .join(" ");
695 Some(RenderedElement {
696 text: format!("[{}]", chord_str),
697 kind: ElementKind::Normal,
698 })
699 }
700 StatusBarElement::LineEnding => Some(RenderedElement {
701 text: format!(" {} ", ctx.state.buffer.line_ending().display_name()),
702 kind: ElementKind::LineEnding,
703 }),
704 StatusBarElement::Encoding => Some(RenderedElement {
705 text: format!(" {} ", ctx.state.buffer.encoding().display_name()),
706 kind: ElementKind::Encoding,
707 }),
708 StatusBarElement::Language => {
709 let text = if ctx.state.language == "text"
710 && ctx.state.display_name != "Text"
711 && ctx.state.display_name != "Plain Text"
712 && ctx.state.display_name != "text"
713 {
714 format!(" {} [syntax only] ", &ctx.state.display_name)
715 } else {
716 format!(" {} ", &ctx.state.display_name)
717 };
718 Some(RenderedElement {
719 text,
720 kind: ElementKind::Language,
721 })
722 }
723 StatusBarElement::Lsp => {
724 if ctx.lsp_status.is_empty() {
725 return None;
726 }
727 Some(RenderedElement {
728 text: format!(" {} ", ctx.lsp_status),
729 kind: ElementKind::Lsp,
730 })
731 }
732 StatusBarElement::Warnings => {
733 if ctx.general_warning_count == 0 {
734 return None;
735 }
736 Some(RenderedElement {
737 text: format!(" [\u{26a0} {}] ", ctx.general_warning_count),
738 kind: ElementKind::WarningBadge,
739 })
740 }
741 StatusBarElement::Update => {
742 let version = ctx.update_available?;
743 Some(RenderedElement {
744 text: format!(" {} ", t!("status.update_available", version = version)),
745 kind: ElementKind::Update,
746 })
747 }
748 StatusBarElement::Palette => {
749 let shortcut = ctx
750 .keybindings
751 .get_keybinding_for_action(
752 &crate::input::keybindings::Action::QuickOpen,
753 crate::input::keybindings::KeyContext::Global,
754 )
755 .unwrap_or_else(|| "?".to_string());
756 Some(RenderedElement {
757 text: format!(" {} ", t!("status.palette", shortcut = shortcut)),
758 kind: ElementKind::Palette,
759 })
760 }
761 StatusBarElement::Clock => {
762 let now = chrono::Local::now();
763 let text = format!("{:02}:{:02}", now.hour(), now.minute());
764 Some(RenderedElement {
765 text,
766 kind: ElementKind::Clock,
767 })
768 }
769 }
770 }
771
772 fn element_style(
774 kind: ElementKind,
775 theme: &crate::view::theme::Theme,
776 hover: StatusBarHover,
777 _warning_level: WarningLevel,
778 lsp_state: LspIndicatorState,
779 ) -> Style {
780 match kind {
781 ElementKind::Normal | ElementKind::Messages | ElementKind::Clock => Style::default()
782 .fg(theme.status_bar_fg)
783 .bg(theme.status_bar_bg),
784 ElementKind::RemoteDisconnected => Style::default()
785 .fg(theme.status_error_indicator_fg)
786 .bg(theme.status_error_indicator_bg),
787 ElementKind::LineEnding => {
788 let is_hovering = hover == StatusBarHover::LineEndingIndicator;
789 let (fg, bg) = if is_hovering {
790 (theme.menu_hover_fg, theme.menu_hover_bg)
791 } else {
792 (theme.status_bar_fg, theme.status_bar_bg)
793 };
794 let mut style = Style::default().fg(fg).bg(bg);
795 if is_hovering {
796 style = style.add_modifier(Modifier::UNDERLINED);
797 }
798 style
799 }
800 ElementKind::Encoding => {
801 let is_hovering = hover == StatusBarHover::EncodingIndicator;
802 let (fg, bg) = if is_hovering {
803 (theme.menu_hover_fg, theme.menu_hover_bg)
804 } else {
805 (theme.status_bar_fg, theme.status_bar_bg)
806 };
807 let mut style = Style::default().fg(fg).bg(bg);
808 if is_hovering {
809 style = style.add_modifier(Modifier::UNDERLINED);
810 }
811 style
812 }
813 ElementKind::Language => {
814 let is_hovering = hover == StatusBarHover::LanguageIndicator;
815 let (fg, bg) = if is_hovering {
816 (theme.menu_hover_fg, theme.menu_hover_bg)
817 } else {
818 (theme.status_bar_fg, theme.status_bar_bg)
819 };
820 let mut style = Style::default().fg(fg).bg(bg);
821 if is_hovering {
822 style = style.add_modifier(Modifier::UNDERLINED);
823 }
824 style
825 }
826 ElementKind::Lsp => {
827 let is_hovering = hover == StatusBarHover::LspIndicator;
828 let (fg, bg) = match lsp_state {
834 LspIndicatorState::Error => {
835 (theme.diagnostic_error_fg, theme.diagnostic_error_bg)
836 }
837 LspIndicatorState::Off => {
838 (theme.diagnostic_warning_fg, theme.diagnostic_warning_bg)
839 }
840 LspIndicatorState::On => (theme.diagnostic_info_fg, theme.diagnostic_info_bg),
841 LspIndicatorState::OffDismissed => (theme.status_bar_fg, theme.status_bar_bg),
849 LspIndicatorState::None => (theme.status_bar_fg, theme.status_bar_bg),
850 };
851 let mut style = Style::default().fg(fg).bg(bg);
852 if is_hovering && lsp_state != LspIndicatorState::None {
857 style = style.add_modifier(Modifier::UNDERLINED);
858 }
859 style
860 }
861 ElementKind::WarningBadge => {
862 let is_hovering = hover == StatusBarHover::WarningBadge;
863 let (fg, bg) = if is_hovering {
864 (
865 theme.status_warning_indicator_hover_fg,
866 theme.status_warning_indicator_hover_bg,
867 )
868 } else {
869 (
870 theme.status_warning_indicator_fg,
871 theme.status_warning_indicator_bg,
872 )
873 };
874 let mut style = Style::default().fg(fg).bg(bg);
875 if is_hovering {
876 style = style.add_modifier(Modifier::UNDERLINED);
877 }
878 style
879 }
880 ElementKind::Update => Style::default()
881 .fg(theme.menu_highlight_fg)
882 .bg(theme.menu_dropdown_bg),
883 ElementKind::Palette => Style::default()
884 .fg(theme.help_indicator_fg)
885 .bg(theme.help_indicator_bg),
886 }
887 }
888
889 fn update_layout_for_element(
891 layout: &mut StatusBarLayout,
892 kind: ElementKind,
893 row: u16,
894 start_col: u16,
895 end_col: u16,
896 ) {
897 match kind {
898 ElementKind::LineEnding => {
899 layout.line_ending_indicator = Some((row, start_col, end_col))
900 }
901 ElementKind::Encoding => layout.encoding_indicator = Some((row, start_col, end_col)),
902 ElementKind::Language => layout.language_indicator = Some((row, start_col, end_col)),
903 ElementKind::Lsp => layout.lsp_indicator = Some((row, start_col, end_col)),
904 ElementKind::WarningBadge => layout.warning_badge = Some((row, start_col, end_col)),
905 ElementKind::Messages => layout.message_area = Some((row, start_col, end_col)),
906 _ => {}
907 }
908 }
909
910 fn element_spans(
915 rendered: &RenderedElement,
916 theme: &crate::view::theme::Theme,
917 hover: StatusBarHover,
918 warning_level: WarningLevel,
919 lsp_state: LspIndicatorState,
920 ) -> (Vec<Span<'static>>, usize) {
921 let base_style = Style::default()
922 .fg(theme.status_bar_fg)
923 .bg(theme.status_bar_bg);
924 let width = str_width(&rendered.text);
925
926 if rendered.kind == ElementKind::RemoteDisconnected && rendered.text.starts_with(SSH_PREFIX)
927 {
928 let error_style = Style::default()
929 .fg(theme.status_error_indicator_fg)
930 .bg(theme.status_error_indicator_bg);
931 if let Some(term_off) = rendered.text.find(SSH_PREFIX_TERMINATOR) {
932 let split_at = term_off + SSH_PREFIX_TERMINATOR.len();
933 let prefix = rendered.text[..split_at].to_string();
934 let rest = rendered.text[split_at..].to_string();
935 return (
936 vec![
937 Span::styled(prefix, error_style),
938 Span::styled(rest, base_style),
939 ],
940 width,
941 );
942 }
943 return (
944 vec![Span::styled(rendered.text.clone(), error_style)],
945 width,
946 );
947 }
948
949 let style = Self::element_style(rendered.kind, theme, hover, warning_level, lsp_state);
950 let spans = if rendered.kind == ElementKind::Clock {
951 vec![
953 Span::styled(rendered.text[..2].to_string(), style),
954 Span::styled(":".to_string(), style.add_modifier(Modifier::SLOW_BLINK)),
955 Span::styled(rendered.text[3..].to_string(), style),
956 ]
957 } else {
958 vec![Span::styled(rendered.text.clone(), style)]
959 };
960 (spans, width)
961 }
962
963 fn render_side(
965 config_side: &[StatusBarElement],
966 ctx: &mut StatusBarContext<'_>,
967 ) -> Vec<(Vec<Span<'static>>, usize, ElementKind)> {
968 let rendered: Vec<RenderedElement> = config_side
969 .iter()
970 .filter_map(|elem| Self::render_element(elem, ctx))
971 .filter(|e| !e.text.is_empty())
972 .collect();
973
974 let theme = ctx.theme;
975 let hover = ctx.hover;
976 let warning_level = ctx.warning_level;
977 let lsp_state = ctx.lsp_indicator_state;
978 rendered
979 .into_iter()
980 .map(|r| {
981 let kind = r.kind;
982 let (spans, width) =
983 Self::element_spans(&r, theme, hover, warning_level, lsp_state);
984 (spans, width, kind)
985 })
986 .collect()
987 }
988
989 fn render_status(
991 frame: &mut Frame,
992 area: Rect,
993 ctx: &mut StatusBarContext<'_>,
994 config: &StatusBarConfig,
995 ) -> StatusBarLayout {
996 let mut layout = StatusBarLayout::default();
997 let base_style = Style::default()
998 .fg(ctx.theme.status_bar_fg)
999 .bg(ctx.theme.status_bar_bg);
1000 let available_width = area.width as usize;
1001
1002 if available_width == 0 || area.height == 0 {
1003 return layout;
1004 }
1005
1006 let left_items = Self::render_side(&config.left, ctx);
1007 let mut right_items = Self::render_side(&config.right, ctx);
1008
1009 const SEPARATOR: &str = " | ";
1010 let separator_width = str_width(SEPARATOR);
1011
1012 let total_right_width: usize = right_items.iter().map(|(_, w, _)| *w).sum();
1021 let left_min_target = available_width
1022 .saturating_mul(2)
1023 .saturating_div(5) .min(40); let right_budget = available_width.saturating_sub(left_min_target + 1);
1026 if total_right_width > right_budget && right_items.len() > 1 {
1027 let mut current = total_right_width;
1028 while current > right_budget && right_items.len() > 1 {
1029 if let Some(dropped) = right_items.pop() {
1030 current = current.saturating_sub(dropped.1);
1031 } else {
1032 break;
1033 }
1034 }
1035 }
1036
1037 let right_width: usize = right_items.iter().map(|(_, w, _)| *w).sum();
1038
1039 let narrow = available_width < 15;
1040 let left_max_width = if narrow {
1041 available_width
1042 } else if available_width > right_width + 1 {
1043 available_width - right_width - 1
1044 } else {
1045 1
1046 };
1047
1048 let mut spans: Vec<Span<'static>> = Vec::new();
1052 let mut used_left: usize = 0;
1053
1054 for (idx, (item_spans, width, kind)) in left_items.into_iter().enumerate() {
1055 let sep_width = if idx == 0 { 0 } else { separator_width };
1056 if used_left + sep_width >= left_max_width {
1057 break;
1058 }
1059 if sep_width > 0 {
1060 spans.push(Span::styled(SEPARATOR, base_style));
1061 used_left += sep_width;
1062 }
1063
1064 let remaining = left_max_width - used_left;
1065 let start_col = used_left;
1066
1067 if width <= remaining {
1068 spans.extend(item_spans);
1069 used_left += width;
1070
1071 Self::update_layout_for_element(
1072 &mut layout,
1073 kind,
1074 area.y,
1075 area.x + start_col as u16,
1076 area.x + (start_col + width) as u16,
1077 );
1078 } else {
1079 let group_text: String = item_spans.iter().map(|s| s.content.as_ref()).collect();
1083 let truncated = truncate_to_width(&group_text, remaining);
1084 let truncated_width = str_width(&truncated);
1085 let overflow_style = Self::element_style(
1086 kind,
1087 ctx.theme,
1088 ctx.hover,
1089 ctx.warning_level,
1090 ctx.lsp_indicator_state,
1091 );
1092 spans.push(Span::styled(truncated, overflow_style));
1093 used_left += truncated_width;
1094
1095 Self::update_layout_for_element(
1096 &mut layout,
1097 kind,
1098 area.y,
1099 area.x + start_col as u16,
1100 area.x + (start_col + truncated_width) as u16,
1101 );
1102 break;
1103 }
1104 }
1105
1106 if narrow {
1107 if used_left < available_width {
1108 spans.push(Span::styled(
1109 " ".repeat(available_width - used_left),
1110 base_style,
1111 ));
1112 }
1113 frame.render_widget(Paragraph::new(Line::from(spans)), area);
1114 return layout;
1115 }
1116
1117 let mut col_offset = used_left;
1118 if col_offset + right_width < available_width {
1119 let padding = available_width - col_offset - right_width;
1120 spans.push(Span::styled(" ".repeat(padding), base_style));
1121 col_offset = available_width - right_width;
1122 } else if col_offset < available_width {
1123 spans.push(Span::styled(" ", base_style));
1124 col_offset += 1;
1125 }
1126
1127 let mut current_col = area.x + col_offset as u16;
1128 for (item_spans, width, kind) in right_items {
1129 Self::update_layout_for_element(
1130 &mut layout,
1131 kind,
1132 area.y,
1133 current_col,
1134 current_col + width as u16,
1135 );
1136 spans.extend(item_spans);
1137 current_col += width as u16;
1138 }
1139
1140 frame.render_widget(Paragraph::new(Line::from(spans)), area);
1141 layout
1142 }
1143
1144 #[allow(clippy::too_many_arguments)]
1155 pub fn render_search_options(
1156 frame: &mut Frame,
1157 area: Rect,
1158 case_sensitive: bool,
1159 whole_word: bool,
1160 use_regex: bool,
1161 confirm_each: Option<bool>, theme: &crate::view::theme::Theme,
1163 keybindings: &crate::input::keybindings::KeybindingResolver,
1164 hover: SearchOptionsHover,
1165 ) -> SearchOptionsLayout {
1166 use crate::primitives::display_width::str_width;
1167
1168 let mut layout = SearchOptionsLayout {
1169 row: area.y,
1170 ..Default::default()
1171 };
1172
1173 let base_style = Style::default()
1175 .fg(theme.menu_dropdown_fg)
1176 .bg(theme.menu_dropdown_bg);
1177
1178 let hover_style = Style::default()
1180 .fg(theme.menu_hover_fg)
1181 .bg(theme.menu_hover_bg);
1182
1183 let get_shortcut = |action: &crate::input::keybindings::Action| -> Option<String> {
1185 keybindings
1186 .get_keybinding_for_action(action, crate::input::keybindings::KeyContext::Prompt)
1187 .or_else(|| {
1188 keybindings.get_keybinding_for_action(
1189 action,
1190 crate::input::keybindings::KeyContext::Global,
1191 )
1192 })
1193 };
1194
1195 let case_shortcut =
1197 get_shortcut(&crate::input::keybindings::Action::ToggleSearchCaseSensitive);
1198 let word_shortcut = get_shortcut(&crate::input::keybindings::Action::ToggleSearchWholeWord);
1199 let regex_shortcut = get_shortcut(&crate::input::keybindings::Action::ToggleSearchRegex);
1200
1201 let case_checkbox = if case_sensitive { "[x]" } else { "[ ]" };
1203 let word_checkbox = if whole_word { "[x]" } else { "[ ]" };
1204 let regex_checkbox = if use_regex { "[x]" } else { "[ ]" };
1205
1206 let active_style = Style::default()
1208 .fg(theme.menu_highlight_fg)
1209 .bg(theme.menu_dropdown_bg);
1210
1211 let shortcut_style = Style::default()
1213 .fg(theme.help_separator_fg)
1214 .bg(theme.menu_dropdown_bg);
1215
1216 let hover_shortcut_style = Style::default()
1218 .fg(theme.menu_hover_fg)
1219 .bg(theme.menu_hover_bg);
1220
1221 let mut spans = Vec::new();
1222 let mut current_col = area.x;
1223
1224 spans.push(Span::styled(" ", base_style));
1226 current_col += 1;
1227
1228 let get_checkbox_style = |is_hovered: bool, is_checked: bool| -> Style {
1230 if is_hovered {
1231 hover_style
1232 } else if is_checked {
1233 active_style
1234 } else {
1235 base_style
1236 }
1237 };
1238
1239 let case_hovered = hover == SearchOptionsHover::CaseSensitive;
1241 let case_start = current_col;
1242 let case_label = format!("{} {}", case_checkbox, t!("search.case_sensitive"));
1243 let case_shortcut_text = case_shortcut
1244 .as_ref()
1245 .map(|s| format!(" ({})", s))
1246 .unwrap_or_default();
1247 let case_full_width = str_width(&case_label) + str_width(&case_shortcut_text);
1248
1249 spans.push(Span::styled(
1250 case_label,
1251 get_checkbox_style(case_hovered, case_sensitive),
1252 ));
1253 if !case_shortcut_text.is_empty() {
1254 spans.push(Span::styled(
1255 case_shortcut_text,
1256 if case_hovered {
1257 hover_shortcut_style
1258 } else {
1259 shortcut_style
1260 },
1261 ));
1262 }
1263 current_col += case_full_width as u16;
1264 layout.case_sensitive = Some((case_start, current_col));
1265
1266 spans.push(Span::styled(" ", base_style));
1268 current_col += 3;
1269
1270 let word_hovered = hover == SearchOptionsHover::WholeWord;
1272 let word_start = current_col;
1273 let word_label = format!("{} {}", word_checkbox, t!("search.whole_word"));
1274 let word_shortcut_text = word_shortcut
1275 .as_ref()
1276 .map(|s| format!(" ({})", s))
1277 .unwrap_or_default();
1278 let word_full_width = str_width(&word_label) + str_width(&word_shortcut_text);
1279
1280 spans.push(Span::styled(
1281 word_label,
1282 get_checkbox_style(word_hovered, whole_word),
1283 ));
1284 if !word_shortcut_text.is_empty() {
1285 spans.push(Span::styled(
1286 word_shortcut_text,
1287 if word_hovered {
1288 hover_shortcut_style
1289 } else {
1290 shortcut_style
1291 },
1292 ));
1293 }
1294 current_col += word_full_width as u16;
1295 layout.whole_word = Some((word_start, current_col));
1296
1297 spans.push(Span::styled(" ", base_style));
1299 current_col += 3;
1300
1301 let regex_hovered = hover == SearchOptionsHover::Regex;
1303 let regex_start = current_col;
1304 let regex_label = format!("{} {}", regex_checkbox, t!("search.regex"));
1305 let regex_shortcut_text = regex_shortcut
1306 .as_ref()
1307 .map(|s| format!(" ({})", s))
1308 .unwrap_or_default();
1309 let regex_full_width = str_width(®ex_label) + str_width(®ex_shortcut_text);
1310
1311 spans.push(Span::styled(
1312 regex_label,
1313 get_checkbox_style(regex_hovered, use_regex),
1314 ));
1315 if !regex_shortcut_text.is_empty() {
1316 spans.push(Span::styled(
1317 regex_shortcut_text,
1318 if regex_hovered {
1319 hover_shortcut_style
1320 } else {
1321 shortcut_style
1322 },
1323 ));
1324 }
1325 current_col += regex_full_width as u16;
1326 layout.regex = Some((regex_start, current_col));
1327
1328 if use_regex && confirm_each.is_some() {
1330 let hint = " \u{2502} $1,$2,…";
1331 spans.push(Span::styled(hint, shortcut_style));
1332 current_col += str_width(hint) as u16;
1333 }
1334
1335 if let Some(confirm_value) = confirm_each {
1337 let confirm_shortcut =
1338 get_shortcut(&crate::input::keybindings::Action::ToggleSearchConfirmEach);
1339 let confirm_checkbox = if confirm_value { "[x]" } else { "[ ]" };
1340
1341 spans.push(Span::styled(" ", base_style));
1343 current_col += 3;
1344
1345 let confirm_hovered = hover == SearchOptionsHover::ConfirmEach;
1346 let confirm_start = current_col;
1347 let confirm_label = format!("{} {}", confirm_checkbox, t!("search.confirm_each"));
1348 let confirm_shortcut_text = confirm_shortcut
1349 .as_ref()
1350 .map(|s| format!(" ({})", s))
1351 .unwrap_or_default();
1352 let confirm_full_width = str_width(&confirm_label) + str_width(&confirm_shortcut_text);
1353
1354 spans.push(Span::styled(
1355 confirm_label,
1356 get_checkbox_style(confirm_hovered, confirm_value),
1357 ));
1358 if !confirm_shortcut_text.is_empty() {
1359 spans.push(Span::styled(
1360 confirm_shortcut_text,
1361 if confirm_hovered {
1362 hover_shortcut_style
1363 } else {
1364 shortcut_style
1365 },
1366 ));
1367 }
1368 current_col += confirm_full_width as u16;
1369 layout.confirm_each = Some((confirm_start, current_col));
1370 }
1371
1372 let current_width = (current_col - area.x) as usize;
1374 let available_width = area.width as usize;
1375 if current_width < available_width {
1376 spans.push(Span::styled(
1377 " ".repeat(available_width.saturating_sub(current_width)),
1378 base_style,
1379 ));
1380 }
1381
1382 let options_line = Paragraph::new(Line::from(spans));
1383 frame.render_widget(options_line, area);
1384
1385 layout
1386 }
1387}
1388
1389#[cfg(test)]
1390mod tests {
1391 use super::*;
1392 use std::path::PathBuf;
1393
1394 #[test]
1395 fn test_truncate_path_short_path() {
1396 let path = PathBuf::from("/home/user/project");
1397 let result = truncate_path(&path, 50);
1398
1399 assert!(!result.truncated);
1400 assert_eq!(result.suffix, "/home/user/project");
1401 assert!(result.prefix.is_empty());
1402 }
1403
1404 #[test]
1405 fn test_truncate_path_long_path() {
1406 let path = PathBuf::from(
1407 "/private/var/folders/p6/nlmq3k8146990kpkxl73mq340000gn/T/.tmpNYt4Fc/project_root",
1408 );
1409 let result = truncate_path(&path, 40);
1410
1411 assert!(result.truncated, "Path should be truncated");
1412 assert_eq!(result.prefix, "/private");
1413 assert!(
1414 result.suffix.contains("project_root"),
1415 "Suffix should contain project_root"
1416 );
1417 }
1418
1419 #[test]
1420 fn test_truncate_path_preserves_last_components() {
1421 let path = PathBuf::from("/a/b/c/d/e/f/g/h/i/j/project/src");
1422 let result = truncate_path(&path, 30);
1423
1424 assert!(result.truncated);
1425 assert!(
1427 result.suffix.contains("src"),
1428 "Should preserve last component 'src', got: {}",
1429 result.suffix
1430 );
1431 }
1432
1433 #[test]
1434 fn test_truncate_path_display_len() {
1435 let path = PathBuf::from("/private/var/folders/deep/nested/path/here");
1436 let result = truncate_path(&path, 30);
1437
1438 let display = result.to_string_plain();
1440 assert!(
1441 display.len() <= 35, "Display should be truncated to around 30 chars, got {} chars: {}",
1443 display.len(),
1444 display
1445 );
1446 }
1447
1448 #[test]
1449 fn test_truncate_path_root_only() {
1450 let path = PathBuf::from("/");
1451 let result = truncate_path(&path, 50);
1452
1453 assert!(!result.truncated);
1454 assert_eq!(result.suffix, "/");
1455 }
1456
1457 #[test]
1458 fn test_truncated_path_to_string_plain() {
1459 let truncated = TruncatedPath {
1460 prefix: "/home".to_string(),
1461 truncated: true,
1462 suffix: "/project/src".to_string(),
1463 };
1464
1465 assert_eq!(truncated.to_string_plain(), "/home/[...]/project/src");
1466 }
1467
1468 #[test]
1469 fn test_truncated_path_to_string_plain_no_truncation() {
1470 let truncated = TruncatedPath {
1471 prefix: String::new(),
1472 truncated: false,
1473 suffix: "/home/user/project".to_string(),
1474 };
1475
1476 assert_eq!(truncated.to_string_plain(), "/home/user/project");
1477 }
1478}