1use std::path::Path;
4
5use crate::app::WarningLevel;
6use crate::primitives::display_width::{char_width, str_width};
7use crate::state::EditorState;
8use crate::view::prompt::Prompt;
9use ratatui::layout::Rect;
10use ratatui::style::{Modifier, Style};
11use ratatui::text::{Line, Span};
12use ratatui::widgets::Paragraph;
13use ratatui::Frame;
14use rust_i18n::t;
15
16#[derive(Debug, Clone, Default)]
18pub struct StatusBarLayout {
19 pub lsp_indicator: Option<(u16, u16, u16)>,
21 pub warning_badge: Option<(u16, u16, u16)>,
23 pub line_ending_indicator: Option<(u16, u16, u16)>,
25 pub encoding_indicator: Option<(u16, u16, u16)>,
27 pub language_indicator: Option<(u16, u16, u16)>,
29 pub message_area: Option<(u16, u16, u16)>,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
35pub enum StatusBarHover {
36 #[default]
37 None,
38 LspIndicator,
40 WarningBadge,
42 LineEndingIndicator,
44 EncodingIndicator,
46 LanguageIndicator,
48 MessageArea,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
54pub enum SearchOptionsHover {
55 #[default]
56 None,
57 CaseSensitive,
58 WholeWord,
59 Regex,
60 ConfirmEach,
61}
62
63#[derive(Debug, Clone, Default)]
65pub struct SearchOptionsLayout {
66 pub row: u16,
68 pub case_sensitive: Option<(u16, u16)>,
70 pub whole_word: Option<(u16, u16)>,
72 pub regex: Option<(u16, u16)>,
74 pub confirm_each: Option<(u16, u16)>,
76}
77
78impl SearchOptionsLayout {
79 pub fn checkbox_at(&self, x: u16, y: u16) -> Option<SearchOptionsHover> {
81 if y != self.row {
82 return None;
83 }
84
85 if let Some((start, end)) = self.case_sensitive {
86 if x >= start && x < end {
87 return Some(SearchOptionsHover::CaseSensitive);
88 }
89 }
90 if let Some((start, end)) = self.whole_word {
91 if x >= start && x < end {
92 return Some(SearchOptionsHover::WholeWord);
93 }
94 }
95 if let Some((start, end)) = self.regex {
96 if x >= start && x < end {
97 return Some(SearchOptionsHover::Regex);
98 }
99 }
100 if let Some((start, end)) = self.confirm_each {
101 if x >= start && x < end {
102 return Some(SearchOptionsHover::ConfirmEach);
103 }
104 }
105 None
106 }
107}
108
109#[derive(Debug, Clone)]
111pub struct TruncatedPath {
112 pub prefix: String,
114 pub truncated: bool,
116 pub suffix: String,
118}
119
120impl TruncatedPath {
121 pub fn to_string_plain(&self) -> String {
123 if self.truncated {
124 format!("{}/[...]{}", self.prefix, self.suffix)
125 } else {
126 format!("{}{}", self.prefix, self.suffix)
127 }
128 }
129
130 pub fn display_len(&self) -> usize {
132 if self.truncated {
133 self.prefix.len() + "/[...]".len() + self.suffix.len()
134 } else {
135 self.prefix.len() + self.suffix.len()
136 }
137 }
138}
139
140pub fn truncate_path(path: &Path, max_len: usize) -> TruncatedPath {
152 let path_str = path.to_string_lossy();
153
154 if path_str.len() <= max_len {
156 return TruncatedPath {
157 prefix: String::new(),
158 truncated: false,
159 suffix: path_str.to_string(),
160 };
161 }
162
163 let components: Vec<&str> = path_str.split('/').filter(|s| !s.is_empty()).collect();
164
165 if components.is_empty() {
166 return TruncatedPath {
167 prefix: "/".to_string(),
168 truncated: false,
169 suffix: String::new(),
170 };
171 }
172
173 let prefix = if path_str.starts_with('/') {
175 format!("/{}", components.first().unwrap_or(&""))
176 } else {
177 components.first().unwrap_or(&"").to_string()
178 };
179
180 let ellipsis_len = "/[...]".len();
182
183 let available_for_suffix = max_len.saturating_sub(prefix.len() + ellipsis_len);
185
186 if available_for_suffix < 5 || components.len() <= 1 {
187 let truncated_path = if path_str.len() > max_len.saturating_sub(3) {
189 format!("{}...", &path_str[..max_len.saturating_sub(3)])
190 } else {
191 path_str.to_string()
192 };
193 return TruncatedPath {
194 prefix: String::new(),
195 truncated: false,
196 suffix: truncated_path,
197 };
198 }
199
200 let mut suffix_parts: Vec<&str> = Vec::new();
202 let mut suffix_len = 0;
203
204 for component in components.iter().skip(1).rev() {
205 let component_len = component.len() + 1; if suffix_len + component_len <= available_for_suffix {
207 suffix_parts.push(component);
208 suffix_len += component_len;
209 } else {
210 break;
211 }
212 }
213
214 suffix_parts.reverse();
215
216 if suffix_parts.len() == components.len() - 1 {
218 return TruncatedPath {
219 prefix: String::new(),
220 truncated: false,
221 suffix: path_str.to_string(),
222 };
223 }
224
225 let suffix = if suffix_parts.is_empty() {
226 let last = components.last().unwrap_or(&"");
228 let truncate_to = available_for_suffix.saturating_sub(4); if truncate_to > 0 && last.len() > truncate_to {
230 format!("/{}...", &last[..truncate_to])
231 } else {
232 format!("/{}", last)
233 }
234 } else {
235 format!("/{}", suffix_parts.join("/"))
236 };
237
238 TruncatedPath {
239 prefix,
240 truncated: true,
241 suffix,
242 }
243}
244
245pub struct StatusBarRenderer;
247
248impl StatusBarRenderer {
249 #[allow(clippy::too_many_arguments)]
269 pub fn render_status_bar(
270 frame: &mut Frame,
271 area: Rect,
272 state: &mut EditorState,
273 cursors: &crate::model::cursor::Cursors,
274 status_message: &Option<String>,
275 plugin_status_message: &Option<String>,
276 lsp_status: &str,
277 theme: &crate::view::theme::Theme,
278 display_name: &str,
279 keybindings: &crate::input::keybindings::KeybindingResolver,
280 chord_state: &[(crossterm::event::KeyCode, crossterm::event::KeyModifiers)],
281 update_available: Option<&str>,
282 warning_level: WarningLevel,
283 general_warning_count: usize,
284 hover: StatusBarHover,
285 remote_connection: Option<&str>,
286 session_name: Option<&str>,
287 read_only: bool,
288 ) -> StatusBarLayout {
289 Self::render_status(
290 frame,
291 area,
292 state,
293 cursors,
294 status_message,
295 plugin_status_message,
296 lsp_status,
297 theme,
298 display_name,
299 keybindings,
300 chord_state,
301 update_available,
302 warning_level,
303 general_warning_count,
304 hover,
305 remote_connection,
306 session_name,
307 read_only,
308 )
309 }
310
311 pub fn render_prompt(
313 frame: &mut Frame,
314 area: Rect,
315 prompt: &Prompt,
316 theme: &crate::view::theme::Theme,
317 ) {
318 let base_style = Style::default().fg(theme.prompt_fg).bg(theme.prompt_bg);
319
320 let mut spans = vec![Span::styled(prompt.message.clone(), base_style)];
322
323 if let Some((sel_start, sel_end)) = prompt.selection_range() {
325 let input = &prompt.input;
326
327 if sel_start > 0 {
329 spans.push(Span::styled(input[..sel_start].to_string(), base_style));
330 }
331
332 if sel_start < sel_end {
334 let selection_style = Style::default()
336 .fg(theme.prompt_selection_fg)
337 .bg(theme.prompt_selection_bg);
338 spans.push(Span::styled(
339 input[sel_start..sel_end].to_string(),
340 selection_style,
341 ));
342 }
343
344 if sel_end < input.len() {
346 spans.push(Span::styled(input[sel_end..].to_string(), base_style));
347 }
348 } else {
349 spans.push(Span::styled(prompt.input.clone(), base_style));
351 }
352
353 let line = Line::from(spans);
354 let prompt_line = Paragraph::new(line).style(base_style);
355
356 frame.render_widget(prompt_line, area);
357
358 let message_width = str_width(&prompt.message);
363 let input_width_before_cursor = str_width(&prompt.input[..prompt.cursor_pos]);
364 let cursor_x = (message_width + input_width_before_cursor) as u16;
365 if cursor_x < area.width {
366 frame.set_cursor_position((area.x + cursor_x, area.y));
367 }
368 }
369
370 pub fn render_file_open_prompt(
374 frame: &mut Frame,
375 area: Rect,
376 prompt: &Prompt,
377 file_open_state: &crate::app::file_open::FileOpenState,
378 theme: &crate::view::theme::Theme,
379 ) {
380 let base_style = Style::default().fg(theme.prompt_fg).bg(theme.prompt_bg);
381 let dir_style = Style::default()
382 .fg(theme.help_separator_fg)
383 .bg(theme.prompt_bg);
384 let ellipsis_style = Style::default()
386 .fg(theme.menu_highlight_fg)
387 .bg(theme.prompt_bg);
388
389 let mut spans = Vec::new();
390
391 let open_prompt = t!("file.open_prompt").to_string();
393 spans.push(Span::styled(open_prompt.clone(), base_style));
394
395 let prefix_len = str_width(&open_prompt);
398 let dir_path = file_open_state.current_dir.to_string_lossy();
399 let dir_path_len = dir_path.len() + 1; let input_len = prompt.input.len();
401 let total_len = prefix_len + dir_path_len + input_len;
402 let threshold = (area.width as usize * 90) / 100;
403
404 let truncated = if total_len > threshold {
406 let available_for_path = threshold
408 .saturating_sub(prefix_len)
409 .saturating_sub(input_len);
410 truncate_path(&file_open_state.current_dir, available_for_path)
411 } else {
412 TruncatedPath {
414 prefix: String::new(),
415 truncated: false,
416 suffix: dir_path.to_string(),
417 }
418 };
419
420 if truncated.truncated {
422 spans.push(Span::styled(truncated.prefix.clone(), dir_style));
424 spans.push(Span::styled("/[...]", ellipsis_style));
426 let suffix_with_slash = if truncated.suffix.ends_with('/') {
428 truncated.suffix.clone()
429 } else {
430 format!("{}/", truncated.suffix)
431 };
432 spans.push(Span::styled(suffix_with_slash, dir_style));
433 } else {
434 let path_display = if truncated.suffix.ends_with('/') {
436 truncated.suffix.clone()
437 } else {
438 format!("{}/", truncated.suffix)
439 };
440 spans.push(Span::styled(path_display, dir_style));
441 }
442
443 spans.push(Span::styled(prompt.input.clone(), base_style));
445
446 let line = Line::from(spans);
447 let prompt_line = Paragraph::new(line).style(base_style);
448
449 frame.render_widget(prompt_line, area);
450
451 let prefix_width = str_width(&open_prompt);
455 let dir_display_width = if truncated.truncated {
456 let suffix_with_slash = if truncated.suffix.ends_with('/') {
457 &truncated.suffix
458 } else {
459 &truncated.suffix
461 };
462 str_width(&truncated.prefix) + str_width("/[...]") + str_width(suffix_with_slash) + 1
463 } else {
464 str_width(&truncated.suffix) + 1 };
466 let input_width_before_cursor = str_width(&prompt.input[..prompt.cursor_pos]);
467 let cursor_x = (prefix_width + dir_display_width + input_width_before_cursor) as u16;
468 if cursor_x < area.width {
469 frame.set_cursor_position((area.x + cursor_x, area.y));
470 }
471 }
472
473 #[allow(clippy::too_many_arguments)]
475 fn render_status(
476 frame: &mut Frame,
477 area: Rect,
478 state: &mut EditorState,
479 cursors: &crate::model::cursor::Cursors,
480 status_message: &Option<String>,
481 plugin_status_message: &Option<String>,
482 lsp_status: &str,
483 theme: &crate::view::theme::Theme,
484 display_name: &str,
485 keybindings: &crate::input::keybindings::KeybindingResolver,
486 chord_state: &[(crossterm::event::KeyCode, crossterm::event::KeyModifiers)],
487 update_available: Option<&str>,
488 warning_level: WarningLevel,
489 general_warning_count: usize,
490 hover: StatusBarHover,
491 remote_connection: Option<&str>,
492 session_name: Option<&str>,
493 read_only: bool,
494 ) -> StatusBarLayout {
495 let mut layout = StatusBarLayout::default();
497 let filename = display_name;
499
500 let modified = if state.buffer.is_modified() {
501 " [+]"
502 } else {
503 ""
504 };
505
506 let read_only_indicator = if read_only { " [RO]" } else { "" };
507
508 let chord_display = if !chord_state.is_empty() {
510 let chord_str = chord_state
511 .iter()
512 .map(|(code, modifiers)| {
513 crate::input::keybindings::format_keybinding(code, modifiers)
514 })
515 .collect::<Vec<_>>()
516 .join(" ");
517 format!(" [{}]", chord_str)
518 } else {
519 String::new()
520 };
521
522 let cursor = *cursors.primary();
526
527 let (line, col) = {
529 let cursor_iter = state.buffer.line_iterator(cursor.position, 80);
531 let line_start = cursor_iter.current_position();
532 let col = cursor.position.saturating_sub(line_start);
533
534 let line_num = state.primary_cursor_line_number.value();
536 (line_num, col)
537 };
538
539 let diagnostics = state.overlays.all();
541 let mut error_count = 0;
542 let mut warning_count = 0;
543 let mut info_count = 0;
544
545 let diagnostic_ns = crate::services::lsp::diagnostics::lsp_diagnostic_namespace();
547 for overlay in diagnostics {
548 if overlay.namespace.as_ref() == Some(&diagnostic_ns) {
549 match overlay.priority {
552 100 => error_count += 1,
553 50 => warning_count += 1,
554 _ => info_count += 1,
555 }
556 }
557 }
558
559 let diagnostics_summary = if error_count + warning_count + info_count > 0 {
561 let mut parts = Vec::new();
562 if error_count > 0 {
563 parts.push(format!("E:{}", error_count));
564 }
565 if warning_count > 0 {
566 parts.push(format!("W:{}", warning_count));
567 }
568 if info_count > 0 {
569 parts.push(format!("I:{}", info_count));
570 }
571 format!(" | {}", parts.join(" "))
572 } else {
573 String::new()
574 };
575
576 let cursor_count_indicator = if cursors.count() > 1 {
578 format!(" | {}", t!("status.cursors", count = cursors.count()))
579 } else {
580 String::new()
581 };
582
583 let mut message_parts: Vec<&str> = Vec::new();
585 if let Some(msg) = status_message {
586 if !msg.is_empty() {
587 message_parts.push(msg);
588 }
589 }
590 if let Some(msg) = plugin_status_message {
591 if !msg.is_empty() {
592 message_parts.push(msg);
593 }
594 }
595
596 let message_suffix = if message_parts.is_empty() {
597 String::new()
598 } else {
599 format!(" | {}", message_parts.join(" | "))
600 };
601
602 let remote_prefix = remote_connection
607 .map(|conn| format!("[SSH:{}] ", conn))
608 .unwrap_or_default();
609 let session_prefix = session_name
610 .map(|name| format!("[{}] ", name))
611 .unwrap_or_default();
612 let byte_offset_mode = state.buffer.line_count().is_none();
613 let base_status = if state.show_cursors {
614 if byte_offset_mode {
615 format!(
616 "{session_prefix}{remote_prefix}{filename}{modified}{read_only_indicator} | Byte {}{diagnostics_summary}{cursor_count_indicator}",
617 cursor.position
618 )
619 } else {
620 format!(
621 "{session_prefix}{remote_prefix}{filename}{modified}{read_only_indicator} | Ln {}, Col {}{diagnostics_summary}{cursor_count_indicator}",
622 line + 1,
623 col + 1
624 )
625 }
626 } else {
627 format!("{session_prefix}{remote_prefix}{filename}{modified}{read_only_indicator}{diagnostics_summary}")
629 };
630
631 let base_and_chord_width = str_width(&base_status) + str_width(&chord_display);
633 let message_width = str_width(&message_suffix);
634
635 let left_status = format!("{base_status}{chord_display}{message_suffix}");
636
637 let line_ending_text = format!(" {} ", state.buffer.line_ending().display_name());
643 let line_ending_width = str_width(&line_ending_text);
644
645 let encoding = state.buffer.encoding();
647 let encoding_text = format!(" {} ", encoding.display_name());
648 let encoding_width = str_width(&encoding_text);
649
650 let language_text = format!(" {} ", &state.language);
652 let language_width = str_width(&language_text);
653
654 let lsp_indicator = if !lsp_status.is_empty() {
656 format!(" {} ", lsp_status)
657 } else {
658 String::new()
659 };
660 let lsp_indicator_width = str_width(&lsp_indicator);
661
662 let warning_badge = if general_warning_count > 0 {
664 format!(" [⚠ {}] ", general_warning_count)
665 } else {
666 String::new()
667 };
668 let warning_badge_width = str_width(&warning_badge);
669
670 let update_indicator = update_available
672 .map(|version| format!(" {} ", t!("status.update_available", version = version)));
673 let update_width = update_indicator.as_ref().map(|s| s.len()).unwrap_or(0);
674
675 let cmd_palette_shortcut = keybindings
678 .get_keybinding_for_action(
679 &crate::input::keybindings::Action::QuickOpen,
680 crate::input::keybindings::KeyContext::Global,
681 )
682 .unwrap_or_else(|| "?".to_string());
683 let cmd_palette_indicator = t!("status.palette", shortcut = cmd_palette_shortcut);
684 let padded_cmd_palette = format!(" {} ", cmd_palette_indicator);
685
686 let available_width = area.width as usize;
689 let cmd_palette_width = str_width(&padded_cmd_palette);
690 let right_side_width = line_ending_width
691 + encoding_width
692 + language_width
693 + lsp_indicator_width
694 + warning_badge_width
695 + update_width
696 + cmd_palette_width;
697
698 let spans = if available_width >= 15 {
700 let left_max_width = if available_width > right_side_width + 1 {
702 available_width - right_side_width - 1 } else {
704 1 };
706
707 let mut spans = vec![];
708
709 let left_visual_width = str_width(&left_status);
711 let displayed_left = if left_visual_width > left_max_width {
712 let truncate_at = left_max_width.saturating_sub(3); if truncate_at > 0 {
714 let mut width = 0;
716 let truncated: String = left_status
717 .chars()
718 .take_while(|ch| {
719 let w = char_width(*ch);
720 if width + w <= truncate_at {
721 width += w;
722 true
723 } else {
724 false
725 }
726 })
727 .collect();
728 format!("{}...", truncated)
729 } else {
730 String::from("...")
731 }
732 } else {
733 left_status.clone()
734 };
735
736 let displayed_left_len = str_width(&displayed_left);
737
738 if message_width > 0 {
740 let msg_start = base_and_chord_width.min(displayed_left_len);
742 let msg_end = displayed_left_len;
743 if msg_end > msg_start {
744 layout.message_area =
745 Some((area.y, area.x + msg_start as u16, area.x + msg_end as u16));
746 }
747 }
748
749 spans.push(Span::styled(
750 displayed_left.clone(),
751 Style::default()
752 .fg(theme.status_bar_fg)
753 .bg(theme.status_bar_bg),
754 ));
755
756 if displayed_left_len + right_side_width < available_width {
758 let padding_len = available_width - displayed_left_len - right_side_width;
759 spans.push(Span::styled(
760 " ".repeat(padding_len),
761 Style::default()
762 .fg(theme.status_bar_fg)
763 .bg(theme.status_bar_bg),
764 ));
765 } else if displayed_left_len < available_width {
766 spans.push(Span::styled(
768 " ",
769 Style::default()
770 .fg(theme.status_bar_fg)
771 .bg(theme.status_bar_bg),
772 ));
773 }
774
775 let mut current_col = area.x + displayed_left_len as u16;
777 if displayed_left_len + right_side_width < available_width {
778 current_col = area.x + (available_width - right_side_width) as u16;
779 }
780
781 {
783 let is_hovering = hover == StatusBarHover::LineEndingIndicator;
784 layout.line_ending_indicator =
786 Some((area.y, current_col, current_col + line_ending_width as u16));
787 let (fg, bg) = if is_hovering {
788 (theme.menu_hover_fg, theme.menu_hover_bg)
789 } else {
790 (theme.status_bar_fg, theme.status_bar_bg)
791 };
792 let mut style = Style::default().fg(fg).bg(bg);
793 if is_hovering {
794 style = style.add_modifier(Modifier::UNDERLINED);
795 }
796 spans.push(Span::styled(line_ending_text.clone(), style));
797 current_col += line_ending_width as u16;
798 }
799
800 {
802 let is_hovering = hover == StatusBarHover::EncodingIndicator;
803 layout.encoding_indicator =
805 Some((area.y, current_col, current_col + encoding_width as u16));
806 let (fg, bg) = if is_hovering {
807 (theme.menu_hover_fg, theme.menu_hover_bg)
808 } else {
809 (theme.status_bar_fg, theme.status_bar_bg)
810 };
811 let mut style = Style::default().fg(fg).bg(bg);
812 if is_hovering {
813 style = style.add_modifier(Modifier::UNDERLINED);
814 }
815 spans.push(Span::styled(encoding_text.clone(), style));
816 current_col += encoding_width as u16;
817 }
818
819 {
821 let is_hovering = hover == StatusBarHover::LanguageIndicator;
822 layout.language_indicator =
824 Some((area.y, current_col, current_col + language_width as u16));
825 let (fg, bg) = if is_hovering {
826 (theme.menu_hover_fg, theme.menu_hover_bg)
827 } else {
828 (theme.status_bar_fg, theme.status_bar_bg)
829 };
830 let mut style = Style::default().fg(fg).bg(bg);
831 if is_hovering {
832 style = style.add_modifier(Modifier::UNDERLINED);
833 }
834 spans.push(Span::styled(language_text.clone(), style));
835 current_col += language_width as u16;
836 }
837
838 if !lsp_indicator.is_empty() {
840 let is_hovering = hover == StatusBarHover::LspIndicator;
841 let (lsp_fg, lsp_bg) = match (warning_level, is_hovering) {
842 (WarningLevel::Error, true) => (
843 theme.status_error_indicator_hover_fg,
844 theme.status_error_indicator_hover_bg,
845 ),
846 (WarningLevel::Error, false) => (
847 theme.status_error_indicator_fg,
848 theme.status_error_indicator_bg,
849 ),
850 (WarningLevel::Warning, true) => (
851 theme.status_warning_indicator_hover_fg,
852 theme.status_warning_indicator_hover_bg,
853 ),
854 (WarningLevel::Warning, false) => (
855 theme.status_warning_indicator_fg,
856 theme.status_warning_indicator_bg,
857 ),
858 (WarningLevel::None, _) => (theme.status_bar_fg, theme.status_bar_bg),
859 };
860 layout.lsp_indicator = Some((
862 area.y,
863 current_col,
864 current_col + lsp_indicator_width as u16,
865 ));
866 current_col += lsp_indicator_width as u16;
867 let mut style = Style::default().fg(lsp_fg).bg(lsp_bg);
868 if is_hovering && warning_level != WarningLevel::None {
869 style = style.add_modifier(Modifier::UNDERLINED);
870 }
871 spans.push(Span::styled(lsp_indicator.clone(), style));
872 }
873
874 if !warning_badge.is_empty() {
876 let is_hovering = hover == StatusBarHover::WarningBadge;
877 layout.warning_badge = Some((
879 area.y,
880 current_col,
881 current_col + warning_badge_width as u16,
882 ));
883 current_col += warning_badge_width as u16;
884 let (fg, bg) = if is_hovering {
885 (
886 theme.status_warning_indicator_hover_fg,
887 theme.status_warning_indicator_hover_bg,
888 )
889 } else {
890 (
891 theme.status_warning_indicator_fg,
892 theme.status_warning_indicator_bg,
893 )
894 };
895 let mut style = Style::default().fg(fg).bg(bg);
896 if is_hovering {
897 style = style.add_modifier(Modifier::UNDERLINED);
898 }
899 spans.push(Span::styled(warning_badge.clone(), style));
900 }
901 let _ = current_col;
903
904 if let Some(ref update_text) = update_indicator {
906 spans.push(Span::styled(
907 update_text.clone(),
908 Style::default()
909 .fg(theme.menu_highlight_fg)
910 .bg(theme.menu_dropdown_bg),
911 ));
912 }
913
914 spans.push(Span::styled(
916 padded_cmd_palette.clone(),
917 Style::default()
918 .fg(theme.help_indicator_fg)
919 .bg(theme.help_indicator_bg),
920 ));
921
922 spans
923 } else {
924 let mut spans = vec![];
926 let left_visual_width = str_width(&left_status);
927 let displayed_left = if left_visual_width > available_width {
928 let truncate_at = available_width.saturating_sub(3);
929 if truncate_at > 0 {
930 let mut width = 0;
932 let truncated: String = left_status
933 .chars()
934 .take_while(|ch| {
935 let w = char_width(*ch);
936 if width + w <= truncate_at {
937 width += w;
938 true
939 } else {
940 false
941 }
942 })
943 .collect();
944 format!("{}...", truncated)
945 } else {
946 let mut width = 0;
948 left_status
949 .chars()
950 .take_while(|ch| {
951 let w = char_width(*ch);
952 if width + w <= available_width {
953 width += w;
954 true
955 } else {
956 false
957 }
958 })
959 .collect()
960 }
961 } else {
962 left_status.clone()
963 };
964
965 spans.push(Span::styled(
966 displayed_left.clone(),
967 Style::default()
968 .fg(theme.status_bar_fg)
969 .bg(theme.status_bar_bg),
970 ));
971
972 if displayed_left.len() < available_width {
974 spans.push(Span::styled(
975 " ".repeat(available_width - displayed_left.len()),
976 Style::default()
977 .fg(theme.status_bar_fg)
978 .bg(theme.status_bar_bg),
979 ));
980 }
981
982 spans
983 };
984
985 let status_line = Paragraph::new(Line::from(spans));
986
987 frame.render_widget(status_line, area);
988
989 layout
990 }
991
992 #[allow(clippy::too_many_arguments)]
1003 pub fn render_search_options(
1004 frame: &mut Frame,
1005 area: Rect,
1006 case_sensitive: bool,
1007 whole_word: bool,
1008 use_regex: bool,
1009 confirm_each: Option<bool>, theme: &crate::view::theme::Theme,
1011 keybindings: &crate::input::keybindings::KeybindingResolver,
1012 hover: SearchOptionsHover,
1013 ) -> SearchOptionsLayout {
1014 use crate::primitives::display_width::str_width;
1015
1016 let mut layout = SearchOptionsLayout {
1017 row: area.y,
1018 ..Default::default()
1019 };
1020
1021 let base_style = Style::default()
1023 .fg(theme.menu_dropdown_fg)
1024 .bg(theme.menu_dropdown_bg);
1025
1026 let hover_style = Style::default()
1028 .fg(theme.menu_hover_fg)
1029 .bg(theme.menu_hover_bg);
1030
1031 let get_shortcut = |action: &crate::input::keybindings::Action| -> Option<String> {
1033 keybindings
1034 .get_keybinding_for_action(action, crate::input::keybindings::KeyContext::Prompt)
1035 .or_else(|| {
1036 keybindings.get_keybinding_for_action(
1037 action,
1038 crate::input::keybindings::KeyContext::Global,
1039 )
1040 })
1041 };
1042
1043 let case_shortcut =
1045 get_shortcut(&crate::input::keybindings::Action::ToggleSearchCaseSensitive);
1046 let word_shortcut = get_shortcut(&crate::input::keybindings::Action::ToggleSearchWholeWord);
1047 let regex_shortcut = get_shortcut(&crate::input::keybindings::Action::ToggleSearchRegex);
1048
1049 let case_checkbox = if case_sensitive { "[x]" } else { "[ ]" };
1051 let word_checkbox = if whole_word { "[x]" } else { "[ ]" };
1052 let regex_checkbox = if use_regex { "[x]" } else { "[ ]" };
1053
1054 let active_style = Style::default()
1056 .fg(theme.menu_highlight_fg)
1057 .bg(theme.menu_dropdown_bg);
1058
1059 let shortcut_style = Style::default()
1061 .fg(theme.help_separator_fg)
1062 .bg(theme.menu_dropdown_bg);
1063
1064 let hover_shortcut_style = Style::default()
1066 .fg(theme.menu_hover_fg)
1067 .bg(theme.menu_hover_bg);
1068
1069 let mut spans = Vec::new();
1070 let mut current_col = area.x;
1071
1072 spans.push(Span::styled(" ", base_style));
1074 current_col += 1;
1075
1076 let get_checkbox_style = |is_hovered: bool, is_checked: bool| -> Style {
1078 if is_hovered {
1079 hover_style
1080 } else if is_checked {
1081 active_style
1082 } else {
1083 base_style
1084 }
1085 };
1086
1087 let case_hovered = hover == SearchOptionsHover::CaseSensitive;
1089 let case_start = current_col;
1090 let case_label = format!("{} {}", case_checkbox, t!("search.case_sensitive"));
1091 let case_shortcut_text = case_shortcut
1092 .as_ref()
1093 .map(|s| format!(" ({})", s))
1094 .unwrap_or_default();
1095 let case_full_width = str_width(&case_label) + str_width(&case_shortcut_text);
1096
1097 spans.push(Span::styled(
1098 case_label,
1099 get_checkbox_style(case_hovered, case_sensitive),
1100 ));
1101 if !case_shortcut_text.is_empty() {
1102 spans.push(Span::styled(
1103 case_shortcut_text,
1104 if case_hovered {
1105 hover_shortcut_style
1106 } else {
1107 shortcut_style
1108 },
1109 ));
1110 }
1111 current_col += case_full_width as u16;
1112 layout.case_sensitive = Some((case_start, current_col));
1113
1114 spans.push(Span::styled(" ", base_style));
1116 current_col += 3;
1117
1118 let word_hovered = hover == SearchOptionsHover::WholeWord;
1120 let word_start = current_col;
1121 let word_label = format!("{} {}", word_checkbox, t!("search.whole_word"));
1122 let word_shortcut_text = word_shortcut
1123 .as_ref()
1124 .map(|s| format!(" ({})", s))
1125 .unwrap_or_default();
1126 let word_full_width = str_width(&word_label) + str_width(&word_shortcut_text);
1127
1128 spans.push(Span::styled(
1129 word_label,
1130 get_checkbox_style(word_hovered, whole_word),
1131 ));
1132 if !word_shortcut_text.is_empty() {
1133 spans.push(Span::styled(
1134 word_shortcut_text,
1135 if word_hovered {
1136 hover_shortcut_style
1137 } else {
1138 shortcut_style
1139 },
1140 ));
1141 }
1142 current_col += word_full_width as u16;
1143 layout.whole_word = Some((word_start, current_col));
1144
1145 spans.push(Span::styled(" ", base_style));
1147 current_col += 3;
1148
1149 let regex_hovered = hover == SearchOptionsHover::Regex;
1151 let regex_start = current_col;
1152 let regex_label = format!("{} {}", regex_checkbox, t!("search.regex"));
1153 let regex_shortcut_text = regex_shortcut
1154 .as_ref()
1155 .map(|s| format!(" ({})", s))
1156 .unwrap_or_default();
1157 let regex_full_width = str_width(®ex_label) + str_width(®ex_shortcut_text);
1158
1159 spans.push(Span::styled(
1160 regex_label,
1161 get_checkbox_style(regex_hovered, use_regex),
1162 ));
1163 if !regex_shortcut_text.is_empty() {
1164 spans.push(Span::styled(
1165 regex_shortcut_text,
1166 if regex_hovered {
1167 hover_shortcut_style
1168 } else {
1169 shortcut_style
1170 },
1171 ));
1172 }
1173 current_col += regex_full_width as u16;
1174 layout.regex = Some((regex_start, current_col));
1175
1176 if use_regex && confirm_each.is_some() {
1178 let hint = " \u{2502} $1,$2,…";
1179 spans.push(Span::styled(hint, shortcut_style));
1180 current_col += str_width(hint) as u16;
1181 }
1182
1183 if let Some(confirm_value) = confirm_each {
1185 let confirm_shortcut =
1186 get_shortcut(&crate::input::keybindings::Action::ToggleSearchConfirmEach);
1187 let confirm_checkbox = if confirm_value { "[x]" } else { "[ ]" };
1188
1189 spans.push(Span::styled(" ", base_style));
1191 current_col += 3;
1192
1193 let confirm_hovered = hover == SearchOptionsHover::ConfirmEach;
1194 let confirm_start = current_col;
1195 let confirm_label = format!("{} {}", confirm_checkbox, t!("search.confirm_each"));
1196 let confirm_shortcut_text = confirm_shortcut
1197 .as_ref()
1198 .map(|s| format!(" ({})", s))
1199 .unwrap_or_default();
1200 let confirm_full_width = str_width(&confirm_label) + str_width(&confirm_shortcut_text);
1201
1202 spans.push(Span::styled(
1203 confirm_label,
1204 get_checkbox_style(confirm_hovered, confirm_value),
1205 ));
1206 if !confirm_shortcut_text.is_empty() {
1207 spans.push(Span::styled(
1208 confirm_shortcut_text,
1209 if confirm_hovered {
1210 hover_shortcut_style
1211 } else {
1212 shortcut_style
1213 },
1214 ));
1215 }
1216 current_col += confirm_full_width as u16;
1217 layout.confirm_each = Some((confirm_start, current_col));
1218 }
1219
1220 let current_width = (current_col - area.x) as usize;
1222 let available_width = area.width as usize;
1223 if current_width < available_width {
1224 spans.push(Span::styled(
1225 " ".repeat(available_width.saturating_sub(current_width)),
1226 base_style,
1227 ));
1228 }
1229
1230 let options_line = Paragraph::new(Line::from(spans));
1231 frame.render_widget(options_line, area);
1232
1233 layout
1234 }
1235}
1236
1237#[cfg(test)]
1238mod tests {
1239 use super::*;
1240 use std::path::PathBuf;
1241
1242 #[test]
1243 fn test_truncate_path_short_path() {
1244 let path = PathBuf::from("/home/user/project");
1245 let result = truncate_path(&path, 50);
1246
1247 assert!(!result.truncated);
1248 assert_eq!(result.suffix, "/home/user/project");
1249 assert!(result.prefix.is_empty());
1250 }
1251
1252 #[test]
1253 fn test_truncate_path_long_path() {
1254 let path = PathBuf::from(
1255 "/private/var/folders/p6/nlmq3k8146990kpkxl73mq340000gn/T/.tmpNYt4Fc/project_root",
1256 );
1257 let result = truncate_path(&path, 40);
1258
1259 assert!(result.truncated, "Path should be truncated");
1260 assert_eq!(result.prefix, "/private");
1261 assert!(
1262 result.suffix.contains("project_root"),
1263 "Suffix should contain project_root"
1264 );
1265 }
1266
1267 #[test]
1268 fn test_truncate_path_preserves_last_components() {
1269 let path = PathBuf::from("/a/b/c/d/e/f/g/h/i/j/project/src");
1270 let result = truncate_path(&path, 30);
1271
1272 assert!(result.truncated);
1273 assert!(
1275 result.suffix.contains("src"),
1276 "Should preserve last component 'src', got: {}",
1277 result.suffix
1278 );
1279 }
1280
1281 #[test]
1282 fn test_truncate_path_display_len() {
1283 let path = PathBuf::from("/private/var/folders/deep/nested/path/here");
1284 let result = truncate_path(&path, 30);
1285
1286 let display = result.to_string_plain();
1288 assert!(
1289 display.len() <= 35, "Display should be truncated to around 30 chars, got {} chars: {}",
1291 display.len(),
1292 display
1293 );
1294 }
1295
1296 #[test]
1297 fn test_truncate_path_root_only() {
1298 let path = PathBuf::from("/");
1299 let result = truncate_path(&path, 50);
1300
1301 assert!(!result.truncated);
1302 assert_eq!(result.suffix, "/");
1303 }
1304
1305 #[test]
1306 fn test_truncated_path_to_string_plain() {
1307 let truncated = TruncatedPath {
1308 prefix: "/home".to_string(),
1309 truncated: true,
1310 suffix: "/project/src".to_string(),
1311 };
1312
1313 assert_eq!(truncated.to_string_plain(), "/home/[...]/project/src");
1314 }
1315
1316 #[test]
1317 fn test_truncated_path_to_string_plain_no_truncation() {
1318 let truncated = TruncatedPath {
1319 prefix: String::new(),
1320 truncated: false,
1321 suffix: "/home/user/project".to_string(),
1322 };
1323
1324 assert_eq!(truncated.to_string_plain(), "/home/user/project");
1325 }
1326}