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