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::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
18/// Text that both marks a buffer as "edited over a disconnected SSH session"
19/// and styles the prefix in the status bar. Kept as constants so `render_element`
20/// and `element_spans` stay in sync.
21const SSH_PREFIX: &str = "[SSH:";
22const SSH_PREFIX_TERMINATOR: &str = "] ";
23
24/// Categorization of how a rendered element should be styled and tracked for click detection.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26enum ElementKind {
27    /// Normal text using base status bar colors
28    Normal,
29    /// Line ending indicator (clickable)
30    LineEnding,
31    /// Encoding indicator (clickable)
32    Encoding,
33    /// Language indicator (clickable)
34    Language,
35    /// LSP status indicator (colored by warning level, clickable)
36    Lsp,
37    /// Warning badge (colored, clickable)
38    WarningBadge,
39    /// Update available indicator (highlighted)
40    Update,
41    /// Command palette shortcut hint (distinct style)
42    Palette,
43    /// Status message area (clickable to show history)
44    Messages,
45    /// Remote disconnected prefix (error colors)
46    RemoteDisconnected,
47    /// Clock element — colon rendered with hardware blink
48    Clock,
49}
50
51/// A single rendered status bar element with its text and styling info.
52struct RenderedElement {
53    text: String,
54    kind: ElementKind,
55}
56
57/// Three-state LSP status used by the status bar `Lsp` element.
58///
59/// Collapses the previous "running / auto_start-dormant / opt-in-dormant /
60/// nothing" fan-out into the three user-meaningful buckets the indicator
61/// actually needs to communicate:
62///
63/// - `On`            — at least one server for this language is running
64/// - `Off`           — configured servers exist for this language, none are running
65/// - `OffDismissed`  — like `Off`, but the user clicked "Disable" from the
66///                     popup; rendered with a muted style so it stops
67///                     shouting for attention while remaining clickable
68///                     (so the user can still open the popup to re-enable
69///                     or see install help).
70/// - `Error`         — at least one server for this language is in the Error state
71/// - `None`          — no LSP configured or running for this language
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
73pub enum LspIndicatorState {
74    #[default]
75    None,
76    On,
77    Off,
78    OffDismissed,
79    Error,
80}
81
82/// Editor state, theming, and runtime inputs needed to render a status bar frame.
83pub 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    /// Three-state LSP indicator: On / Off / Error / None.  Drives the
90    /// indicator's background color independently of `warning_level` (the
91    /// latter still scopes whether a warning badge is shown on the right
92    /// side of the status bar).
93    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/// Layout information returned from status bar rendering for mouse click detection
108#[derive(Debug, Clone, Default)]
109pub struct StatusBarLayout {
110    /// LSP indicator area (row, start_col, end_col) - None if no LSP indicator shown
111    pub lsp_indicator: Option<(u16, u16, u16)>,
112    /// Warning badge area (row, start_col, end_col) - None if no warnings
113    pub warning_badge: Option<(u16, u16, u16)>,
114    /// Line ending indicator area (row, start_col, end_col)
115    pub line_ending_indicator: Option<(u16, u16, u16)>,
116    /// Encoding indicator area (row, start_col, end_col)
117    pub encoding_indicator: Option<(u16, u16, u16)>,
118    /// Language indicator area (row, start_col, end_col)
119    pub language_indicator: Option<(u16, u16, u16)>,
120    /// Status message area (row, start_col, end_col) - clickable to show full history
121    pub message_area: Option<(u16, u16, u16)>,
122}
123
124/// Status bar hover state for styling clickable indicators
125#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
126pub enum StatusBarHover {
127    #[default]
128    None,
129    /// Mouse is over the LSP indicator
130    LspIndicator,
131    /// Mouse is over the warning badge
132    WarningBadge,
133    /// Mouse is over the line ending indicator
134    LineEndingIndicator,
135    /// Mouse is over the encoding indicator
136    EncodingIndicator,
137    /// Mouse is over the language indicator
138    LanguageIndicator,
139    /// Mouse is over the status message area
140    MessageArea,
141}
142
143/// Which search option checkbox is being hovered
144#[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/// Layout information for search options bar hit testing
155#[derive(Debug, Clone, Default)]
156pub struct SearchOptionsLayout {
157    /// Row where the search options are rendered
158    pub row: u16,
159    /// Case Sensitive checkbox area (start_col, end_col)
160    pub case_sensitive: Option<(u16, u16)>,
161    /// Whole Word checkbox area (start_col, end_col)
162    pub whole_word: Option<(u16, u16)>,
163    /// Regex checkbox area (start_col, end_col)
164    pub regex: Option<(u16, u16)>,
165    /// Confirm Each checkbox area (start_col, end_col) - only present in replace mode
166    pub confirm_each: Option<(u16, u16)>,
167}
168
169impl SearchOptionsLayout {
170    /// Check which search option checkbox (if any) is at the given position
171    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/// Result of truncating a path for display
201#[derive(Debug, Clone)]
202pub struct TruncatedPath {
203    /// The first component of the path (e.g., "/home" or "C:\")
204    pub prefix: String,
205    /// Whether truncation occurred (if true, display "[...]" between prefix and suffix)
206    pub truncated: bool,
207    /// The last components of the path (e.g., "project/src")
208    pub suffix: String,
209}
210
211impl TruncatedPath {
212    /// Get the full display string (without styling)
213    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    /// Get the display length
222    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
231/// Truncate a path for display, showing the first component, [...], and last components
232///
233/// For example, `/private/var/folders/p6/nlmq.../T/.tmpNYt4Fc/project/file.txt`
234/// becomes `/private/[...]/project/file.txt`
235///
236/// # Arguments
237/// * `path` - The path to truncate
238/// * `max_len` - Maximum length for the display string
239///
240/// # Returns
241/// A TruncatedPath struct with prefix, truncation indicator, and suffix
242pub fn truncate_path(path: &Path, max_len: usize) -> TruncatedPath {
243    let path_str = path.to_string_lossy();
244
245    // If path fits, return as-is
246    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    // Always keep the root and first component as prefix
265    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    // The "[...]/" takes 6 characters
272    let ellipsis_len = "/[...]".len();
273
274    // Calculate how much space we have for the suffix
275    let available_for_suffix = max_len.saturating_sub(prefix.len() + ellipsis_len);
276
277    if available_for_suffix < 5 || components.len() <= 1 {
278        // Not enough space or only one component, just truncate the end
279        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    // Build suffix from the last components that fit
292    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; // +1 for the '/'
297        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 we included all remaining components, no truncation needed
308    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        // Can't fit any suffix components, truncate the last component
318        let last = components.last().unwrap_or(&"");
319        let truncate_to = available_for_suffix.saturating_sub(4); // "/.." and some chars
320        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
336/// Truncate a string to fit within `max_width` display columns, appending "..." if truncated.
337fn 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
366/// Renders the status bar and prompt/minibuffer
367pub struct StatusBarRenderer;
368
369impl StatusBarRenderer {
370    /// Render only the status bar (without prompt).
371    ///
372    /// Returns layout information with positions of clickable indicators.
373    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    /// Render the prompt/minibuffer
383    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        // Create spans for the prompt
392        let mut spans = vec![Span::styled(prompt.message.clone(), base_style)];
393
394        // If there's a selection, split the input into parts
395        if let Some((sel_start, sel_end)) = prompt.selection_range() {
396            let input = &prompt.input;
397
398            // Text before selection
399            if sel_start > 0 {
400                spans.push(Span::styled(input[..sel_start].to_string(), base_style));
401            }
402
403            // Selected text (blue background for visibility, cursor remains visible)
404            if sel_start < sel_end {
405                // Use theme colors for selection to ensure consistency across themes
406                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            // Text after selection
416            if sel_end < input.len() {
417                spans.push(Span::styled(input[sel_end..].to_string(), base_style));
418            }
419        } else {
420            // No selection, render entire input normally
421            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        // Set cursor position in the prompt
430        // Use display width (not byte length) for proper handling of:
431        // - Double-width CJK characters
432        // - Zero-width combining characters (Thai diacritics, etc.)
433        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    /// Render the file open prompt with colorized path
442    /// Shows: "Open: /path/to/current/dir/filename" where the directory part is dimmed
443    /// Long paths are truncated: "/private/[...]/project/" with [...] styled differently
444    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        // Style for the [...] ellipsis - use a more visible color
456        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        // "Open: " prefix
463        let open_prompt = t!("file.open_prompt").to_string();
464        spans.push(Span::styled(open_prompt.clone(), base_style));
465
466        // Calculate if we need to truncate
467        // Only truncate if full path + input exceeds 90% of available width
468        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; // +1 for trailing slash
471        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        // Truncate the path only if total length exceeds 90% of width
476        let truncated = if total_len > threshold {
477            // Calculate how much space we have for the path after truncation
478            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            // No truncation needed - return full path
484            TruncatedPath {
485                prefix: String::new(),
486                truncated: false,
487                suffix: dir_path.to_string(),
488            }
489        };
490
491        // Build the directory display with separate spans for styling
492        if truncated.truncated {
493            // Prefix (dimmed)
494            spans.push(Span::styled(truncated.prefix.clone(), dir_style));
495            // Ellipsis "/[...]" (highlighted)
496            spans.push(Span::styled("/[...]", ellipsis_style));
497            // Suffix with trailing slash (dimmed)
498            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            // No truncation - just show the path with trailing slash
506            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        // User input (the filename part) - normal color
515        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        // Set cursor position in the prompt
523        // Use display width for proper handling of Unicode characters
524        // We need to calculate the visual width of: "Open: " + dir_display + input[..cursor_pos]
525        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                // We already added "/" in the suffix_with_slash above, so approximate
531                &truncated.suffix
532            };
533            str_width(&truncated.prefix) + str_width("/[...]") + str_width(suffix_with_slash) + 1
534        } else {
535            str_width(&truncated.suffix) + 1 // +1 for trailing slash
536        };
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    /// Render a single element to its text representation.
545    /// Returns None if the element has nothing to display.
546    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    /// Get the style for a rendered element based on its kind, theme, and hover state.
773    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                // Color by LSP state, reusing the diagnostic theme keys:
829                //   Error → diagnostics.error_*   (red-ish)
830                //   Off   → diagnostics.warning_* (yellow-ish)
831                //   On    → diagnostics.info_*    (info-ish)
832                //   None  → default status-bar colors
833                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                    // Dismissed: fall back to the neutral status-bar
842                    // palette so the pill reads as low-priority.  We
843                    // intentionally don't introduce a dedicated theme
844                    // key — every theme already carries the plain
845                    // status-bar fg/bg, which reliably produces a
846                    // "muted" look next to the vivid error/warning/info
847                    // palettes above.
848                    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                // Always underline on hover — the indicator is clickable
853                // in all non-empty states.  Previously we only underlined
854                // when warning_level != None, so "LSP (on)" gave no hover
855                // cue that it was clickable.
856                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    /// Map an ElementKind to the layout field it should populate.
890    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    /// Build the styled spans for a single rendered element, honoring the
911    /// special-case two-color rendering for a disconnected remote filename.
912    ///
913    /// Returns the spans and the total display width of the emitted text.
914    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            // "HH:MM" — blink the colon via terminal hardware (SGR 5)
952            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    /// Render a configured side (left/right) into styled per-element groups.
964    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    /// Render the normal status bar (config-driven).
990    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        // Reserve a sane minimum for the left side so the buffer name and
1013        // cursor position aren't truncated to a single character on narrow
1014        // terminals (regression originally reported as
1015        // `t  LF  ASCII  Markdown ...`).  Drop low-priority right elements
1016        // (configured right-most first) until the remaining right side fits
1017        // alongside that minimum left budget.  We never drop the *first*
1018        // right element so the user keeps at least one piece of right-side
1019        // status if any was configured.
1020        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) // ~40% of width reserved for left when feasible
1024            .min(40); // but never demand more than 40 cols even on wide terminals
1025        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        // Emit left side, consuming `left_items` so each element's spans move
1049        // directly into the output without a clone. Widths are cached so the
1050        // truncation check doesn't re-measure text.
1051        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                // Overflow: truncate the concatenated text of this element.
1080                // Per-span styling is lost for the overflowed slice — we fall
1081                // back to whatever `element_style` would have returned.
1082                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    /// Render the search options bar (shown when search prompt is active)
1145    ///
1146    /// Displays checkboxes for search options with their keyboard shortcuts:
1147    /// - Case Sensitive (Alt+C)
1148    /// - Whole Word (Alt+W)
1149    /// - Regex (Alt+R)
1150    /// - Confirm Each (Alt+I) - only shown in replace mode
1151    ///
1152    /// # Returns
1153    /// Layout information for hit testing mouse clicks on checkboxes
1154    #[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>, // None = don't show, Some(value) = show with this state
1162        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        // Use menu dropdown background (dark gray) for the options bar
1174        let base_style = Style::default()
1175            .fg(theme.menu_dropdown_fg)
1176            .bg(theme.menu_dropdown_bg);
1177
1178        // Style for hovered options - use menu hover colors
1179        let hover_style = Style::default()
1180            .fg(theme.menu_hover_fg)
1181            .bg(theme.menu_hover_bg);
1182
1183        // Helper to look up keybinding for an action (Prompt context first, then Global)
1184        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        // Get keybindings for search options
1196        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        // Build the options display with checkboxes
1202        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        // Style for active (checked) options - highlighted with menu highlight colors
1207        let active_style = Style::default()
1208            .fg(theme.menu_highlight_fg)
1209            .bg(theme.menu_dropdown_bg);
1210
1211        // Style for keyboard shortcuts - use theme color for consistency
1212        let shortcut_style = Style::default()
1213            .fg(theme.help_separator_fg)
1214            .bg(theme.menu_dropdown_bg);
1215
1216        // Hovered shortcut style
1217        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        // Left padding
1225        spans.push(Span::styled(" ", base_style));
1226        current_col += 1;
1227
1228        // Helper to get style based on hover and checked state
1229        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        // Case Sensitive option
1240        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        // Separator
1267        spans.push(Span::styled("   ", base_style));
1268        current_col += 3;
1269
1270        // Whole Word option
1271        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        // Separator
1298        spans.push(Span::styled("   ", base_style));
1299        current_col += 3;
1300
1301        // Regex option
1302        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(&regex_label) + str_width(&regex_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        // Show capture group hint when regex is enabled in replace mode
1329        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        // Confirm Each option (only shown in replace mode)
1336        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            // Separator
1342            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        // Fill remaining space
1373        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        // Should preserve the last components that fit
1426        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        // The display length should not exceed max_len (approximately)
1439        let display = result.to_string_plain();
1440        assert!(
1441            display.len() <= 35, // Allow some slack for trailing slash
1442            "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}