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    /// Remote authority indicator — styling driven by connection state
50    RemoteIndicator(RemoteIndicatorState),
51}
52
53/// Visual/semantic state of the remote authority indicator.
54///
55/// Covers the full dev-container UX lifecycle the spec asks for —
56/// Local, Connecting to a remote authority, Connected, FailedAttach,
57/// Disconnected — while remaining general enough for any remote
58/// authority Fresh currently supports (SSH today; containers;
59/// anything a plugin installs via `editor.setAuthority(...)`).
60///
61/// Variants deliberately hold no data — phase labels and error text
62/// are passed alongside via `StatusBarContext::remote_state_override`
63/// (added in Phase B-2) so the enum stays `Copy` and core code never
64/// learns devcontainer-specific vocabulary (see
65/// `AUTHORITY_DESIGN.md` principle 3).
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
67pub enum RemoteIndicatorState {
68    /// Editing local files; rendered with the default status-bar palette.
69    #[default]
70    Local,
71    /// An attach (or reconnect) is in flight — rendered with a spinner
72    /// glyph and the help-indicator palette. Plugins drive this via
73    /// `setRemoteIndicatorState` before kicking off `devcontainer up`
74    /// or similar long-running setup.
75    Connecting,
76    /// Connected to an SSH / container / other remote authority.
77    Connected,
78    /// The last attach attempt failed. Rendered with the error palette
79    /// so the state is visible at a glance; the popup surfaces the
80    /// error detail and a Retry action.
81    FailedAttach,
82    /// Connection lost — rendered with the error palette as a persistent
83    /// warning that writes/saves are no longer reaching the authority.
84    Disconnected,
85}
86
87/// Plugin-supplied override for the Remote Indicator. Carries both
88/// the state enum and a user-visible label/error text so core doesn't
89/// need to know how to phrase "Connecting..." or a specific failure
90/// string — the plugin owns the copy.
91///
92/// Deserialized from the tagged JSON shape accepted by the
93/// `SetRemoteIndicatorState` plugin op (see `fresh-core::api`). Kept
94/// in the view crate so the enum lives next to the rendering that
95/// consumes it.
96#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
97#[serde(tag = "kind", rename_all = "snake_case")]
98pub enum RemoteIndicatorOverride {
99    /// Force the indicator to "Local" even when the authority would
100    /// otherwise read as Connected. Rarely needed in practice.
101    Local,
102    /// Attach is in flight. `label` is the short text shown next to
103    /// the spinner glyph (e.g. "Building", "Pulling image").
104    Connecting {
105        #[serde(default)]
106        label: Option<String>,
107    },
108    /// Force Connected. `label` overrides the authority's display
109    /// string if present; otherwise the derived label is shown.
110    Connected {
111        #[serde(default)]
112        label: Option<String>,
113    },
114    /// Last attach attempt failed. `error` is the short message the
115    /// indicator renders; longer context belongs in the popup.
116    FailedAttach {
117        #[serde(default)]
118        error: Option<String>,
119    },
120    /// Explicitly disconnected (e.g. plugin detected a container
121    /// stop that the authority doesn't know about yet).
122    Disconnected {
123        #[serde(default)]
124        label: Option<String>,
125    },
126}
127
128impl RemoteIndicatorOverride {
129    /// Project into the Copy enum consumed by `element_style`.
130    pub fn state(&self) -> RemoteIndicatorState {
131        match self {
132            Self::Local => RemoteIndicatorState::Local,
133            Self::Connecting { .. } => RemoteIndicatorState::Connecting,
134            Self::Connected { .. } => RemoteIndicatorState::Connected,
135            Self::FailedAttach { .. } => RemoteIndicatorState::FailedAttach,
136            Self::Disconnected { .. } => RemoteIndicatorState::Disconnected,
137        }
138    }
139
140    /// Short label rendered inside the indicator element. Defaults
141    /// are chosen so an override with no `label`/`error` field still
142    /// displays something sensible.
143    pub fn label(&self) -> String {
144        match self {
145            Self::Local => "Local".to_string(),
146            Self::Connecting { label } => match label {
147                Some(s) if !s.is_empty() => format!("⠿ {}", s),
148                _ => "⠿ Connecting".to_string(),
149            },
150            Self::Connected { label } => label
151                .as_deref()
152                .filter(|s| !s.is_empty())
153                .unwrap_or("Connected")
154                .to_string(),
155            Self::FailedAttach { error } => match error {
156                Some(s) if !s.is_empty() => format!("Attach failed: {}", s),
157                _ => "Attach failed".to_string(),
158            },
159            Self::Disconnected { label } => match label {
160                Some(s) if !s.is_empty() => format!("{} (Disconnected)", s),
161                _ => "Disconnected".to_string(),
162            },
163        }
164    }
165}
166
167/// A single rendered status bar element with its text and styling info.
168struct RenderedElement {
169    text: String,
170    kind: ElementKind,
171}
172
173/// Three-state LSP status used by the status bar `Lsp` element.
174///
175/// Collapses the previous "running / auto_start-dormant / opt-in-dormant /
176/// nothing" fan-out into the three user-meaningful buckets the indicator
177/// actually needs to communicate:
178///
179/// - `On`            — at least one server for this language is running
180/// - `Off`           — configured servers exist for this language, none are running
181/// - `OffDismissed`  — like `Off`, but the user clicked "Disable" from the
182///                     popup; rendered with a muted style so it stops
183///                     shouting for attention while remaining clickable
184///                     (so the user can still open the popup to re-enable
185///                     or see install help).
186/// - `Error`         — at least one server for this language is in the Error state
187/// - `None`          — no LSP configured or running for this language
188#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
189pub enum LspIndicatorState {
190    #[default]
191    None,
192    On,
193    Off,
194    OffDismissed,
195    Error,
196}
197
198/// Editor state, theming, and runtime inputs needed to render a status bar frame.
199pub struct StatusBarContext<'a> {
200    pub state: &'a mut EditorState,
201    pub cursors: &'a crate::model::cursor::Cursors,
202    pub status_message: &'a Option<String>,
203    pub plugin_status_message: &'a Option<String>,
204    pub lsp_status: &'a str,
205    /// Three-state LSP indicator: On / Off / Error / None.  Drives the
206    /// indicator's background color independently of `warning_level` (the
207    /// latter still scopes whether a warning badge is shown on the right
208    /// side of the status bar).
209    pub lsp_indicator_state: LspIndicatorState,
210    pub theme: &'a crate::view::theme::Theme,
211    pub display_name: &'a str,
212    pub keybindings: &'a crate::input::keybindings::KeybindingResolver,
213    pub chord_state: &'a [(crossterm::event::KeyCode, crossterm::event::KeyModifiers)],
214    pub update_available: Option<&'a str>,
215    pub warning_level: WarningLevel,
216    pub general_warning_count: usize,
217    pub hover: StatusBarHover,
218    pub remote_connection: Option<&'a str>,
219    pub session_name: Option<&'a str>,
220    pub read_only: bool,
221    /// Plugin-supplied override for the `{remote}` indicator. When
222    /// `Some`, its state+label are rendered instead of the one
223    /// derived from `remote_connection`. Set via the
224    /// `SetRemoteIndicatorState` plugin op; cleared by
225    /// `ClearRemoteIndicatorState` or by a `None` pass at the call
226    /// site.
227    pub remote_state_override: Option<&'a RemoteIndicatorOverride>,
228}
229
230/// Layout information returned from status bar rendering for mouse click detection
231#[derive(Debug, Clone, Default)]
232pub struct StatusBarLayout {
233    /// LSP indicator area (row, start_col, end_col) - None if no LSP indicator shown
234    pub lsp_indicator: Option<(u16, u16, u16)>,
235    /// Warning badge area (row, start_col, end_col) - None if no warnings
236    pub warning_badge: Option<(u16, u16, u16)>,
237    /// Line ending indicator area (row, start_col, end_col)
238    pub line_ending_indicator: Option<(u16, u16, u16)>,
239    /// Encoding indicator area (row, start_col, end_col)
240    pub encoding_indicator: Option<(u16, u16, u16)>,
241    /// Language indicator area (row, start_col, end_col)
242    pub language_indicator: Option<(u16, u16, u16)>,
243    /// Status message area (row, start_col, end_col) - clickable to show full history
244    pub message_area: Option<(u16, u16, u16)>,
245    /// Remote authority indicator area (row, start_col, end_col) - clickable
246    /// to open the remote-authority context menu.
247    pub remote_indicator: Option<(u16, u16, u16)>,
248}
249
250/// Status bar hover state for styling clickable indicators
251#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
252pub enum StatusBarHover {
253    #[default]
254    None,
255    /// Mouse is over the LSP indicator
256    LspIndicator,
257    /// Mouse is over the warning badge
258    WarningBadge,
259    /// Mouse is over the line ending indicator
260    LineEndingIndicator,
261    /// Mouse is over the encoding indicator
262    EncodingIndicator,
263    /// Mouse is over the language indicator
264    LanguageIndicator,
265    /// Mouse is over the status message area
266    MessageArea,
267    /// Mouse is over the remote authority indicator
268    RemoteIndicator,
269}
270
271/// Which search option checkbox is being hovered
272#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
273pub enum SearchOptionsHover {
274    #[default]
275    None,
276    CaseSensitive,
277    WholeWord,
278    Regex,
279    ConfirmEach,
280}
281
282/// Layout information for search options bar hit testing
283#[derive(Debug, Clone, Default)]
284pub struct SearchOptionsLayout {
285    /// Row where the search options are rendered
286    pub row: u16,
287    /// Case Sensitive checkbox area (start_col, end_col)
288    pub case_sensitive: Option<(u16, u16)>,
289    /// Whole Word checkbox area (start_col, end_col)
290    pub whole_word: Option<(u16, u16)>,
291    /// Regex checkbox area (start_col, end_col)
292    pub regex: Option<(u16, u16)>,
293    /// Confirm Each checkbox area (start_col, end_col) - only present in replace mode
294    pub confirm_each: Option<(u16, u16)>,
295}
296
297impl SearchOptionsLayout {
298    /// Check which search option checkbox (if any) is at the given position
299    pub fn checkbox_at(&self, x: u16, y: u16) -> Option<SearchOptionsHover> {
300        if y != self.row {
301            return None;
302        }
303
304        if let Some((start, end)) = self.case_sensitive {
305            if x >= start && x < end {
306                return Some(SearchOptionsHover::CaseSensitive);
307            }
308        }
309        if let Some((start, end)) = self.whole_word {
310            if x >= start && x < end {
311                return Some(SearchOptionsHover::WholeWord);
312            }
313        }
314        if let Some((start, end)) = self.regex {
315            if x >= start && x < end {
316                return Some(SearchOptionsHover::Regex);
317            }
318        }
319        if let Some((start, end)) = self.confirm_each {
320            if x >= start && x < end {
321                return Some(SearchOptionsHover::ConfirmEach);
322            }
323        }
324        None
325    }
326}
327
328/// Result of truncating a path for display
329#[derive(Debug, Clone)]
330pub struct TruncatedPath {
331    /// The first component of the path (e.g., "/home" or "C:\")
332    pub prefix: String,
333    /// Whether truncation occurred (if true, display "[...]" between prefix and suffix)
334    pub truncated: bool,
335    /// The last components of the path (e.g., "project/src")
336    pub suffix: String,
337}
338
339impl TruncatedPath {
340    /// Get the full display string (without styling)
341    pub fn to_string_plain(&self) -> String {
342        if self.truncated {
343            format!("{}/[...]{}", self.prefix, self.suffix)
344        } else {
345            format!("{}{}", self.prefix, self.suffix)
346        }
347    }
348
349    /// Get the display length
350    pub fn display_len(&self) -> usize {
351        if self.truncated {
352            self.prefix.len() + "/[...]".len() + self.suffix.len()
353        } else {
354            self.prefix.len() + self.suffix.len()
355        }
356    }
357}
358
359/// Truncate a path for display, showing the first component, [...], and last components
360///
361/// For example, `/private/var/folders/p6/nlmq.../T/.tmpNYt4Fc/project/file.txt`
362/// becomes `/private/[...]/project/file.txt`
363///
364/// # Arguments
365/// * `path` - The path to truncate
366/// * `max_len` - Maximum length for the display string
367///
368/// # Returns
369/// A TruncatedPath struct with prefix, truncation indicator, and suffix
370pub fn truncate_path(path: &Path, max_len: usize) -> TruncatedPath {
371    let path_str = path.to_string_lossy();
372
373    // If path fits, return as-is
374    if path_str.len() <= max_len {
375        return TruncatedPath {
376            prefix: String::new(),
377            truncated: false,
378            suffix: path_str.to_string(),
379        };
380    }
381
382    let components: Vec<&str> = path_str.split('/').filter(|s| !s.is_empty()).collect();
383
384    if components.is_empty() {
385        return TruncatedPath {
386            prefix: "/".to_string(),
387            truncated: false,
388            suffix: String::new(),
389        };
390    }
391
392    // Always keep the root and first component as prefix
393    let prefix = if path_str.starts_with('/') {
394        format!("/{}", components.first().unwrap_or(&""))
395    } else {
396        components.first().unwrap_or(&"").to_string()
397    };
398
399    // The "[...]/" takes 6 characters
400    let ellipsis_len = "/[...]".len();
401
402    // Calculate how much space we have for the suffix
403    let available_for_suffix = max_len.saturating_sub(prefix.len() + ellipsis_len);
404
405    if available_for_suffix < 5 || components.len() <= 1 {
406        // Not enough space or only one component, just truncate the end
407        let truncated_path = if path_str.len() > max_len.saturating_sub(3) {
408            format!("{}...", &path_str[..max_len.saturating_sub(3)])
409        } else {
410            path_str.to_string()
411        };
412        return TruncatedPath {
413            prefix: String::new(),
414            truncated: false,
415            suffix: truncated_path,
416        };
417    }
418
419    // Build suffix from the last components that fit
420    let mut suffix_parts: Vec<&str> = Vec::new();
421    let mut suffix_len = 0;
422
423    for component in components.iter().skip(1).rev() {
424        let component_len = component.len() + 1; // +1 for the '/'
425        if suffix_len + component_len <= available_for_suffix {
426            suffix_parts.push(component);
427            suffix_len += component_len;
428        } else {
429            break;
430        }
431    }
432
433    suffix_parts.reverse();
434
435    // If we included all remaining components, no truncation needed
436    if suffix_parts.len() == components.len() - 1 {
437        return TruncatedPath {
438            prefix: String::new(),
439            truncated: false,
440            suffix: path_str.to_string(),
441        };
442    }
443
444    let suffix = if suffix_parts.is_empty() {
445        // Can't fit any suffix components, truncate the last component
446        let last = components.last().unwrap_or(&"");
447        let truncate_to = available_for_suffix.saturating_sub(4); // "/.." and some chars
448        if truncate_to > 0 && last.len() > truncate_to {
449            format!("/{}...", &last[..truncate_to])
450        } else {
451            format!("/{}", last)
452        }
453    } else {
454        format!("/{}", suffix_parts.join("/"))
455    };
456
457    TruncatedPath {
458        prefix,
459        truncated: true,
460        suffix,
461    }
462}
463
464/// Truncate a string to fit within `max_width` display columns, appending "..." if truncated.
465fn truncate_to_width(s: &str, max_width: usize) -> String {
466    let width = str_width(s);
467    if width <= max_width {
468        return s.to_string();
469    }
470    let truncate_at = max_width.saturating_sub(3);
471    if truncate_at == 0 {
472        return if max_width >= 3 {
473            "...".to_string()
474        } else {
475            s.chars().take(max_width).collect()
476        };
477    }
478    let mut w = 0;
479    let truncated: String = s
480        .chars()
481        .take_while(|ch| {
482            let cw = char_width(*ch);
483            if w + cw <= truncate_at {
484                w += cw;
485                true
486            } else {
487                false
488            }
489        })
490        .collect();
491    format!("{}...", truncated)
492}
493
494/// Renders the status bar and prompt/minibuffer
495pub struct StatusBarRenderer;
496
497impl StatusBarRenderer {
498    /// Render only the status bar (without prompt).
499    ///
500    /// Returns layout information with positions of clickable indicators.
501    pub fn render_status_bar(
502        frame: &mut Frame,
503        area: Rect,
504        ctx: &mut StatusBarContext<'_>,
505        config: &StatusBarConfig,
506    ) -> StatusBarLayout {
507        Self::render_status(frame, area, ctx, config)
508    }
509
510    /// Render the prompt/minibuffer
511    pub fn render_prompt(
512        frame: &mut Frame,
513        area: Rect,
514        prompt: &Prompt,
515        theme: &crate::view::theme::Theme,
516    ) {
517        let base_style = Style::default().fg(theme.prompt_fg).bg(theme.prompt_bg);
518
519        // Create spans for the prompt
520        let mut spans = vec![Span::styled(prompt.message.clone(), base_style)];
521
522        // If there's a selection, split the input into parts
523        if let Some((sel_start, sel_end)) = prompt.selection_range() {
524            let input = &prompt.input;
525
526            // Text before selection
527            if sel_start > 0 {
528                spans.push(Span::styled(input[..sel_start].to_string(), base_style));
529            }
530
531            // Selected text (blue background for visibility, cursor remains visible)
532            if sel_start < sel_end {
533                // Use theme colors for selection to ensure consistency across themes
534                let selection_style = Style::default()
535                    .fg(theme.prompt_selection_fg)
536                    .bg(theme.prompt_selection_bg);
537                spans.push(Span::styled(
538                    input[sel_start..sel_end].to_string(),
539                    selection_style,
540                ));
541            }
542
543            // Text after selection
544            if sel_end < input.len() {
545                spans.push(Span::styled(input[sel_end..].to_string(), base_style));
546            }
547        } else {
548            // No selection, render entire input normally
549            spans.push(Span::styled(prompt.input.clone(), base_style));
550        }
551
552        let line = Line::from(spans);
553        let prompt_line = Paragraph::new(line).style(base_style);
554
555        frame.render_widget(prompt_line, area);
556
557        // Set cursor position in the prompt
558        // Use display width (not byte length) for proper handling of:
559        // - Double-width CJK characters
560        // - Zero-width combining characters (Thai diacritics, etc.)
561        let message_width = str_width(&prompt.message);
562        let input_width_before_cursor = str_width(&prompt.input[..prompt.cursor_pos]);
563        let cursor_x = (message_width + input_width_before_cursor) as u16;
564        if cursor_x < area.width {
565            frame.set_cursor_position((area.x + cursor_x, area.y));
566        }
567    }
568
569    /// Render the file open prompt with colorized path
570    /// Shows: "Open: /path/to/current/dir/filename" where the directory part is dimmed
571    /// Long paths are truncated: "/private/[...]/project/" with [...] styled differently
572    pub fn render_file_open_prompt(
573        frame: &mut Frame,
574        area: Rect,
575        prompt: &Prompt,
576        file_open_state: &crate::app::file_open::FileOpenState,
577        theme: &crate::view::theme::Theme,
578    ) {
579        let base_style = Style::default().fg(theme.prompt_fg).bg(theme.prompt_bg);
580        let dir_style = Style::default()
581            .fg(theme.help_separator_fg)
582            .bg(theme.prompt_bg);
583        // Style for the [...] ellipsis - use a more visible color
584        let ellipsis_style = Style::default()
585            .fg(theme.menu_highlight_fg)
586            .bg(theme.prompt_bg);
587
588        let mut spans = Vec::new();
589
590        // "Open: " prefix
591        let open_prompt = t!("file.open_prompt").to_string();
592        spans.push(Span::styled(open_prompt.clone(), base_style));
593
594        // Calculate if we need to truncate
595        // Only truncate if full path + input exceeds 90% of available width
596        let prefix_len = str_width(&open_prompt);
597        let dir_path = file_open_state.current_dir.to_string_lossy();
598        let dir_path_len = dir_path.len() + 1; // +1 for trailing slash
599        let input_len = prompt.input.len();
600        let total_len = prefix_len + dir_path_len + input_len;
601        let threshold = (area.width as usize * 90) / 100;
602
603        // Truncate the path only if total length exceeds 90% of width
604        let truncated = if total_len > threshold {
605            // Calculate how much space we have for the path after truncation
606            let available_for_path = threshold
607                .saturating_sub(prefix_len)
608                .saturating_sub(input_len);
609            truncate_path(&file_open_state.current_dir, available_for_path)
610        } else {
611            // No truncation needed - return full path
612            TruncatedPath {
613                prefix: String::new(),
614                truncated: false,
615                suffix: dir_path.to_string(),
616            }
617        };
618
619        // Build the directory display with separate spans for styling
620        if truncated.truncated {
621            // Prefix (dimmed)
622            spans.push(Span::styled(truncated.prefix.clone(), dir_style));
623            // Ellipsis "/[...]" (highlighted)
624            spans.push(Span::styled("/[...]", ellipsis_style));
625            // Suffix with trailing slash (dimmed)
626            let suffix_with_slash = if truncated.suffix.ends_with('/') {
627                truncated.suffix.clone()
628            } else {
629                format!("{}/", truncated.suffix)
630            };
631            spans.push(Span::styled(suffix_with_slash, dir_style));
632        } else {
633            // No truncation - just show the path with trailing slash
634            let path_display = if truncated.suffix.ends_with('/') {
635                truncated.suffix.clone()
636            } else {
637                format!("{}/", truncated.suffix)
638            };
639            spans.push(Span::styled(path_display, dir_style));
640        }
641
642        // User input (the filename part) - normal color
643        spans.push(Span::styled(prompt.input.clone(), base_style));
644
645        let line = Line::from(spans);
646        let prompt_line = Paragraph::new(line).style(base_style);
647
648        frame.render_widget(prompt_line, area);
649
650        // Set cursor position in the prompt
651        // Use display width for proper handling of Unicode characters
652        // We need to calculate the visual width of: "Open: " + dir_display + input[..cursor_pos]
653        let prefix_width = str_width(&open_prompt);
654        let dir_display_width = if truncated.truncated {
655            let suffix_with_slash = if truncated.suffix.ends_with('/') {
656                &truncated.suffix
657            } else {
658                // We already added "/" in the suffix_with_slash above, so approximate
659                &truncated.suffix
660            };
661            str_width(&truncated.prefix) + str_width("/[...]") + str_width(suffix_with_slash) + 1
662        } else {
663            str_width(&truncated.suffix) + 1 // +1 for trailing slash
664        };
665        let input_width_before_cursor = str_width(&prompt.input[..prompt.cursor_pos]);
666        let cursor_x = (prefix_width + dir_display_width + input_width_before_cursor) as u16;
667        if cursor_x < area.width {
668            frame.set_cursor_position((area.x + cursor_x, area.y));
669        }
670    }
671
672    /// Render a single element to its text representation.
673    /// Returns None if the element has nothing to display.
674    fn render_element(
675        element: &StatusBarElement,
676        ctx: &mut StatusBarContext<'_>,
677    ) -> Option<RenderedElement> {
678        match element {
679            StatusBarElement::Filename => {
680                let modified = if ctx.state.buffer.is_modified() {
681                    " [+]"
682                } else {
683                    ""
684                };
685                let read_only_indicator = if ctx.read_only { " [RO]" } else { "" };
686                let remote_disconnected = ctx
687                    .remote_connection
688                    .map(|conn| conn.contains("(Disconnected)"))
689                    .unwrap_or(false);
690                let remote_prefix = ctx
691                    .remote_connection
692                    .map(|conn| {
693                        if conn.starts_with("Container:") {
694                            format!("[{}] ", conn)
695                        } else {
696                            format!("{SSH_PREFIX}{conn}{SSH_PREFIX_TERMINATOR}")
697                        }
698                    })
699                    .unwrap_or_default();
700                let session_prefix = ctx
701                    .session_name
702                    .map(|name| format!("[{}] ", name))
703                    .unwrap_or_default();
704                let display_name = ctx.display_name;
705                let text = format!(
706                    "{session_prefix}{remote_prefix}{display_name}{modified}{read_only_indicator}"
707                );
708                let kind = if remote_disconnected {
709                    ElementKind::RemoteDisconnected
710                } else {
711                    ElementKind::Normal
712                };
713                Some(RenderedElement { text, kind })
714            }
715            StatusBarElement::Cursor => {
716                if !ctx.state.show_cursors {
717                    return None;
718                }
719                let cursor = *ctx.cursors.primary();
720                let byte_offset_mode = ctx.state.buffer.line_count().is_none();
721                let text = if byte_offset_mode {
722                    format!("Byte {}", cursor.position)
723                } else {
724                    let cursor_iter = ctx.state.buffer.line_iterator(cursor.position, 80);
725                    let line_start = cursor_iter.current_position();
726                    let col = cursor.position.saturating_sub(line_start);
727                    let line = ctx.state.primary_cursor_line_number.value();
728                    format!("Ln {}, Col {}", line + 1, col + 1)
729                };
730                Some(RenderedElement {
731                    text,
732                    kind: ElementKind::Normal,
733                })
734            }
735            StatusBarElement::CursorCompact => {
736                if !ctx.state.show_cursors {
737                    return None;
738                }
739                let cursor = *ctx.cursors.primary();
740                let byte_offset_mode = ctx.state.buffer.line_count().is_none();
741                let text = if byte_offset_mode {
742                    format!("{}", cursor.position)
743                } else {
744                    let cursor_iter = ctx.state.buffer.line_iterator(cursor.position, 80);
745                    let line_start = cursor_iter.current_position();
746                    let col = cursor.position.saturating_sub(line_start);
747                    let line = ctx.state.primary_cursor_line_number.value();
748                    format!("{}:{}", line + 1, col + 1)
749                };
750                Some(RenderedElement {
751                    text,
752                    kind: ElementKind::Normal,
753                })
754            }
755            StatusBarElement::Diagnostics => {
756                let diagnostics = ctx.state.overlays.all();
757                let mut error_count = 0usize;
758                let mut warning_count = 0usize;
759                let mut info_count = 0usize;
760                let diagnostic_ns = crate::services::lsp::diagnostics::lsp_diagnostic_namespace();
761                for overlay in diagnostics {
762                    if overlay.namespace.as_ref() == Some(&diagnostic_ns) {
763                        match overlay.priority {
764                            100 => error_count += 1,
765                            50 => warning_count += 1,
766                            _ => info_count += 1,
767                        }
768                    }
769                }
770                if error_count + warning_count + info_count == 0 {
771                    return None;
772                }
773                let mut parts = Vec::new();
774                if error_count > 0 {
775                    parts.push(format!("E:{}", error_count));
776                }
777                if warning_count > 0 {
778                    parts.push(format!("W:{}", warning_count));
779                }
780                if info_count > 0 {
781                    parts.push(format!("I:{}", info_count));
782                }
783                Some(RenderedElement {
784                    text: parts.join(" "),
785                    kind: ElementKind::Normal,
786                })
787            }
788            StatusBarElement::CursorCount => {
789                if ctx.cursors.count() <= 1 {
790                    return None;
791                }
792                Some(RenderedElement {
793                    text: t!("status.cursors", count = ctx.cursors.count()).to_string(),
794                    kind: ElementKind::Normal,
795                })
796            }
797            StatusBarElement::Messages => {
798                let mut parts: Vec<&str> = Vec::new();
799                if let Some(msg) = ctx.status_message {
800                    if !msg.is_empty() {
801                        parts.push(msg);
802                    }
803                }
804                if let Some(msg) = ctx.plugin_status_message {
805                    if !msg.is_empty() {
806                        parts.push(msg);
807                    }
808                }
809                if parts.is_empty() {
810                    return None;
811                }
812                Some(RenderedElement {
813                    text: parts.join(" | "),
814                    kind: ElementKind::Messages,
815                })
816            }
817            StatusBarElement::Chord => {
818                if ctx.chord_state.is_empty() {
819                    return None;
820                }
821                let chord_str = ctx
822                    .chord_state
823                    .iter()
824                    .map(|(code, modifiers)| {
825                        crate::input::keybindings::format_keybinding(code, modifiers)
826                    })
827                    .collect::<Vec<_>>()
828                    .join(" ");
829                Some(RenderedElement {
830                    text: format!("[{}]", chord_str),
831                    kind: ElementKind::Normal,
832                })
833            }
834            StatusBarElement::LineEnding => Some(RenderedElement {
835                text: format!(" {} ", ctx.state.buffer.line_ending().display_name()),
836                kind: ElementKind::LineEnding,
837            }),
838            StatusBarElement::Encoding => Some(RenderedElement {
839                text: format!(" {} ", ctx.state.buffer.encoding().display_name()),
840                kind: ElementKind::Encoding,
841            }),
842            StatusBarElement::Language => {
843                let text = if ctx.state.language == "text"
844                    && ctx.state.display_name != "Text"
845                    && ctx.state.display_name != "Plain Text"
846                    && ctx.state.display_name != "text"
847                {
848                    format!(" {} [syntax only] ", &ctx.state.display_name)
849                } else {
850                    format!(" {} ", &ctx.state.display_name)
851                };
852                Some(RenderedElement {
853                    text,
854                    kind: ElementKind::Language,
855                })
856            }
857            StatusBarElement::Lsp => {
858                if ctx.lsp_status.is_empty() {
859                    return None;
860                }
861                Some(RenderedElement {
862                    text: format!(" {} ", ctx.lsp_status),
863                    kind: ElementKind::Lsp,
864                })
865            }
866            StatusBarElement::Warnings => {
867                if ctx.general_warning_count == 0 {
868                    return None;
869                }
870                Some(RenderedElement {
871                    text: format!(" [\u{26a0} {}] ", ctx.general_warning_count),
872                    kind: ElementKind::WarningBadge,
873                })
874            }
875            StatusBarElement::Update => {
876                let version = ctx.update_available?;
877                Some(RenderedElement {
878                    text: format!(" {} ", t!("status.update_available", version = version)),
879                    kind: ElementKind::Update,
880                })
881            }
882            StatusBarElement::Palette => {
883                let shortcut = ctx
884                    .keybindings
885                    .get_keybinding_for_action(
886                        &crate::input::keybindings::Action::QuickOpen,
887                        crate::input::keybindings::KeyContext::Global,
888                    )
889                    .unwrap_or_else(|| "?".to_string());
890                Some(RenderedElement {
891                    text: format!(" {} ", t!("status.palette", shortcut = shortcut)),
892                    kind: ElementKind::Palette,
893                })
894            }
895            StatusBarElement::Clock => {
896                let now = chrono::Local::now();
897                let text = format!("{:02}:{:02}", now.hour(), now.minute());
898                Some(RenderedElement {
899                    text,
900                    kind: ElementKind::Clock,
901                })
902            }
903            StatusBarElement::RemoteIndicator => {
904                // Persistent remote-authority entry point. When local we
905                // still emit a short label so the indicator is visible —
906                // the spec calls for a persistent control, not one that
907                // vanishes when there is nothing to report.
908                //
909                // Precedence: plugin-supplied override (via
910                // `SetRemoteIndicatorState`) wins over the authority-
911                // derived state. The override carries its own label;
912                // derived states synthesize one from `remote_connection`.
913                let (text, state) = if let Some(over) = ctx.remote_state_override {
914                    (format!(" {} ", over.label()), over.state())
915                } else {
916                    match ctx.remote_connection {
917                        None => (" Local ".to_string(), RemoteIndicatorState::Local),
918                        Some(conn) if conn.contains("(Disconnected)") => {
919                            (format!(" {} ", conn), RemoteIndicatorState::Disconnected)
920                        }
921                        Some(conn) => (format!(" {} ", conn), RemoteIndicatorState::Connected),
922                    }
923                };
924                Some(RenderedElement {
925                    text,
926                    kind: ElementKind::RemoteIndicator(state),
927                })
928            }
929        }
930    }
931
932    /// Get the style for a rendered element based on its kind, theme, and hover state.
933    fn element_style(
934        kind: ElementKind,
935        theme: &crate::view::theme::Theme,
936        hover: StatusBarHover,
937        _warning_level: WarningLevel,
938        lsp_state: LspIndicatorState,
939    ) -> Style {
940        match kind {
941            ElementKind::Normal | ElementKind::Messages | ElementKind::Clock => Style::default()
942                .fg(theme.status_bar_fg)
943                .bg(theme.status_bar_bg),
944            ElementKind::RemoteDisconnected => Style::default()
945                .fg(theme.status_error_indicator_fg)
946                .bg(theme.status_error_indicator_bg),
947            ElementKind::LineEnding => {
948                let is_hovering = hover == StatusBarHover::LineEndingIndicator;
949                let (fg, bg) = if is_hovering {
950                    (theme.menu_hover_fg, theme.menu_hover_bg)
951                } else {
952                    (theme.status_bar_fg, theme.status_bar_bg)
953                };
954                let mut style = Style::default().fg(fg).bg(bg);
955                if is_hovering {
956                    style = style.add_modifier(Modifier::UNDERLINED);
957                }
958                style
959            }
960            ElementKind::Encoding => {
961                let is_hovering = hover == StatusBarHover::EncodingIndicator;
962                let (fg, bg) = if is_hovering {
963                    (theme.menu_hover_fg, theme.menu_hover_bg)
964                } else {
965                    (theme.status_bar_fg, theme.status_bar_bg)
966                };
967                let mut style = Style::default().fg(fg).bg(bg);
968                if is_hovering {
969                    style = style.add_modifier(Modifier::UNDERLINED);
970                }
971                style
972            }
973            ElementKind::Language => {
974                let is_hovering = hover == StatusBarHover::LanguageIndicator;
975                let (fg, bg) = if is_hovering {
976                    (theme.menu_hover_fg, theme.menu_hover_bg)
977                } else {
978                    (theme.status_bar_fg, theme.status_bar_bg)
979                };
980                let mut style = Style::default().fg(fg).bg(bg);
981                if is_hovering {
982                    style = style.add_modifier(Modifier::UNDERLINED);
983                }
984                style
985            }
986            ElementKind::Lsp => {
987                let is_hovering = hover == StatusBarHover::LspIndicator;
988                // Color by LSP state, reusing the diagnostic theme keys:
989                //   Error → diagnostics.error_*   (red-ish)
990                //   Off   → diagnostics.warning_* (yellow-ish)
991                //   On    → diagnostics.info_*    (info-ish)
992                //   None  → default status-bar colors
993                let (fg, bg) = match lsp_state {
994                    LspIndicatorState::Error => {
995                        (theme.diagnostic_error_fg, theme.diagnostic_error_bg)
996                    }
997                    LspIndicatorState::Off => {
998                        (theme.diagnostic_warning_fg, theme.diagnostic_warning_bg)
999                    }
1000                    LspIndicatorState::On => (theme.diagnostic_info_fg, theme.diagnostic_info_bg),
1001                    // Dismissed: fall back to the neutral status-bar
1002                    // palette so the pill reads as low-priority.  We
1003                    // intentionally don't introduce a dedicated theme
1004                    // key — every theme already carries the plain
1005                    // status-bar fg/bg, which reliably produces a
1006                    // "muted" look next to the vivid error/warning/info
1007                    // palettes above.
1008                    LspIndicatorState::OffDismissed => (theme.status_bar_fg, theme.status_bar_bg),
1009                    LspIndicatorState::None => (theme.status_bar_fg, theme.status_bar_bg),
1010                };
1011                let mut style = Style::default().fg(fg).bg(bg);
1012                // Always underline on hover — the indicator is clickable
1013                // in all non-empty states.  Previously we only underlined
1014                // when warning_level != None, so "LSP (on)" gave no hover
1015                // cue that it was clickable.
1016                if is_hovering && lsp_state != LspIndicatorState::None {
1017                    style = style.add_modifier(Modifier::UNDERLINED);
1018                }
1019                style
1020            }
1021            ElementKind::WarningBadge => {
1022                let is_hovering = hover == StatusBarHover::WarningBadge;
1023                let (fg, bg) = if is_hovering {
1024                    (
1025                        theme.status_warning_indicator_hover_fg,
1026                        theme.status_warning_indicator_hover_bg,
1027                    )
1028                } else {
1029                    (
1030                        theme.status_warning_indicator_fg,
1031                        theme.status_warning_indicator_bg,
1032                    )
1033                };
1034                let mut style = Style::default().fg(fg).bg(bg);
1035                if is_hovering {
1036                    style = style.add_modifier(Modifier::UNDERLINED);
1037                }
1038                style
1039            }
1040            ElementKind::Update => Style::default()
1041                .fg(theme.menu_highlight_fg)
1042                .bg(theme.menu_dropdown_bg),
1043            ElementKind::Palette => Style::default()
1044                .fg(theme.help_indicator_fg)
1045                .bg(theme.help_indicator_bg),
1046            ElementKind::RemoteIndicator(state) => {
1047                let is_hovering = hover == StatusBarHover::RemoteIndicator;
1048                let (fg, bg) = match state {
1049                    // Connecting and Connected share the "help
1050                    // indicator" palette so the transition from one to
1051                    // the other is a glyph swap rather than a color
1052                    // flash — the user's eye tracks the indicator
1053                    // changing, not disappearing.
1054                    RemoteIndicatorState::Connecting | RemoteIndicatorState::Connected => {
1055                        (theme.help_indicator_fg, theme.help_indicator_bg)
1056                    }
1057                    // FailedAttach + Disconnected share the error
1058                    // palette. Both are "the remote isn't reaching you
1059                    // right now" states, differing only in cause.
1060                    RemoteIndicatorState::FailedAttach | RemoteIndicatorState::Disconnected => (
1061                        theme.status_error_indicator_fg,
1062                        theme.status_error_indicator_bg,
1063                    ),
1064                    // Local: neutral status-bar palette.
1065                    RemoteIndicatorState::Local => (theme.status_bar_fg, theme.status_bar_bg),
1066                };
1067                let mut style = Style::default().fg(fg).bg(bg);
1068                if is_hovering {
1069                    style = style.add_modifier(Modifier::UNDERLINED);
1070                }
1071                style
1072            }
1073        }
1074    }
1075
1076    /// Map an ElementKind to the layout field it should populate.
1077    fn update_layout_for_element(
1078        layout: &mut StatusBarLayout,
1079        kind: ElementKind,
1080        row: u16,
1081        start_col: u16,
1082        end_col: u16,
1083    ) {
1084        match kind {
1085            ElementKind::LineEnding => {
1086                layout.line_ending_indicator = Some((row, start_col, end_col))
1087            }
1088            ElementKind::Encoding => layout.encoding_indicator = Some((row, start_col, end_col)),
1089            ElementKind::Language => layout.language_indicator = Some((row, start_col, end_col)),
1090            ElementKind::Lsp => layout.lsp_indicator = Some((row, start_col, end_col)),
1091            ElementKind::WarningBadge => layout.warning_badge = Some((row, start_col, end_col)),
1092            ElementKind::Messages => layout.message_area = Some((row, start_col, end_col)),
1093            ElementKind::RemoteIndicator(_) => {
1094                layout.remote_indicator = Some((row, start_col, end_col))
1095            }
1096            _ => {}
1097        }
1098    }
1099
1100    /// Build the styled spans for a single rendered element, honoring the
1101    /// special-case two-color rendering for a disconnected remote filename.
1102    ///
1103    /// Returns the spans and the total display width of the emitted text.
1104    fn element_spans(
1105        rendered: &RenderedElement,
1106        theme: &crate::view::theme::Theme,
1107        hover: StatusBarHover,
1108        warning_level: WarningLevel,
1109        lsp_state: LspIndicatorState,
1110    ) -> (Vec<Span<'static>>, usize) {
1111        let base_style = Style::default()
1112            .fg(theme.status_bar_fg)
1113            .bg(theme.status_bar_bg);
1114        let width = str_width(&rendered.text);
1115
1116        if rendered.kind == ElementKind::RemoteDisconnected && rendered.text.starts_with(SSH_PREFIX)
1117        {
1118            let error_style = Style::default()
1119                .fg(theme.status_error_indicator_fg)
1120                .bg(theme.status_error_indicator_bg);
1121            if let Some(term_off) = rendered.text.find(SSH_PREFIX_TERMINATOR) {
1122                let split_at = term_off + SSH_PREFIX_TERMINATOR.len();
1123                let prefix = rendered.text[..split_at].to_string();
1124                let rest = rendered.text[split_at..].to_string();
1125                return (
1126                    vec![
1127                        Span::styled(prefix, error_style),
1128                        Span::styled(rest, base_style),
1129                    ],
1130                    width,
1131                );
1132            }
1133            return (
1134                vec![Span::styled(rendered.text.clone(), error_style)],
1135                width,
1136            );
1137        }
1138
1139        let style = Self::element_style(rendered.kind, theme, hover, warning_level, lsp_state);
1140        let spans = if rendered.kind == ElementKind::Clock {
1141            // "HH:MM" — blink the colon via terminal hardware (SGR 5)
1142            vec![
1143                Span::styled(rendered.text[..2].to_string(), style),
1144                Span::styled(":".to_string(), style.add_modifier(Modifier::SLOW_BLINK)),
1145                Span::styled(rendered.text[3..].to_string(), style),
1146            ]
1147        } else {
1148            vec![Span::styled(rendered.text.clone(), style)]
1149        };
1150        (spans, width)
1151    }
1152
1153    /// Render a configured side (left/right) into styled per-element groups.
1154    fn render_side(
1155        config_side: &[StatusBarElement],
1156        ctx: &mut StatusBarContext<'_>,
1157    ) -> Vec<(Vec<Span<'static>>, usize, ElementKind)> {
1158        let rendered: Vec<RenderedElement> = config_side
1159            .iter()
1160            .filter_map(|elem| Self::render_element(elem, ctx))
1161            .filter(|e| !e.text.is_empty())
1162            .collect();
1163
1164        let theme = ctx.theme;
1165        let hover = ctx.hover;
1166        let warning_level = ctx.warning_level;
1167        let lsp_state = ctx.lsp_indicator_state;
1168        rendered
1169            .into_iter()
1170            .map(|r| {
1171                let kind = r.kind;
1172                let (spans, width) =
1173                    Self::element_spans(&r, theme, hover, warning_level, lsp_state);
1174                (spans, width, kind)
1175            })
1176            .collect()
1177    }
1178
1179    /// Render the normal status bar (config-driven).
1180    fn render_status(
1181        frame: &mut Frame,
1182        area: Rect,
1183        ctx: &mut StatusBarContext<'_>,
1184        config: &StatusBarConfig,
1185    ) -> StatusBarLayout {
1186        let mut layout = StatusBarLayout::default();
1187        let base_style = Style::default()
1188            .fg(ctx.theme.status_bar_fg)
1189            .bg(ctx.theme.status_bar_bg);
1190        let available_width = area.width as usize;
1191
1192        if available_width == 0 || area.height == 0 {
1193            return layout;
1194        }
1195
1196        let left_items = Self::render_side(&config.left, ctx);
1197        let mut right_items = Self::render_side(&config.right, ctx);
1198
1199        const SEPARATOR: &str = " | ";
1200        let separator_width = str_width(SEPARATOR);
1201
1202        // Reserve a sane minimum for the left side so the buffer name and
1203        // cursor position aren't truncated to a single character on narrow
1204        // terminals (regression originally reported as
1205        // `t  LF  ASCII  Markdown ...`).  Drop low-priority right elements
1206        // (configured right-most first) until the remaining right side fits
1207        // alongside that minimum left budget.  We never drop the *first*
1208        // right element so the user keeps at least one piece of right-side
1209        // status if any was configured.
1210        let total_right_width: usize = right_items.iter().map(|(_, w, _)| *w).sum();
1211        let left_min_target = available_width
1212            .saturating_mul(2)
1213            .saturating_div(5) // ~40% of width reserved for left when feasible
1214            .min(40); // but never demand more than 40 cols even on wide terminals
1215        let right_budget = available_width.saturating_sub(left_min_target + 1);
1216        if total_right_width > right_budget && right_items.len() > 1 {
1217            let mut current = total_right_width;
1218            while current > right_budget && right_items.len() > 1 {
1219                if let Some(dropped) = right_items.pop() {
1220                    current = current.saturating_sub(dropped.1);
1221                } else {
1222                    break;
1223                }
1224            }
1225        }
1226
1227        let right_width: usize = right_items.iter().map(|(_, w, _)| *w).sum();
1228
1229        let narrow = available_width < 15;
1230        let left_max_width = if narrow {
1231            available_width
1232        } else if available_width > right_width + 1 {
1233            available_width - right_width - 1
1234        } else {
1235            1
1236        };
1237
1238        // Emit left side, consuming `left_items` so each element's spans move
1239        // directly into the output without a clone. Widths are cached so the
1240        // truncation check doesn't re-measure text.
1241        let mut spans: Vec<Span<'static>> = Vec::new();
1242        let mut used_left: usize = 0;
1243
1244        for (idx, (item_spans, width, kind)) in left_items.into_iter().enumerate() {
1245            let sep_width = if idx == 0 { 0 } else { separator_width };
1246            if used_left + sep_width >= left_max_width {
1247                break;
1248            }
1249            if sep_width > 0 {
1250                spans.push(Span::styled(SEPARATOR, base_style));
1251                used_left += sep_width;
1252            }
1253
1254            let remaining = left_max_width - used_left;
1255            let start_col = used_left;
1256
1257            if width <= remaining {
1258                spans.extend(item_spans);
1259                used_left += width;
1260
1261                Self::update_layout_for_element(
1262                    &mut layout,
1263                    kind,
1264                    area.y,
1265                    area.x + start_col as u16,
1266                    area.x + (start_col + width) as u16,
1267                );
1268            } else {
1269                // Overflow: truncate the concatenated text of this element.
1270                // Per-span styling is lost for the overflowed slice — we fall
1271                // back to whatever `element_style` would have returned.
1272                let group_text: String = item_spans.iter().map(|s| s.content.as_ref()).collect();
1273                let truncated = truncate_to_width(&group_text, remaining);
1274                let truncated_width = str_width(&truncated);
1275                let overflow_style = Self::element_style(
1276                    kind,
1277                    ctx.theme,
1278                    ctx.hover,
1279                    ctx.warning_level,
1280                    ctx.lsp_indicator_state,
1281                );
1282                spans.push(Span::styled(truncated, overflow_style));
1283                used_left += truncated_width;
1284
1285                Self::update_layout_for_element(
1286                    &mut layout,
1287                    kind,
1288                    area.y,
1289                    area.x + start_col as u16,
1290                    area.x + (start_col + truncated_width) as u16,
1291                );
1292                break;
1293            }
1294        }
1295
1296        if narrow {
1297            if used_left < available_width {
1298                spans.push(Span::styled(
1299                    " ".repeat(available_width - used_left),
1300                    base_style,
1301                ));
1302            }
1303            frame.render_widget(Paragraph::new(Line::from(spans)), area);
1304            return layout;
1305        }
1306
1307        let mut col_offset = used_left;
1308        if col_offset + right_width < available_width {
1309            let padding = available_width - col_offset - right_width;
1310            spans.push(Span::styled(" ".repeat(padding), base_style));
1311            col_offset = available_width - right_width;
1312        } else if col_offset < available_width {
1313            spans.push(Span::styled(" ", base_style));
1314            col_offset += 1;
1315        }
1316
1317        let mut current_col = area.x + col_offset as u16;
1318        for (item_spans, width, kind) in right_items {
1319            Self::update_layout_for_element(
1320                &mut layout,
1321                kind,
1322                area.y,
1323                current_col,
1324                current_col + width as u16,
1325            );
1326            spans.extend(item_spans);
1327            current_col += width as u16;
1328        }
1329
1330        frame.render_widget(Paragraph::new(Line::from(spans)), area);
1331        layout
1332    }
1333
1334    /// Render the search options bar (shown when search prompt is active)
1335    ///
1336    /// Displays checkboxes for search options with their keyboard shortcuts:
1337    /// - Case Sensitive (Alt+C)
1338    /// - Whole Word (Alt+W)
1339    /// - Regex (Alt+R)
1340    /// - Confirm Each (Alt+I) - only shown in replace mode
1341    ///
1342    /// # Returns
1343    /// Layout information for hit testing mouse clicks on checkboxes
1344    #[allow(clippy::too_many_arguments)]
1345    pub fn render_search_options(
1346        frame: &mut Frame,
1347        area: Rect,
1348        case_sensitive: bool,
1349        whole_word: bool,
1350        use_regex: bool,
1351        confirm_each: Option<bool>, // None = don't show, Some(value) = show with this state
1352        theme: &crate::view::theme::Theme,
1353        keybindings: &crate::input::keybindings::KeybindingResolver,
1354        hover: SearchOptionsHover,
1355    ) -> SearchOptionsLayout {
1356        use crate::primitives::display_width::str_width;
1357
1358        let mut layout = SearchOptionsLayout {
1359            row: area.y,
1360            ..Default::default()
1361        };
1362
1363        // Use menu dropdown background (dark gray) for the options bar
1364        let base_style = Style::default()
1365            .fg(theme.menu_dropdown_fg)
1366            .bg(theme.menu_dropdown_bg);
1367
1368        // Style for hovered options - use menu hover colors
1369        let hover_style = Style::default()
1370            .fg(theme.menu_hover_fg)
1371            .bg(theme.menu_hover_bg);
1372
1373        // Helper to look up keybinding for an action (Prompt context first, then Global)
1374        let get_shortcut = |action: &crate::input::keybindings::Action| -> Option<String> {
1375            keybindings
1376                .get_keybinding_for_action(action, crate::input::keybindings::KeyContext::Prompt)
1377                .or_else(|| {
1378                    keybindings.get_keybinding_for_action(
1379                        action,
1380                        crate::input::keybindings::KeyContext::Global,
1381                    )
1382                })
1383        };
1384
1385        // Get keybindings for search options
1386        let case_shortcut =
1387            get_shortcut(&crate::input::keybindings::Action::ToggleSearchCaseSensitive);
1388        let word_shortcut = get_shortcut(&crate::input::keybindings::Action::ToggleSearchWholeWord);
1389        let regex_shortcut = get_shortcut(&crate::input::keybindings::Action::ToggleSearchRegex);
1390
1391        // Build the options display with checkboxes
1392        let case_checkbox = if case_sensitive { "[x]" } else { "[ ]" };
1393        let word_checkbox = if whole_word { "[x]" } else { "[ ]" };
1394        let regex_checkbox = if use_regex { "[x]" } else { "[ ]" };
1395
1396        // Style for active (checked) options - highlighted with menu highlight colors
1397        let active_style = Style::default()
1398            .fg(theme.menu_highlight_fg)
1399            .bg(theme.menu_dropdown_bg);
1400
1401        // Style for keyboard shortcuts - use theme color for consistency
1402        let shortcut_style = Style::default()
1403            .fg(theme.help_separator_fg)
1404            .bg(theme.menu_dropdown_bg);
1405
1406        // Hovered shortcut style
1407        let hover_shortcut_style = Style::default()
1408            .fg(theme.menu_hover_fg)
1409            .bg(theme.menu_hover_bg);
1410
1411        let mut spans = Vec::new();
1412        let mut current_col = area.x;
1413
1414        // Left padding
1415        spans.push(Span::styled(" ", base_style));
1416        current_col += 1;
1417
1418        // Helper to get style based on hover and checked state
1419        let get_checkbox_style = |is_hovered: bool, is_checked: bool| -> Style {
1420            if is_hovered {
1421                hover_style
1422            } else if is_checked {
1423                active_style
1424            } else {
1425                base_style
1426            }
1427        };
1428
1429        // Case Sensitive option
1430        let case_hovered = hover == SearchOptionsHover::CaseSensitive;
1431        let case_start = current_col;
1432        let case_label = format!("{} {}", case_checkbox, t!("search.case_sensitive"));
1433        let case_shortcut_text = case_shortcut
1434            .as_ref()
1435            .map(|s| format!(" ({})", s))
1436            .unwrap_or_default();
1437        let case_full_width = str_width(&case_label) + str_width(&case_shortcut_text);
1438
1439        spans.push(Span::styled(
1440            case_label,
1441            get_checkbox_style(case_hovered, case_sensitive),
1442        ));
1443        if !case_shortcut_text.is_empty() {
1444            spans.push(Span::styled(
1445                case_shortcut_text,
1446                if case_hovered {
1447                    hover_shortcut_style
1448                } else {
1449                    shortcut_style
1450                },
1451            ));
1452        }
1453        current_col += case_full_width as u16;
1454        layout.case_sensitive = Some((case_start, current_col));
1455
1456        // Separator
1457        spans.push(Span::styled("   ", base_style));
1458        current_col += 3;
1459
1460        // Whole Word option
1461        let word_hovered = hover == SearchOptionsHover::WholeWord;
1462        let word_start = current_col;
1463        let word_label = format!("{} {}", word_checkbox, t!("search.whole_word"));
1464        let word_shortcut_text = word_shortcut
1465            .as_ref()
1466            .map(|s| format!(" ({})", s))
1467            .unwrap_or_default();
1468        let word_full_width = str_width(&word_label) + str_width(&word_shortcut_text);
1469
1470        spans.push(Span::styled(
1471            word_label,
1472            get_checkbox_style(word_hovered, whole_word),
1473        ));
1474        if !word_shortcut_text.is_empty() {
1475            spans.push(Span::styled(
1476                word_shortcut_text,
1477                if word_hovered {
1478                    hover_shortcut_style
1479                } else {
1480                    shortcut_style
1481                },
1482            ));
1483        }
1484        current_col += word_full_width as u16;
1485        layout.whole_word = Some((word_start, current_col));
1486
1487        // Separator
1488        spans.push(Span::styled("   ", base_style));
1489        current_col += 3;
1490
1491        // Regex option
1492        let regex_hovered = hover == SearchOptionsHover::Regex;
1493        let regex_start = current_col;
1494        let regex_label = format!("{} {}", regex_checkbox, t!("search.regex"));
1495        let regex_shortcut_text = regex_shortcut
1496            .as_ref()
1497            .map(|s| format!(" ({})", s))
1498            .unwrap_or_default();
1499        let regex_full_width = str_width(&regex_label) + str_width(&regex_shortcut_text);
1500
1501        spans.push(Span::styled(
1502            regex_label,
1503            get_checkbox_style(regex_hovered, use_regex),
1504        ));
1505        if !regex_shortcut_text.is_empty() {
1506            spans.push(Span::styled(
1507                regex_shortcut_text,
1508                if regex_hovered {
1509                    hover_shortcut_style
1510                } else {
1511                    shortcut_style
1512                },
1513            ));
1514        }
1515        current_col += regex_full_width as u16;
1516        layout.regex = Some((regex_start, current_col));
1517
1518        // Show capture group hint when regex is enabled in replace mode
1519        if use_regex && confirm_each.is_some() {
1520            let hint = " \u{2502} $1,$2,…";
1521            spans.push(Span::styled(hint, shortcut_style));
1522            current_col += str_width(hint) as u16;
1523        }
1524
1525        // Confirm Each option (only shown in replace mode)
1526        if let Some(confirm_value) = confirm_each {
1527            let confirm_shortcut =
1528                get_shortcut(&crate::input::keybindings::Action::ToggleSearchConfirmEach);
1529            let confirm_checkbox = if confirm_value { "[x]" } else { "[ ]" };
1530
1531            // Separator
1532            spans.push(Span::styled("   ", base_style));
1533            current_col += 3;
1534
1535            let confirm_hovered = hover == SearchOptionsHover::ConfirmEach;
1536            let confirm_start = current_col;
1537            let confirm_label = format!("{} {}", confirm_checkbox, t!("search.confirm_each"));
1538            let confirm_shortcut_text = confirm_shortcut
1539                .as_ref()
1540                .map(|s| format!(" ({})", s))
1541                .unwrap_or_default();
1542            let confirm_full_width = str_width(&confirm_label) + str_width(&confirm_shortcut_text);
1543
1544            spans.push(Span::styled(
1545                confirm_label,
1546                get_checkbox_style(confirm_hovered, confirm_value),
1547            ));
1548            if !confirm_shortcut_text.is_empty() {
1549                spans.push(Span::styled(
1550                    confirm_shortcut_text,
1551                    if confirm_hovered {
1552                        hover_shortcut_style
1553                    } else {
1554                        shortcut_style
1555                    },
1556                ));
1557            }
1558            current_col += confirm_full_width as u16;
1559            layout.confirm_each = Some((confirm_start, current_col));
1560        }
1561
1562        // Fill remaining space
1563        let current_width = (current_col - area.x) as usize;
1564        let available_width = area.width as usize;
1565        if current_width < available_width {
1566            spans.push(Span::styled(
1567                " ".repeat(available_width.saturating_sub(current_width)),
1568                base_style,
1569            ));
1570        }
1571
1572        let options_line = Paragraph::new(Line::from(spans));
1573        frame.render_widget(options_line, area);
1574
1575        layout
1576    }
1577}
1578
1579#[cfg(test)]
1580mod tests {
1581    use super::*;
1582    use std::path::PathBuf;
1583
1584    #[test]
1585    fn test_truncate_path_short_path() {
1586        let path = PathBuf::from("/home/user/project");
1587        let result = truncate_path(&path, 50);
1588
1589        assert!(!result.truncated);
1590        assert_eq!(result.suffix, "/home/user/project");
1591        assert!(result.prefix.is_empty());
1592    }
1593
1594    #[test]
1595    fn test_truncate_path_long_path() {
1596        let path = PathBuf::from(
1597            "/private/var/folders/p6/nlmq3k8146990kpkxl73mq340000gn/T/.tmpNYt4Fc/project_root",
1598        );
1599        let result = truncate_path(&path, 40);
1600
1601        assert!(result.truncated, "Path should be truncated");
1602        assert_eq!(result.prefix, "/private");
1603        assert!(
1604            result.suffix.contains("project_root"),
1605            "Suffix should contain project_root"
1606        );
1607    }
1608
1609    #[test]
1610    fn test_truncate_path_preserves_last_components() {
1611        let path = PathBuf::from("/a/b/c/d/e/f/g/h/i/j/project/src");
1612        let result = truncate_path(&path, 30);
1613
1614        assert!(result.truncated);
1615        // Should preserve the last components that fit
1616        assert!(
1617            result.suffix.contains("src"),
1618            "Should preserve last component 'src', got: {}",
1619            result.suffix
1620        );
1621    }
1622
1623    #[test]
1624    fn test_truncate_path_display_len() {
1625        let path = PathBuf::from("/private/var/folders/deep/nested/path/here");
1626        let result = truncate_path(&path, 30);
1627
1628        // The display length should not exceed max_len (approximately)
1629        let display = result.to_string_plain();
1630        assert!(
1631            display.len() <= 35, // Allow some slack for trailing slash
1632            "Display should be truncated to around 30 chars, got {} chars: {}",
1633            display.len(),
1634            display
1635        );
1636    }
1637
1638    #[test]
1639    fn test_truncate_path_root_only() {
1640        let path = PathBuf::from("/");
1641        let result = truncate_path(&path, 50);
1642
1643        assert!(!result.truncated);
1644        assert_eq!(result.suffix, "/");
1645    }
1646
1647    #[test]
1648    fn test_truncated_path_to_string_plain() {
1649        let truncated = TruncatedPath {
1650            prefix: "/home".to_string(),
1651            truncated: true,
1652            suffix: "/project/src".to_string(),
1653        };
1654
1655        assert_eq!(truncated.to_string_plain(), "/home/[...]/project/src");
1656    }
1657
1658    #[test]
1659    fn test_truncated_path_to_string_plain_no_truncation() {
1660        let truncated = TruncatedPath {
1661            prefix: String::new(),
1662            truncated: false,
1663            suffix: "/home/user/project".to_string(),
1664        };
1665
1666        assert_eq!(truncated.to_string_plain(), "/home/user/project");
1667    }
1668
1669    #[test]
1670    fn test_remote_indicator_element_kind_equality() {
1671        // Each lifecycle state produces a distinct ElementKind so the styler
1672        // can pick the right palette for Local / Connecting / Connected /
1673        // FailedAttach / Disconnected.
1674        assert_eq!(
1675            ElementKind::RemoteIndicator(RemoteIndicatorState::Local),
1676            ElementKind::RemoteIndicator(RemoteIndicatorState::Local)
1677        );
1678        let distinct = [
1679            RemoteIndicatorState::Local,
1680            RemoteIndicatorState::Connecting,
1681            RemoteIndicatorState::Connected,
1682            RemoteIndicatorState::FailedAttach,
1683            RemoteIndicatorState::Disconnected,
1684        ];
1685        for (i, a) in distinct.iter().enumerate() {
1686            for (j, b) in distinct.iter().enumerate() {
1687                if i == j {
1688                    continue;
1689                }
1690                assert_ne!(
1691                    ElementKind::RemoteIndicator(*a),
1692                    ElementKind::RemoteIndicator(*b),
1693                    "expected {:?} != {:?}",
1694                    a,
1695                    b
1696                );
1697            }
1698        }
1699    }
1700
1701    #[test]
1702    fn test_remote_indicator_state_default_is_local() {
1703        // `Default` → `Local` is relied on by callers that construct the
1704        // indicator before a connection is known.
1705        assert_eq!(RemoteIndicatorState::default(), RemoteIndicatorState::Local);
1706    }
1707
1708    #[test]
1709    fn test_remote_indicator_override_deserializes_kind_tags() {
1710        // Pins the wire shape the `SetRemoteIndicatorState` plugin op
1711        // accepts. A breaking change here would silently reject plugin
1712        // payloads after upgrade.
1713        let cases: &[(&str, RemoteIndicatorOverride)] = &[
1714            (r#"{"kind":"local"}"#, RemoteIndicatorOverride::Local),
1715            (
1716                r#"{"kind":"connecting","label":"Building"}"#,
1717                RemoteIndicatorOverride::Connecting {
1718                    label: Some("Building".into()),
1719                },
1720            ),
1721            (
1722                r#"{"kind":"connecting"}"#,
1723                RemoteIndicatorOverride::Connecting { label: None },
1724            ),
1725            (
1726                r#"{"kind":"connected","label":"Container:abc"}"#,
1727                RemoteIndicatorOverride::Connected {
1728                    label: Some("Container:abc".into()),
1729                },
1730            ),
1731            (
1732                r#"{"kind":"failed_attach","error":"exit 1"}"#,
1733                RemoteIndicatorOverride::FailedAttach {
1734                    error: Some("exit 1".into()),
1735                },
1736            ),
1737            (
1738                r#"{"kind":"disconnected","label":"Container:abc"}"#,
1739                RemoteIndicatorOverride::Disconnected {
1740                    label: Some("Container:abc".into()),
1741                },
1742            ),
1743        ];
1744        for (json, expected) in cases {
1745            let parsed: RemoteIndicatorOverride = serde_json::from_str(json)
1746                .unwrap_or_else(|e| panic!("failed to parse {}: {}", json, e));
1747            assert_eq!(&parsed, expected, "wire shape mismatch for {}", json);
1748        }
1749    }
1750
1751    #[test]
1752    fn test_remote_indicator_override_labels() {
1753        // Labels surface in the `{remote}` element text directly, so
1754        // defaults matter — a missing `label` must still produce
1755        // something readable.
1756        let connecting = RemoteIndicatorOverride::Connecting { label: None };
1757        assert!(
1758            connecting.label().contains("Connecting"),
1759            "connecting default label should mention Connecting, got {:?}",
1760            connecting.label()
1761        );
1762
1763        let connecting_labeled = RemoteIndicatorOverride::Connecting {
1764            label: Some("Building".into()),
1765        };
1766        assert!(
1767            connecting_labeled.label().contains("Building"),
1768            "labeled connecting should include the label, got {:?}",
1769            connecting_labeled.label()
1770        );
1771
1772        let failed_bare = RemoteIndicatorOverride::FailedAttach { error: None };
1773        assert_eq!(failed_bare.label(), "Attach failed");
1774
1775        let failed_detail = RemoteIndicatorOverride::FailedAttach {
1776            error: Some("exit 1".into()),
1777        };
1778        assert!(
1779            failed_detail.label().contains("exit 1"),
1780            "failed with error should include the error, got {:?}",
1781            failed_detail.label()
1782        );
1783    }
1784
1785    #[test]
1786    fn test_remote_indicator_override_state_projection() {
1787        assert_eq!(
1788            RemoteIndicatorOverride::Local.state(),
1789            RemoteIndicatorState::Local
1790        );
1791        assert_eq!(
1792            RemoteIndicatorOverride::Connecting { label: None }.state(),
1793            RemoteIndicatorState::Connecting
1794        );
1795        assert_eq!(
1796            RemoteIndicatorOverride::Connected { label: None }.state(),
1797            RemoteIndicatorState::Connected
1798        );
1799        assert_eq!(
1800            RemoteIndicatorOverride::FailedAttach { error: None }.state(),
1801            RemoteIndicatorState::FailedAttach
1802        );
1803        assert_eq!(
1804            RemoteIndicatorOverride::Disconnected { label: None }.state(),
1805            RemoteIndicatorState::Disconnected
1806        );
1807    }
1808}