Skip to main content

fresh/view/ui/
status_bar.rs

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