Skip to main content

fresh/view/ui/
status_bar.rs

1//! Status bar and prompt/minibuffer rendering
2
3use 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/// Layout information returned from status bar rendering for mouse click detection
17#[derive(Debug, Clone, Default)]
18pub struct StatusBarLayout {
19    /// LSP indicator area (row, start_col, end_col) - None if no LSP indicator shown
20    pub lsp_indicator: Option<(u16, u16, u16)>,
21    /// Warning badge area (row, start_col, end_col) - None if no warnings
22    pub warning_badge: Option<(u16, u16, u16)>,
23    /// Line ending indicator area (row, start_col, end_col)
24    pub line_ending_indicator: Option<(u16, u16, u16)>,
25    /// Encoding indicator area (row, start_col, end_col)
26    pub encoding_indicator: Option<(u16, u16, u16)>,
27    /// Language indicator area (row, start_col, end_col)
28    pub language_indicator: Option<(u16, u16, u16)>,
29    /// Status message area (row, start_col, end_col) - clickable to show full history
30    pub message_area: Option<(u16, u16, u16)>,
31}
32
33/// Status bar hover state for styling clickable indicators
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
35pub enum StatusBarHover {
36    #[default]
37    None,
38    /// Mouse is over the LSP indicator
39    LspIndicator,
40    /// Mouse is over the warning badge
41    WarningBadge,
42    /// Mouse is over the line ending indicator
43    LineEndingIndicator,
44    /// Mouse is over the encoding indicator
45    EncodingIndicator,
46    /// Mouse is over the language indicator
47    LanguageIndicator,
48    /// Mouse is over the status message area
49    MessageArea,
50}
51
52/// Which search option checkbox is being hovered
53#[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/// Layout information for search options bar hit testing
64#[derive(Debug, Clone, Default)]
65pub struct SearchOptionsLayout {
66    /// Row where the search options are rendered
67    pub row: u16,
68    /// Case Sensitive checkbox area (start_col, end_col)
69    pub case_sensitive: Option<(u16, u16)>,
70    /// Whole Word checkbox area (start_col, end_col)
71    pub whole_word: Option<(u16, u16)>,
72    /// Regex checkbox area (start_col, end_col)
73    pub regex: Option<(u16, u16)>,
74    /// Confirm Each checkbox area (start_col, end_col) - only present in replace mode
75    pub confirm_each: Option<(u16, u16)>,
76}
77
78impl SearchOptionsLayout {
79    /// Check which search option checkbox (if any) is at the given position
80    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/// Result of truncating a path for display
110#[derive(Debug, Clone)]
111pub struct TruncatedPath {
112    /// The first component of the path (e.g., "/home" or "C:\")
113    pub prefix: String,
114    /// Whether truncation occurred (if true, display "[...]" between prefix and suffix)
115    pub truncated: bool,
116    /// The last components of the path (e.g., "project/src")
117    pub suffix: String,
118}
119
120impl TruncatedPath {
121    /// Get the full display string (without styling)
122    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    /// Get the display length
131    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
140/// Truncate a path for display, showing the first component, [...], and last components
141///
142/// For example, `/private/var/folders/p6/nlmq.../T/.tmpNYt4Fc/project/file.txt`
143/// becomes `/private/[...]/project/file.txt`
144///
145/// # Arguments
146/// * `path` - The path to truncate
147/// * `max_len` - Maximum length for the display string
148///
149/// # Returns
150/// A TruncatedPath struct with prefix, truncation indicator, and suffix
151pub fn truncate_path(path: &Path, max_len: usize) -> TruncatedPath {
152    let path_str = path.to_string_lossy();
153
154    // If path fits, return as-is
155    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    // Always keep the root and first component as prefix
174    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    // The "[...]/" takes 6 characters
181    let ellipsis_len = "/[...]".len();
182
183    // Calculate how much space we have for the suffix
184    let available_for_suffix = max_len.saturating_sub(prefix.len() + ellipsis_len);
185
186    if available_for_suffix < 5 || components.len() <= 1 {
187        // Not enough space or only one component, just truncate the end
188        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    // Build suffix from the last components that fit
201    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; // +1 for the '/'
206        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 we included all remaining components, no truncation needed
217    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        // Can't fit any suffix components, truncate the last component
227        let last = components.last().unwrap_or(&"");
228        let truncate_to = available_for_suffix.saturating_sub(4); // "/.." and some chars
229        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
245/// Renders the status bar and prompt/minibuffer
246pub struct StatusBarRenderer;
247
248impl StatusBarRenderer {
249    /// Render only the status bar (without prompt)
250    ///
251    /// # Arguments
252    /// * `frame` - The ratatui frame to render to
253    /// * `area` - The rectangular area to render in
254    /// * `state` - The active buffer's editor state
255    /// * `status_message` - Optional status message to display
256    /// * `lsp_status` - LSP status indicator
257    /// * `theme` - The active theme for colors
258    /// * `display_name` - The display name for the file (project-relative path)
259    /// * `chord_state` - Current chord sequence state (for multi-key bindings)
260    /// * `update_available` - Optional new version string if an update is available
261    /// * `warning_level` - LSP warning level (for coloring LSP indicator)
262    /// * `general_warning_count` - Number of general warnings (for badge display)
263    /// * `remote_connection` - Optional remote connection info (e.g., "user@host")
264    /// * `session_name` - Optional session name (for session persistence mode)
265    ///
266    /// # Returns
267    /// Layout information with positions of clickable indicators
268    #[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    /// Render the prompt/minibuffer
312    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        // Create spans for the prompt
321        let mut spans = vec![Span::styled(prompt.message.clone(), base_style)];
322
323        // If there's a selection, split the input into parts
324        if let Some((sel_start, sel_end)) = prompt.selection_range() {
325            let input = &prompt.input;
326
327            // Text before selection
328            if sel_start > 0 {
329                spans.push(Span::styled(input[..sel_start].to_string(), base_style));
330            }
331
332            // Selected text (blue background for visibility, cursor remains visible)
333            if sel_start < sel_end {
334                // Use theme colors for selection to ensure consistency across themes
335                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            // Text after selection
345            if sel_end < input.len() {
346                spans.push(Span::styled(input[sel_end..].to_string(), base_style));
347            }
348        } else {
349            // No selection, render entire input normally
350            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        // Set cursor position in the prompt
359        // Use display width (not byte length) for proper handling of:
360        // - Double-width CJK characters
361        // - Zero-width combining characters (Thai diacritics, etc.)
362        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    /// Render the file open prompt with colorized path
371    /// Shows: "Open: /path/to/current/dir/filename" where the directory part is dimmed
372    /// Long paths are truncated: "/private/[...]/project/" with [...] styled differently
373    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        // Style for the [...] ellipsis - use a more visible color
385        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        // "Open: " prefix
392        let open_prompt = t!("file.open_prompt").to_string();
393        spans.push(Span::styled(open_prompt.clone(), base_style));
394
395        // Calculate if we need to truncate
396        // Only truncate if full path + input exceeds 90% of available width
397        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; // +1 for trailing slash
400        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        // Truncate the path only if total length exceeds 90% of width
405        let truncated = if total_len > threshold {
406            // Calculate how much space we have for the path after truncation
407            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            // No truncation needed - return full path
413            TruncatedPath {
414                prefix: String::new(),
415                truncated: false,
416                suffix: dir_path.to_string(),
417            }
418        };
419
420        // Build the directory display with separate spans for styling
421        if truncated.truncated {
422            // Prefix (dimmed)
423            spans.push(Span::styled(truncated.prefix.clone(), dir_style));
424            // Ellipsis "/[...]" (highlighted)
425            spans.push(Span::styled("/[...]", ellipsis_style));
426            // Suffix with trailing slash (dimmed)
427            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            // No truncation - just show the path with trailing slash
435            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        // User input (the filename part) - normal color
444        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        // Set cursor position in the prompt
452        // Use display width for proper handling of Unicode characters
453        // We need to calculate the visual width of: "Open: " + dir_display + input[..cursor_pos]
454        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                // We already added "/" in the suffix_with_slash above, so approximate
460                &truncated.suffix
461            };
462            str_width(&truncated.prefix) + str_width("/[...]") + str_width(suffix_with_slash) + 1
463        } else {
464            str_width(&truncated.suffix) + 1 // +1 for trailing slash
465        };
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    /// Render the normal status bar
474    #[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        // Initialize layout tracking
496        let mut layout = StatusBarLayout::default();
497        // Use the pre-computed display name from buffer metadata
498        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        // Format chord state if present
509        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        // View mode indicator (view_mode now lives in SplitViewState/BufferViewState)
523        // Not available here — status bar shows only buffer-level info.
524
525        let cursor = *cursors.primary();
526
527        // Get line number and column efficiently using cached values
528        let (line, col) = {
529            // Find the start of the line containing the cursor
530            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            // Use cached line number from state
535            let line_num = state.primary_cursor_line_number.value();
536            (line_num, col)
537        };
538
539        // Count diagnostics by severity
540        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        // Use the lsp-diagnostic namespace to identify diagnostic overlays
546        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                // Check priority to determine severity
550                // Based on lsp_diagnostics.rs: Error=100, Warning=50, Info=30, Hint=10
551                match overlay.priority {
552                    100 => error_count += 1,
553                    50 => warning_count += 1,
554                    _ => info_count += 1,
555                }
556            }
557        }
558
559        // Build diagnostics summary if there are any
560        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        // Build cursor count indicator (only show if multiple cursors)
577        let cursor_count_indicator = if cursors.count() > 1 {
578            format!(" | {}", t!("status.cursors", count = cursors.count()))
579        } else {
580            String::new()
581        };
582
583        // Build status message parts
584        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        // Build left status (file info, position, diagnostics, messages)
603        // Session/Remote indicator comes first (if present), then filename
604        // Line and column are 0-indexed internally, but displayed as 1-indexed (standard editor convention)
605        // For virtual buffers with hidden cursors, don't show line/column info
606        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            // Virtual buffer - just show filename and modified indicator
628            format!("{session_prefix}{remote_prefix}{filename}{modified}{read_only_indicator}{diagnostics_summary}")
629        };
630
631        // Track where the message starts for click detection
632        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        // Build right-side indicators (these stay fixed on the right)
638        // Order: [Line ending] [Language] [LSP indicator] [warning badge] [update] [Palette]
639        // Note: Remote indicator is now on the left side, before the filename
640
641        // Line ending indicator (clickable to change format)
642        let line_ending_text = format!(" {} ", state.buffer.line_ending().display_name());
643        let line_ending_width = str_width(&line_ending_text);
644
645        // Encoding indicator (clickable to change encoding)
646        let encoding = state.buffer.encoding();
647        let encoding_text = format!(" {} ", encoding.display_name());
648        let encoding_width = str_width(&encoding_text);
649
650        // Language indicator (clickable to change language)
651        let language_text = format!(" {} ", &state.language);
652        let language_width = str_width(&language_text);
653
654        // LSP indicator (right-aligned, with colored background if warning/error)
655        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        // General warning badge (right-aligned)
663        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        // Build update indicator for right side (if update available)
671        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        // Build Quick Open / Command Palette indicator for right side
676        // Always show the indicator on the right side
677        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        // Calculate available width and right side width
687        // Right side: [Line ending] [Encoding] [Language] [LSP indicator] [warning badge] [update] [Palette]
688        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        // Only show command palette indicator if there's enough space (at least 15 chars for minimal display)
699        let spans = if available_width >= 15 {
700            // Reserve space for right side indicators
701            let left_max_width = if available_width > right_side_width + 1 {
702                available_width - right_side_width - 1 // -1 for at least one space separator
703            } else {
704                1 // Minimal space
705            };
706
707            let mut spans = vec![];
708
709            // Truncate left status if it's too long (use visual width, not char count)
710            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); // -3 for "..."
713                if truncate_at > 0 {
714                    // Take characters up to visual width limit
715                    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            // Track message area for click detection (if there's a message)
739            if message_width > 0 {
740                // The message starts after base_and_chord, but might be truncated
741                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            // Add spacing to push right side indicators to the right
757            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                // Add minimal space
767                spans.push(Span::styled(
768                    " ",
769                    Style::default()
770                        .fg(theme.status_bar_fg)
771                        .bg(theme.status_bar_bg),
772                ));
773            }
774
775            // Track current column for layout positions
776            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            // Add line ending indicator (clickable to change format)
782            {
783                let is_hovering = hover == StatusBarHover::LineEndingIndicator;
784                // Record position for click detection
785                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            // Add encoding indicator (clickable to change encoding)
801            {
802                let is_hovering = hover == StatusBarHover::EncodingIndicator;
803                // Record position for click detection
804                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            // Add language indicator (clickable to change language)
820            {
821                let is_hovering = hover == StatusBarHover::LanguageIndicator;
822                // Record position for click detection
823                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            // Add LSP indicator with colored background if warning/error
839            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                // Record LSP indicator position for click detection
861                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            // Add general warning badge if there are warnings
875            if !warning_badge.is_empty() {
876                let is_hovering = hover == StatusBarHover::WarningBadge;
877                // Record warning badge position for click detection
878                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            // Keep current_col in scope to avoid unused warning
902            let _ = current_col;
903
904            // Add update indicator if available (with highlighted styling)
905            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            // Add command palette indicator with distinct styling and padding
915            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            // Terminal too narrow or no command palette indicator - fill entire width with left status
925            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                    // Take characters up to visual width limit
931                    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                    // Take characters up to available width
947                    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            // Fill remaining width
973            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    /// Render the search options bar (shown when search prompt is active)
993    ///
994    /// Displays checkboxes for search options with their keyboard shortcuts:
995    /// - Case Sensitive (Alt+C)
996    /// - Whole Word (Alt+W)
997    /// - Regex (Alt+R)
998    /// - Confirm Each (Alt+I) - only shown in replace mode
999    ///
1000    /// # Returns
1001    /// Layout information for hit testing mouse clicks on checkboxes
1002    #[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>, // None = don't show, Some(value) = show with this state
1010        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        // Use menu dropdown background (dark gray) for the options bar
1022        let base_style = Style::default()
1023            .fg(theme.menu_dropdown_fg)
1024            .bg(theme.menu_dropdown_bg);
1025
1026        // Style for hovered options - use menu hover colors
1027        let hover_style = Style::default()
1028            .fg(theme.menu_hover_fg)
1029            .bg(theme.menu_hover_bg);
1030
1031        // Helper to look up keybinding for an action (Prompt context first, then Global)
1032        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        // Get keybindings for search options
1044        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        // Build the options display with checkboxes
1050        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        // Style for active (checked) options - highlighted with menu highlight colors
1055        let active_style = Style::default()
1056            .fg(theme.menu_highlight_fg)
1057            .bg(theme.menu_dropdown_bg);
1058
1059        // Style for keyboard shortcuts - use theme color for consistency
1060        let shortcut_style = Style::default()
1061            .fg(theme.help_separator_fg)
1062            .bg(theme.menu_dropdown_bg);
1063
1064        // Hovered shortcut style
1065        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        // Left padding
1073        spans.push(Span::styled(" ", base_style));
1074        current_col += 1;
1075
1076        // Helper to get style based on hover and checked state
1077        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        // Case Sensitive option
1088        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        // Separator
1115        spans.push(Span::styled("   ", base_style));
1116        current_col += 3;
1117
1118        // Whole Word option
1119        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        // Separator
1146        spans.push(Span::styled("   ", base_style));
1147        current_col += 3;
1148
1149        // Regex option
1150        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(&regex_label) + str_width(&regex_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        // Show capture group hint when regex is enabled in replace mode
1177        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        // Confirm Each option (only shown in replace mode)
1184        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            // Separator
1190            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        // Fill remaining space
1221        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        // Should preserve the last components that fit
1274        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        // The display length should not exceed max_len (approximately)
1287        let display = result.to_string_plain();
1288        assert!(
1289            display.len() <= 35, // Allow some slack for trailing slash
1290            "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}