Skip to main content

fresh/view/ui/
status_bar.rs

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