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