use std::collections::HashMap;
use std::path::Path;
use crate::app::WarningLevel;
use crate::config::{StatusBarConfig, StatusBarElement};
use crate::primitives::display_width::{char_width, str_width};
use crate::state::EditorState;
use crate::view::prompt::Prompt;
use chrono::Timelike;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use ratatui::Frame;
use rust_i18n::t;
const SSH_PREFIX: &str = "[SSH:";
const SSH_PREFIX_TERMINATOR: &str = "] ";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ElementKind {
Normal,
LineEnding,
Encoding,
Language,
Lsp,
WarningBadge,
Update,
Palette,
Messages,
RemoteDisconnected,
Clock,
RemoteIndicator(RemoteIndicatorState),
Custom,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum RemoteIndicatorState {
#[default]
Local,
Connecting,
Connected,
FailedAttach,
Disconnected,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum RemoteIndicatorOverride {
Local,
Connecting {
#[serde(default)]
label: Option<String>,
},
Connected {
#[serde(default)]
label: Option<String>,
},
FailedAttach {
#[serde(default)]
error: Option<String>,
},
Disconnected {
#[serde(default)]
label: Option<String>,
},
}
impl RemoteIndicatorOverride {
pub fn state(&self) -> RemoteIndicatorState {
match self {
Self::Local => RemoteIndicatorState::Local,
Self::Connecting { .. } => RemoteIndicatorState::Connecting,
Self::Connected { .. } => RemoteIndicatorState::Connected,
Self::FailedAttach { .. } => RemoteIndicatorState::FailedAttach,
Self::Disconnected { .. } => RemoteIndicatorState::Disconnected,
}
}
pub fn label(&self) -> String {
match self {
Self::Local => "Local".to_string(),
Self::Connecting { label } => match label {
Some(s) if !s.is_empty() => format!("⠿ {}", s),
_ => "⠿ Connecting".to_string(),
},
Self::Connected { label } => label
.as_deref()
.filter(|s| !s.is_empty())
.unwrap_or("Connected")
.to_string(),
Self::FailedAttach { error } => match error {
Some(s) if !s.is_empty() => format!("Attach failed: {}", s),
_ => "Attach failed".to_string(),
},
Self::Disconnected { label } => match label {
Some(s) if !s.is_empty() => format!("{} (Disconnected)", s),
_ => "Disconnected".to_string(),
},
}
}
}
struct RenderedElement {
text: String,
kind: ElementKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum LspIndicatorState {
#[default]
None,
On,
Off,
OffDismissed,
Error,
}
pub struct StatusBarContext<'a> {
pub state: &'a mut EditorState,
pub cursors: &'a crate::model::cursor::Cursors,
pub status_message: &'a Option<String>,
pub plugin_status_message: &'a Option<String>,
pub lsp_status: &'a str,
pub lsp_indicator_state: LspIndicatorState,
pub theme: &'a crate::view::theme::Theme,
pub display_name: &'a str,
pub keybindings: &'a crate::input::keybindings::KeybindingResolver,
pub chord_state: &'a [(crossterm::event::KeyCode, crossterm::event::KeyModifiers)],
pub update_available: Option<&'a str>,
pub warning_level: WarningLevel,
pub general_warning_count: usize,
pub hover: StatusBarHover,
pub remote_connection: Option<&'a str>,
pub session_name: Option<&'a str>,
pub read_only: bool,
pub remote_state_override: Option<&'a RemoteIndicatorOverride>,
pub is_synthetic_placeholder: bool,
pub remote_indicator_on_bar: bool,
pub dynamic_status_bar_elements: HashMap<String, String>,
}
#[derive(Debug, Clone, Default)]
pub struct StatusBarLayout {
pub lsp_indicator: Option<(u16, u16, u16)>,
pub warning_badge: Option<(u16, u16, u16)>,
pub line_ending_indicator: Option<(u16, u16, u16)>,
pub encoding_indicator: Option<(u16, u16, u16)>,
pub language_indicator: Option<(u16, u16, u16)>,
pub message_area: Option<(u16, u16, u16)>,
pub remote_indicator: Option<(u16, u16, u16)>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum StatusBarHover {
#[default]
None,
LspIndicator,
WarningBadge,
LineEndingIndicator,
EncodingIndicator,
LanguageIndicator,
MessageArea,
RemoteIndicator,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SearchOptionsHover {
#[default]
None,
CaseSensitive,
WholeWord,
Regex,
ConfirmEach,
}
#[derive(Debug, Clone, Default)]
pub struct SearchOptionsLayout {
pub row: u16,
pub case_sensitive: Option<(u16, u16)>,
pub whole_word: Option<(u16, u16)>,
pub regex: Option<(u16, u16)>,
pub confirm_each: Option<(u16, u16)>,
}
impl SearchOptionsLayout {
pub fn checkbox_at(&self, x: u16, y: u16) -> Option<SearchOptionsHover> {
if y != self.row {
return None;
}
if let Some((start, end)) = self.case_sensitive {
if x >= start && x < end {
return Some(SearchOptionsHover::CaseSensitive);
}
}
if let Some((start, end)) = self.whole_word {
if x >= start && x < end {
return Some(SearchOptionsHover::WholeWord);
}
}
if let Some((start, end)) = self.regex {
if x >= start && x < end {
return Some(SearchOptionsHover::Regex);
}
}
if let Some((start, end)) = self.confirm_each {
if x >= start && x < end {
return Some(SearchOptionsHover::ConfirmEach);
}
}
None
}
}
#[derive(Debug, Clone)]
pub struct TruncatedPath {
pub prefix: String,
pub truncated: bool,
pub suffix: String,
}
impl TruncatedPath {
pub fn to_string_plain(&self) -> String {
if self.truncated {
format!("{}/[...]{}", self.prefix, self.suffix)
} else {
format!("{}{}", self.prefix, self.suffix)
}
}
pub fn display_len(&self) -> usize {
if self.truncated {
self.prefix.len() + "/[...]".len() + self.suffix.len()
} else {
self.prefix.len() + self.suffix.len()
}
}
}
pub fn truncate_path(path: &Path, max_len: usize) -> TruncatedPath {
let path_str = path.to_string_lossy();
if path_str.len() <= max_len {
return TruncatedPath {
prefix: String::new(),
truncated: false,
suffix: path_str.to_string(),
};
}
let components: Vec<&str> = path_str.split('/').filter(|s| !s.is_empty()).collect();
if components.is_empty() {
return TruncatedPath {
prefix: "/".to_string(),
truncated: false,
suffix: String::new(),
};
}
let prefix = if path_str.starts_with('/') {
format!("/{}", components.first().unwrap_or(&""))
} else {
components.first().unwrap_or(&"").to_string()
};
let ellipsis_len = "/[...]".len();
let available_for_suffix = max_len.saturating_sub(prefix.len() + ellipsis_len);
if available_for_suffix < 5 || components.len() <= 1 {
let truncated_path = if path_str.len() > max_len.saturating_sub(3) {
let cut = path_str.floor_char_boundary(max_len.saturating_sub(3));
format!("{}...", &path_str[..cut])
} else {
path_str.to_string()
};
return TruncatedPath {
prefix: String::new(),
truncated: false,
suffix: truncated_path,
};
}
let mut suffix_parts: Vec<&str> = Vec::new();
let mut suffix_len = 0;
for component in components.iter().skip(1).rev() {
let component_len = component.len() + 1; if suffix_len + component_len <= available_for_suffix {
suffix_parts.push(component);
suffix_len += component_len;
} else {
break;
}
}
suffix_parts.reverse();
if suffix_parts.len() == components.len() - 1 {
return TruncatedPath {
prefix: String::new(),
truncated: false,
suffix: path_str.to_string(),
};
}
let suffix = if suffix_parts.is_empty() {
let last = components.last().unwrap_or(&"");
let truncate_to = available_for_suffix.saturating_sub(4); if truncate_to > 0 && last.len() > truncate_to {
let cut = last.floor_char_boundary(truncate_to);
format!("/{}...", &last[..cut])
} else {
format!("/{}", last)
}
} else {
format!("/{}", suffix_parts.join("/"))
};
TruncatedPath {
prefix,
truncated: true,
suffix,
}
}
fn truncate_to_width(s: &str, max_width: usize) -> String {
let width = str_width(s);
if width <= max_width {
return s.to_string();
}
let truncate_at = max_width.saturating_sub(3);
if truncate_at == 0 {
return if max_width >= 3 {
"...".to_string()
} else {
s.chars().take(max_width).collect()
};
}
let mut w = 0;
let truncated: String = s
.chars()
.take_while(|ch| {
let cw = char_width(*ch);
if w + cw <= truncate_at {
w += cw;
true
} else {
false
}
})
.collect();
format!("{}...", truncated)
}
const CURSOR_COL_RESERVE: usize = 3;
fn format_cursor_position(line: usize, col: usize, line_count: usize) -> String {
let text = format!("Ln {line}, Col {col}");
let line_digits = line_count.max(1).to_string().len();
let min_width = 9 + line_digits + CURSOR_COL_RESERVE;
if text.len() < min_width {
format!("{text:<min_width$}")
} else {
text
}
}
fn format_cursor_position_compact(line: usize, col: usize, line_count: usize) -> String {
let text = format!("{line}:{col}");
let line_digits = line_count.max(1).to_string().len();
let min_width = 1 + line_digits + CURSOR_COL_RESERVE;
if text.len() < min_width {
format!("{text:<min_width$}")
} else {
text
}
}
pub struct StatusBarRenderer;
impl StatusBarRenderer {
pub fn render_status_bar(
frame: &mut Frame,
area: Rect,
ctx: &mut StatusBarContext<'_>,
config: &StatusBarConfig,
) -> StatusBarLayout {
Self::render_status(frame, area, ctx, config)
}
pub fn render_prompt(
frame: &mut Frame,
area: Rect,
prompt: &Prompt,
theme: &crate::view::theme::Theme,
) {
let base_style = Style::default().fg(theme.prompt_fg).bg(theme.prompt_bg);
let mut spans = vec![Span::styled(prompt.message.clone(), base_style)];
if let Some((sel_start, sel_end)) = prompt.selection_range() {
let input = &prompt.input;
if sel_start > 0 {
spans.push(Span::styled(input[..sel_start].to_string(), base_style));
}
if sel_start < sel_end {
let selection_style = Style::default()
.fg(theme.prompt_selection_fg)
.bg(theme.prompt_selection_bg);
spans.push(Span::styled(
input[sel_start..sel_end].to_string(),
selection_style,
));
}
if sel_end < input.len() {
spans.push(Span::styled(input[sel_end..].to_string(), base_style));
}
} else {
spans.push(Span::styled(prompt.input.clone(), base_style));
}
let line = Line::from(spans);
let prompt_line = Paragraph::new(line).style(base_style);
frame.render_widget(prompt_line, area);
let message_width = str_width(&prompt.message);
let input_width_before_cursor = str_width(&prompt.input[..prompt.cursor_pos]);
let cursor_x = (message_width + input_width_before_cursor) as u16;
if cursor_x < area.width {
frame.set_cursor_position((area.x + cursor_x, area.y));
}
}
pub fn render_file_open_prompt(
frame: &mut Frame,
area: Rect,
prompt: &Prompt,
file_open_state: &crate::app::file_open::FileOpenState,
theme: &crate::view::theme::Theme,
) {
let base_style = Style::default().fg(theme.prompt_fg).bg(theme.prompt_bg);
let dir_style = Style::default()
.fg(theme.help_separator_fg)
.bg(theme.prompt_bg);
let ellipsis_style = Style::default()
.fg(theme.menu_highlight_fg)
.bg(theme.prompt_bg);
let mut spans = Vec::new();
let open_prompt = t!("file.open_prompt").to_string();
spans.push(Span::styled(open_prompt.clone(), base_style));
let prefix_len = str_width(&open_prompt);
let dir_path = file_open_state.current_dir.to_string_lossy();
let dir_path_len = dir_path.len() + 1; let input_len = prompt.input.len();
let total_len = prefix_len + dir_path_len + input_len;
let threshold = (area.width as usize * 90) / 100;
let truncated = if total_len > threshold {
let available_for_path = threshold
.saturating_sub(prefix_len)
.saturating_sub(input_len);
truncate_path(&file_open_state.current_dir, available_for_path)
} else {
TruncatedPath {
prefix: String::new(),
truncated: false,
suffix: dir_path.to_string(),
}
};
if truncated.truncated {
spans.push(Span::styled(truncated.prefix.clone(), dir_style));
spans.push(Span::styled("/[...]", ellipsis_style));
let suffix_with_slash = if truncated.suffix.ends_with('/') {
truncated.suffix.clone()
} else {
format!("{}/", truncated.suffix)
};
spans.push(Span::styled(suffix_with_slash, dir_style));
} else {
let path_display = if truncated.suffix.ends_with('/') {
truncated.suffix.clone()
} else {
format!("{}/", truncated.suffix)
};
spans.push(Span::styled(path_display, dir_style));
}
spans.push(Span::styled(prompt.input.clone(), base_style));
let line = Line::from(spans);
let prompt_line = Paragraph::new(line).style(base_style);
frame.render_widget(prompt_line, area);
let prefix_width = str_width(&open_prompt);
let dir_display_width = if truncated.truncated {
let suffix_with_slash = if truncated.suffix.ends_with('/') {
&truncated.suffix
} else {
&truncated.suffix
};
str_width(&truncated.prefix) + str_width("/[...]") + str_width(suffix_with_slash) + 1
} else {
str_width(&truncated.suffix) + 1 };
let input_width_before_cursor = str_width(&prompt.input[..prompt.cursor_pos]);
let cursor_x = (prefix_width + dir_display_width + input_width_before_cursor) as u16;
if cursor_x < area.width {
frame.set_cursor_position((area.x + cursor_x, area.y));
}
}
fn render_element(
element: &StatusBarElement,
ctx: &mut StatusBarContext<'_>,
) -> Option<RenderedElement> {
if ctx.is_synthetic_placeholder
&& matches!(
element,
StatusBarElement::Filename
| StatusBarElement::Cursor
| StatusBarElement::CursorCompact
| StatusBarElement::CursorCount
| StatusBarElement::Diagnostics
| StatusBarElement::LineEnding
| StatusBarElement::Encoding
| StatusBarElement::Language
)
{
return None;
}
match element {
StatusBarElement::Filename => {
let modified = if ctx.state.buffer.is_modified() {
" [+]"
} else {
""
};
let read_only_indicator = if ctx.read_only { " [RO]" } else { "" };
let remote_disconnected = ctx
.remote_connection
.map(|conn| conn.contains("(Disconnected)"))
.unwrap_or(false);
let remote_prefix = if ctx.remote_indicator_on_bar {
String::new()
} else {
ctx.remote_connection
.map(|conn| {
if conn.starts_with("Container:") {
format!("[{}] ", conn)
} else {
format!("{SSH_PREFIX}{conn}{SSH_PREFIX_TERMINATOR}")
}
})
.unwrap_or_default()
};
let session_prefix = ctx
.session_name
.map(|name| format!("[{}] ", name))
.unwrap_or_default();
let display_name = ctx.display_name;
let text = format!(
"{session_prefix}{remote_prefix}{display_name}{modified}{read_only_indicator}"
);
let kind = if remote_disconnected {
ElementKind::RemoteDisconnected
} else {
ElementKind::Normal
};
Some(RenderedElement { text, kind })
}
StatusBarElement::Cursor => {
if !ctx.state.show_cursors {
return None;
}
let cursor = *ctx.cursors.primary();
let line_count = ctx.state.buffer.line_count();
let text = if let Some(lc) = line_count {
let cursor_iter = ctx.state.buffer.line_iterator(cursor.position, 80);
let line_start = cursor_iter.current_position();
let col = cursor.position.saturating_sub(line_start);
let line = ctx.state.primary_cursor_line_number.value();
format_cursor_position(line + 1, col + 1, lc)
} else {
format!("Byte {}", cursor.position)
};
Some(RenderedElement {
text,
kind: ElementKind::Normal,
})
}
StatusBarElement::CursorCompact => {
if !ctx.state.show_cursors {
return None;
}
let cursor = *ctx.cursors.primary();
let line_count = ctx.state.buffer.line_count();
let text = if let Some(lc) = line_count {
let cursor_iter = ctx.state.buffer.line_iterator(cursor.position, 80);
let line_start = cursor_iter.current_position();
let col = cursor.position.saturating_sub(line_start);
let line = ctx.state.primary_cursor_line_number.value();
format_cursor_position_compact(line + 1, col + 1, lc)
} else {
format!("{}", cursor.position)
};
Some(RenderedElement {
text,
kind: ElementKind::Normal,
})
}
StatusBarElement::Diagnostics => {
let diagnostics = ctx.state.overlays.all();
let mut error_count = 0usize;
let mut warning_count = 0usize;
let mut info_count = 0usize;
let diagnostic_ns = crate::services::lsp::diagnostics::lsp_diagnostic_namespace();
for overlay in diagnostics {
if overlay.namespace.as_ref() == Some(&diagnostic_ns) {
match overlay.priority {
100 => error_count += 1,
50 => warning_count += 1,
_ => info_count += 1,
}
}
}
if error_count + warning_count + info_count == 0 {
return None;
}
let mut parts = Vec::new();
if error_count > 0 {
parts.push(format!("E:{}", error_count));
}
if warning_count > 0 {
parts.push(format!("W:{}", warning_count));
}
if info_count > 0 {
parts.push(format!("I:{}", info_count));
}
Some(RenderedElement {
text: parts.join(" "),
kind: ElementKind::Normal,
})
}
StatusBarElement::CursorCount => {
if ctx.cursors.count() <= 1 {
return None;
}
Some(RenderedElement {
text: t!("status.cursors", count = ctx.cursors.count()).to_string(),
kind: ElementKind::Normal,
})
}
StatusBarElement::Messages => {
let mut parts: Vec<&str> = Vec::new();
if let Some(msg) = ctx.status_message {
if !msg.is_empty() {
parts.push(msg);
}
}
if let Some(msg) = ctx.plugin_status_message {
if !msg.is_empty() {
parts.push(msg);
}
}
if parts.is_empty() {
return None;
}
Some(RenderedElement {
text: parts.join(" | "),
kind: ElementKind::Messages,
})
}
StatusBarElement::Chord => {
if ctx.chord_state.is_empty() {
return None;
}
let chord_str = ctx
.chord_state
.iter()
.map(|(code, modifiers)| {
crate::input::keybindings::format_keybinding(code, modifiers)
})
.collect::<Vec<_>>()
.join(" ");
Some(RenderedElement {
text: format!("[{}]", chord_str),
kind: ElementKind::Normal,
})
}
StatusBarElement::LineEnding => Some(RenderedElement {
text: format!(" {} ", ctx.state.buffer.line_ending().display_name()),
kind: ElementKind::LineEnding,
}),
StatusBarElement::Encoding => Some(RenderedElement {
text: format!(" {} ", ctx.state.buffer.encoding().display_name()),
kind: ElementKind::Encoding,
}),
StatusBarElement::Language => {
let text = if ctx.state.language == "text"
&& ctx.state.display_name != "Text"
&& ctx.state.display_name != "Plain Text"
&& ctx.state.display_name != "text"
{
format!(" {} [syntax only] ", &ctx.state.display_name)
} else {
format!(" {} ", &ctx.state.display_name)
};
Some(RenderedElement {
text,
kind: ElementKind::Language,
})
}
StatusBarElement::Lsp => {
if ctx.lsp_status.is_empty() {
return None;
}
Some(RenderedElement {
text: format!(" {} ", ctx.lsp_status),
kind: ElementKind::Lsp,
})
}
StatusBarElement::Warnings => {
if ctx.general_warning_count == 0 {
return None;
}
Some(RenderedElement {
text: format!(" [\u{26a0} {}] ", ctx.general_warning_count),
kind: ElementKind::WarningBadge,
})
}
StatusBarElement::Update => {
let version = ctx.update_available?;
Some(RenderedElement {
text: format!(" {} ", t!("status.update_available", version = version)),
kind: ElementKind::Update,
})
}
StatusBarElement::Palette => {
let shortcut = ctx
.keybindings
.get_keybinding_for_action(
&crate::input::keybindings::Action::QuickOpen,
crate::input::keybindings::KeyContext::Global,
)
.unwrap_or_else(|| "?".to_string());
Some(RenderedElement {
text: format!(" {} ", t!("status.palette", shortcut = shortcut)),
kind: ElementKind::Palette,
})
}
StatusBarElement::Clock => {
let now = chrono::Local::now();
let text = format!("{:02}:{:02}", now.hour(), now.minute());
Some(RenderedElement {
text,
kind: ElementKind::Clock,
})
}
StatusBarElement::RemoteIndicator => {
let (text, state) = if let Some(over) = ctx.remote_state_override {
(format!(" {} ", over.label()), over.state())
} else {
match ctx.remote_connection {
None => (" Local ".to_string(), RemoteIndicatorState::Local),
Some(conn) if conn.contains("(Disconnected)") => {
(format!(" {} ", conn), RemoteIndicatorState::Disconnected)
}
Some(conn) => (format!(" {} ", conn), RemoteIndicatorState::Connected),
}
};
Some(RenderedElement {
text,
kind: ElementKind::RemoteIndicator(state),
})
}
StatusBarElement::CustomToken(key) => {
if let Some(value) = ctx.dynamic_status_bar_elements.get(key) {
Some(RenderedElement {
text: value.clone(),
kind: ElementKind::Custom,
})
} else {
None }
}
}
}
fn element_style(
kind: ElementKind,
theme: &crate::view::theme::Theme,
hover: StatusBarHover,
_warning_level: WarningLevel,
lsp_state: LspIndicatorState,
) -> Style {
match kind {
ElementKind::Normal | ElementKind::Messages | ElementKind::Clock => Style::default()
.fg(theme.status_bar_fg)
.bg(theme.status_bar_bg),
ElementKind::RemoteDisconnected => Style::default()
.fg(theme.status_error_indicator_fg)
.bg(theme.status_error_indicator_bg),
ElementKind::LineEnding => {
let is_hovering = hover == StatusBarHover::LineEndingIndicator;
let (fg, bg) = if is_hovering {
(theme.menu_hover_fg, theme.menu_hover_bg)
} else {
(theme.status_bar_fg, theme.status_bar_bg)
};
let mut style = Style::default().fg(fg).bg(bg);
if is_hovering {
style = style.add_modifier(Modifier::UNDERLINED);
}
style
}
ElementKind::Encoding => {
let is_hovering = hover == StatusBarHover::EncodingIndicator;
let (fg, bg) = if is_hovering {
(theme.menu_hover_fg, theme.menu_hover_bg)
} else {
(theme.status_bar_fg, theme.status_bar_bg)
};
let mut style = Style::default().fg(fg).bg(bg);
if is_hovering {
style = style.add_modifier(Modifier::UNDERLINED);
}
style
}
ElementKind::Language => {
let is_hovering = hover == StatusBarHover::LanguageIndicator;
let (fg, bg) = if is_hovering {
(theme.menu_hover_fg, theme.menu_hover_bg)
} else {
(theme.status_bar_fg, theme.status_bar_bg)
};
let mut style = Style::default().fg(fg).bg(bg);
if is_hovering {
style = style.add_modifier(Modifier::UNDERLINED);
}
style
}
ElementKind::Lsp => {
let is_hovering = hover == StatusBarHover::LspIndicator;
let (fg, bg) = match lsp_state {
LspIndicatorState::Error => {
(theme.diagnostic_error_fg, theme.diagnostic_error_bg)
}
LspIndicatorState::Off => (
theme.status_lsp_actionable_fg,
theme.status_lsp_actionable_bg,
),
LspIndicatorState::On => (theme.status_lsp_on_fg, theme.status_lsp_on_bg),
LspIndicatorState::OffDismissed => (theme.status_bar_fg, theme.status_bar_bg),
LspIndicatorState::None => (theme.status_bar_fg, theme.status_bar_bg),
};
let mut style = Style::default().fg(fg).bg(bg);
if is_hovering && lsp_state != LspIndicatorState::None {
style = style.add_modifier(Modifier::UNDERLINED);
}
style
}
ElementKind::WarningBadge => {
let is_hovering = hover == StatusBarHover::WarningBadge;
let (fg, bg) = if is_hovering {
(
theme.status_warning_indicator_hover_fg,
theme.status_warning_indicator_hover_bg,
)
} else {
(
theme.status_warning_indicator_fg,
theme.status_warning_indicator_bg,
)
};
let mut style = Style::default().fg(fg).bg(bg);
if is_hovering {
style = style.add_modifier(Modifier::UNDERLINED);
}
style
}
ElementKind::Update => Style::default()
.fg(theme.menu_highlight_fg)
.bg(theme.menu_dropdown_bg),
ElementKind::Palette => Style::default()
.fg(theme.status_palette_fg)
.bg(theme.status_palette_bg),
ElementKind::Custom => Style::default()
.fg(theme.status_bar_fg)
.bg(theme.status_bar_bg),
ElementKind::RemoteIndicator(state) => {
let is_hovering = hover == StatusBarHover::RemoteIndicator;
let (fg, bg) = match state {
RemoteIndicatorState::Connecting | RemoteIndicatorState::Connected => {
(theme.help_indicator_fg, theme.help_indicator_bg)
}
RemoteIndicatorState::FailedAttach | RemoteIndicatorState::Disconnected => (
theme.status_error_indicator_fg,
theme.status_error_indicator_bg,
),
RemoteIndicatorState::Local => (theme.status_bar_fg, theme.status_bar_bg),
};
let mut style = Style::default().fg(fg).bg(bg);
if is_hovering {
style = style.add_modifier(Modifier::UNDERLINED);
}
style
}
}
}
fn update_layout_for_element(
layout: &mut StatusBarLayout,
kind: ElementKind,
row: u16,
start_col: u16,
end_col: u16,
) {
match kind {
ElementKind::LineEnding => {
layout.line_ending_indicator = Some((row, start_col, end_col))
}
ElementKind::Encoding => layout.encoding_indicator = Some((row, start_col, end_col)),
ElementKind::Language => layout.language_indicator = Some((row, start_col, end_col)),
ElementKind::Lsp => layout.lsp_indicator = Some((row, start_col, end_col)),
ElementKind::WarningBadge => layout.warning_badge = Some((row, start_col, end_col)),
ElementKind::Messages => layout.message_area = Some((row, start_col, end_col)),
ElementKind::RemoteIndicator(_) => {
layout.remote_indicator = Some((row, start_col, end_col))
}
_ => {}
}
}
fn element_spans(
rendered: &RenderedElement,
theme: &crate::view::theme::Theme,
hover: StatusBarHover,
warning_level: WarningLevel,
lsp_state: LspIndicatorState,
) -> (Vec<Span<'static>>, usize) {
let base_style = Style::default()
.fg(theme.status_bar_fg)
.bg(theme.status_bar_bg);
let width = str_width(&rendered.text);
if rendered.kind == ElementKind::RemoteDisconnected && rendered.text.starts_with(SSH_PREFIX)
{
let error_style = Style::default()
.fg(theme.status_error_indicator_fg)
.bg(theme.status_error_indicator_bg);
if let Some(term_off) = rendered.text.find(SSH_PREFIX_TERMINATOR) {
let split_at = term_off + SSH_PREFIX_TERMINATOR.len();
let prefix = rendered.text[..split_at].to_string();
let rest = rendered.text[split_at..].to_string();
return (
vec![
Span::styled(prefix, error_style),
Span::styled(rest, base_style),
],
width,
);
}
return (
vec![Span::styled(rendered.text.clone(), error_style)],
width,
);
}
let style = Self::element_style(rendered.kind, theme, hover, warning_level, lsp_state);
let spans = if rendered.kind == ElementKind::Clock {
vec![
Span::styled(rendered.text[..2].to_string(), style),
Span::styled(":".to_string(), style.add_modifier(Modifier::SLOW_BLINK)),
Span::styled(rendered.text[3..].to_string(), style),
]
} else {
vec![Span::styled(rendered.text.clone(), style)]
};
(spans, width)
}
fn render_side(
config_side: &[StatusBarElement],
ctx: &mut StatusBarContext<'_>,
) -> Vec<(Vec<Span<'static>>, usize, ElementKind)> {
let rendered: Vec<RenderedElement> = config_side
.iter()
.filter_map(|elem| Self::render_element(elem, ctx))
.filter(|e| !e.text.is_empty())
.collect();
let theme = ctx.theme;
let hover = ctx.hover;
let warning_level = ctx.warning_level;
let lsp_state = ctx.lsp_indicator_state;
rendered
.into_iter()
.map(|r| {
let kind = r.kind;
let (spans, width) =
Self::element_spans(&r, theme, hover, warning_level, lsp_state);
(spans, width, kind)
})
.collect()
}
fn render_status(
frame: &mut Frame,
area: Rect,
ctx: &mut StatusBarContext<'_>,
config: &StatusBarConfig,
) -> StatusBarLayout {
let mut layout = StatusBarLayout::default();
let base_style = Style::default()
.fg(ctx.theme.status_bar_fg)
.bg(ctx.theme.status_bar_bg);
let available_width = area.width as usize;
if available_width == 0 || area.height == 0 {
return layout;
}
ctx.remote_indicator_on_bar = config
.left
.iter()
.chain(config.right.iter())
.any(|e| matches!(e, StatusBarElement::RemoteIndicator));
let left_items = Self::render_side(&config.left, ctx);
let mut right_items = Self::render_side(&config.right, ctx);
const SEPARATOR: &str = " | ";
let separator_width = str_width(SEPARATOR);
let total_right_width: usize = right_items.iter().map(|(_, w, _)| *w).sum();
let left_min_target = available_width
.saturating_mul(2)
.saturating_div(5) .min(40); let right_budget = available_width.saturating_sub(left_min_target + 1);
if total_right_width > right_budget && right_items.len() > 1 {
let mut current = total_right_width;
while current > right_budget && right_items.len() > 1 {
if let Some(dropped) = right_items.pop() {
current = current.saturating_sub(dropped.1);
} else {
break;
}
}
}
let right_width: usize = right_items.iter().map(|(_, w, _)| *w).sum();
let narrow = available_width < 15;
let left_max_width = if narrow {
available_width
} else if available_width > right_width + 1 {
available_width - right_width - 1
} else {
1
};
let mut spans: Vec<Span<'static>> = Vec::new();
let mut used_left: usize = 0;
for (idx, (item_spans, width, kind)) in left_items.into_iter().enumerate() {
let sep_width = if idx == 0 { 0 } else { separator_width };
if used_left + sep_width >= left_max_width {
break;
}
if sep_width > 0 {
spans.push(Span::styled(SEPARATOR, base_style));
used_left += sep_width;
}
let remaining = left_max_width - used_left;
let start_col = used_left;
if width <= remaining {
spans.extend(item_spans);
used_left += width;
Self::update_layout_for_element(
&mut layout,
kind,
area.y,
area.x + start_col as u16,
area.x + (start_col + width) as u16,
);
} else {
let group_text: String = item_spans.iter().map(|s| s.content.as_ref()).collect();
let truncated = truncate_to_width(&group_text, remaining);
let truncated_width = str_width(&truncated);
let overflow_style = Self::element_style(
kind,
ctx.theme,
ctx.hover,
ctx.warning_level,
ctx.lsp_indicator_state,
);
spans.push(Span::styled(truncated, overflow_style));
used_left += truncated_width;
Self::update_layout_for_element(
&mut layout,
kind,
area.y,
area.x + start_col as u16,
area.x + (start_col + truncated_width) as u16,
);
break;
}
}
if narrow {
if used_left < available_width {
spans.push(Span::styled(
" ".repeat(available_width - used_left),
base_style,
));
}
frame.render_widget(Paragraph::new(Line::from(spans)), area);
return layout;
}
let mut col_offset = used_left;
if col_offset + right_width < available_width {
let padding = available_width - col_offset - right_width;
spans.push(Span::styled(" ".repeat(padding), base_style));
col_offset = available_width - right_width;
} else if col_offset < available_width {
spans.push(Span::styled(" ", base_style));
col_offset += 1;
}
let mut current_col = area.x + col_offset as u16;
for (item_spans, width, kind) in right_items {
Self::update_layout_for_element(
&mut layout,
kind,
area.y,
current_col,
current_col + width as u16,
);
spans.extend(item_spans);
current_col += width as u16;
}
frame.render_widget(Paragraph::new(Line::from(spans)), area);
layout
}
#[allow(clippy::too_many_arguments)]
pub fn render_search_options(
frame: &mut Frame,
area: Rect,
case_sensitive: bool,
whole_word: bool,
use_regex: bool,
confirm_each: Option<bool>, theme: &crate::view::theme::Theme,
keybindings: &crate::input::keybindings::KeybindingResolver,
hover: SearchOptionsHover,
) -> SearchOptionsLayout {
use crate::primitives::display_width::str_width;
let mut layout = SearchOptionsLayout {
row: area.y,
..Default::default()
};
let base_style = Style::default()
.fg(theme.menu_dropdown_fg)
.bg(theme.menu_dropdown_bg);
let hover_style = Style::default()
.fg(theme.menu_hover_fg)
.bg(theme.menu_hover_bg);
let get_shortcut = |action: &crate::input::keybindings::Action| -> Option<String> {
keybindings
.get_keybinding_for_action(action, crate::input::keybindings::KeyContext::Prompt)
.or_else(|| {
keybindings.get_keybinding_for_action(
action,
crate::input::keybindings::KeyContext::Global,
)
})
};
let case_shortcut =
get_shortcut(&crate::input::keybindings::Action::ToggleSearchCaseSensitive);
let word_shortcut = get_shortcut(&crate::input::keybindings::Action::ToggleSearchWholeWord);
let regex_shortcut = get_shortcut(&crate::input::keybindings::Action::ToggleSearchRegex);
let case_checkbox = if case_sensitive { "[x]" } else { "[ ]" };
let word_checkbox = if whole_word { "[x]" } else { "[ ]" };
let regex_checkbox = if use_regex { "[x]" } else { "[ ]" };
let active_style = Style::default()
.fg(theme.menu_highlight_fg)
.bg(theme.menu_dropdown_bg);
let shortcut_style = Style::default()
.fg(theme.help_separator_fg)
.bg(theme.menu_dropdown_bg);
let hover_shortcut_style = Style::default()
.fg(theme.menu_hover_fg)
.bg(theme.menu_hover_bg);
let mut spans = Vec::new();
let mut current_col = area.x;
spans.push(Span::styled(" ", base_style));
current_col += 1;
let get_checkbox_style = |is_hovered: bool, is_checked: bool| -> Style {
if is_hovered {
hover_style
} else if is_checked {
active_style
} else {
base_style
}
};
let case_hovered = hover == SearchOptionsHover::CaseSensitive;
let case_start = current_col;
let case_label = format!("{} {}", case_checkbox, t!("search.case_sensitive"));
let case_shortcut_text = case_shortcut
.as_ref()
.map(|s| format!(" ({})", s))
.unwrap_or_default();
let case_full_width = str_width(&case_label) + str_width(&case_shortcut_text);
spans.push(Span::styled(
case_label,
get_checkbox_style(case_hovered, case_sensitive),
));
if !case_shortcut_text.is_empty() {
spans.push(Span::styled(
case_shortcut_text,
if case_hovered {
hover_shortcut_style
} else {
shortcut_style
},
));
}
current_col += case_full_width as u16;
layout.case_sensitive = Some((case_start, current_col));
spans.push(Span::styled(" ", base_style));
current_col += 3;
let word_hovered = hover == SearchOptionsHover::WholeWord;
let word_start = current_col;
let word_label = format!("{} {}", word_checkbox, t!("search.whole_word"));
let word_shortcut_text = word_shortcut
.as_ref()
.map(|s| format!(" ({})", s))
.unwrap_or_default();
let word_full_width = str_width(&word_label) + str_width(&word_shortcut_text);
spans.push(Span::styled(
word_label,
get_checkbox_style(word_hovered, whole_word),
));
if !word_shortcut_text.is_empty() {
spans.push(Span::styled(
word_shortcut_text,
if word_hovered {
hover_shortcut_style
} else {
shortcut_style
},
));
}
current_col += word_full_width as u16;
layout.whole_word = Some((word_start, current_col));
spans.push(Span::styled(" ", base_style));
current_col += 3;
let regex_hovered = hover == SearchOptionsHover::Regex;
let regex_start = current_col;
let regex_label = format!("{} {}", regex_checkbox, t!("search.regex"));
let regex_shortcut_text = regex_shortcut
.as_ref()
.map(|s| format!(" ({})", s))
.unwrap_or_default();
let regex_full_width = str_width(®ex_label) + str_width(®ex_shortcut_text);
spans.push(Span::styled(
regex_label,
get_checkbox_style(regex_hovered, use_regex),
));
if !regex_shortcut_text.is_empty() {
spans.push(Span::styled(
regex_shortcut_text,
if regex_hovered {
hover_shortcut_style
} else {
shortcut_style
},
));
}
current_col += regex_full_width as u16;
layout.regex = Some((regex_start, current_col));
if use_regex && confirm_each.is_some() {
let hint = " \u{2502} $1,$2,…";
spans.push(Span::styled(hint, shortcut_style));
current_col += str_width(hint) as u16;
}
if let Some(confirm_value) = confirm_each {
let confirm_shortcut =
get_shortcut(&crate::input::keybindings::Action::ToggleSearchConfirmEach);
let confirm_checkbox = if confirm_value { "[x]" } else { "[ ]" };
spans.push(Span::styled(" ", base_style));
current_col += 3;
let confirm_hovered = hover == SearchOptionsHover::ConfirmEach;
let confirm_start = current_col;
let confirm_label = format!("{} {}", confirm_checkbox, t!("search.confirm_each"));
let confirm_shortcut_text = confirm_shortcut
.as_ref()
.map(|s| format!(" ({})", s))
.unwrap_or_default();
let confirm_full_width = str_width(&confirm_label) + str_width(&confirm_shortcut_text);
spans.push(Span::styled(
confirm_label,
get_checkbox_style(confirm_hovered, confirm_value),
));
if !confirm_shortcut_text.is_empty() {
spans.push(Span::styled(
confirm_shortcut_text,
if confirm_hovered {
hover_shortcut_style
} else {
shortcut_style
},
));
}
current_col += confirm_full_width as u16;
layout.confirm_each = Some((confirm_start, current_col));
}
let current_width = (current_col - area.x) as usize;
let available_width = area.width as usize;
if current_width < available_width {
spans.push(Span::styled(
" ".repeat(available_width.saturating_sub(current_width)),
base_style,
));
}
let options_line = Paragraph::new(Line::from(spans));
frame.render_widget(options_line, area);
layout
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn test_truncate_path_short_path() {
let path = PathBuf::from("/home/user/project");
let result = truncate_path(&path, 50);
assert!(!result.truncated);
assert_eq!(result.suffix, "/home/user/project");
assert!(result.prefix.is_empty());
}
#[test]
fn test_truncate_path_long_path() {
let path = PathBuf::from(
"/private/var/folders/p6/nlmq3k8146990kpkxl73mq340000gn/T/.tmpNYt4Fc/project_root",
);
let result = truncate_path(&path, 40);
assert!(result.truncated, "Path should be truncated");
assert_eq!(result.prefix, "/private");
assert!(
result.suffix.contains("project_root"),
"Suffix should contain project_root"
);
}
#[test]
fn test_truncate_path_preserves_last_components() {
let path = PathBuf::from("/a/b/c/d/e/f/g/h/i/j/project/src");
let result = truncate_path(&path, 30);
assert!(result.truncated);
assert!(
result.suffix.contains("src"),
"Should preserve last component 'src', got: {}",
result.suffix
);
}
#[test]
fn test_truncate_path_display_len() {
let path = PathBuf::from("/private/var/folders/deep/nested/path/here");
let result = truncate_path(&path, 30);
let display = result.to_string_plain();
assert!(
display.len() <= 35, "Display should be truncated to around 30 chars, got {} chars: {}",
display.len(),
display
);
}
#[test]
fn test_truncate_path_root_only() {
let path = PathBuf::from("/");
let result = truncate_path(&path, 50);
assert!(!result.truncated);
assert_eq!(result.suffix, "/");
}
#[test]
fn test_truncate_path_multibyte_single_component_does_not_panic() {
let path = PathBuf::from("/ユーザーのプロジェクト名前/file");
let result = truncate_path(&path, 5);
let display = result.to_string_plain();
assert!(display.is_char_boundary(display.len()));
assert!(display.ends_with("..."));
}
#[test]
fn test_truncate_path_multibyte_last_component_does_not_panic() {
let path = PathBuf::from("/a/ユーザーのプロジェクト名前");
let result = truncate_path(&path, 13);
let display = result.to_string_plain();
assert!(display.is_char_boundary(display.len()));
}
#[test]
fn test_truncated_path_to_string_plain() {
let truncated = TruncatedPath {
prefix: "/home".to_string(),
truncated: true,
suffix: "/project/src".to_string(),
};
assert_eq!(truncated.to_string_plain(), "/home/[...]/project/src");
}
#[test]
fn test_truncated_path_to_string_plain_no_truncation() {
let truncated = TruncatedPath {
prefix: String::new(),
truncated: false,
suffix: "/home/user/project".to_string(),
};
assert_eq!(truncated.to_string_plain(), "/home/user/project");
}
#[test]
fn test_remote_indicator_element_kind_equality() {
assert_eq!(
ElementKind::RemoteIndicator(RemoteIndicatorState::Local),
ElementKind::RemoteIndicator(RemoteIndicatorState::Local)
);
let distinct = [
RemoteIndicatorState::Local,
RemoteIndicatorState::Connecting,
RemoteIndicatorState::Connected,
RemoteIndicatorState::FailedAttach,
RemoteIndicatorState::Disconnected,
];
for (i, a) in distinct.iter().enumerate() {
for (j, b) in distinct.iter().enumerate() {
if i == j {
continue;
}
assert_ne!(
ElementKind::RemoteIndicator(*a),
ElementKind::RemoteIndicator(*b),
"expected {:?} != {:?}",
a,
b
);
}
}
}
#[test]
fn test_remote_indicator_state_default_is_local() {
assert_eq!(RemoteIndicatorState::default(), RemoteIndicatorState::Local);
}
#[test]
fn test_remote_indicator_override_deserializes_kind_tags() {
let cases: &[(&str, RemoteIndicatorOverride)] = &[
(r#"{"kind":"local"}"#, RemoteIndicatorOverride::Local),
(
r#"{"kind":"connecting","label":"Building"}"#,
RemoteIndicatorOverride::Connecting {
label: Some("Building".into()),
},
),
(
r#"{"kind":"connecting"}"#,
RemoteIndicatorOverride::Connecting { label: None },
),
(
r#"{"kind":"connected","label":"Container:abc"}"#,
RemoteIndicatorOverride::Connected {
label: Some("Container:abc".into()),
},
),
(
r#"{"kind":"failed_attach","error":"exit 1"}"#,
RemoteIndicatorOverride::FailedAttach {
error: Some("exit 1".into()),
},
),
(
r#"{"kind":"disconnected","label":"Container:abc"}"#,
RemoteIndicatorOverride::Disconnected {
label: Some("Container:abc".into()),
},
),
];
for (json, expected) in cases {
let parsed: RemoteIndicatorOverride = serde_json::from_str(json)
.unwrap_or_else(|e| panic!("failed to parse {}: {}", json, e));
assert_eq!(&parsed, expected, "wire shape mismatch for {}", json);
}
}
#[test]
fn test_remote_indicator_override_labels() {
let connecting = RemoteIndicatorOverride::Connecting { label: None };
assert!(
connecting.label().contains("Connecting"),
"connecting default label should mention Connecting, got {:?}",
connecting.label()
);
let connecting_labeled = RemoteIndicatorOverride::Connecting {
label: Some("Building".into()),
};
assert!(
connecting_labeled.label().contains("Building"),
"labeled connecting should include the label, got {:?}",
connecting_labeled.label()
);
let failed_bare = RemoteIndicatorOverride::FailedAttach { error: None };
assert_eq!(failed_bare.label(), "Attach failed");
let failed_detail = RemoteIndicatorOverride::FailedAttach {
error: Some("exit 1".into()),
};
assert!(
failed_detail.label().contains("exit 1"),
"failed with error should include the error, got {:?}",
failed_detail.label()
);
}
#[test]
fn test_palette_and_lsp_on_use_dedicated_theme_keys() {
let theme = crate::view::theme::Theme::from_json(
r#"{"name":"t","editor":{},"ui":{},"search":{},"diagnostic":{},"syntax":{}}"#,
)
.expect("minimal theme should parse");
assert_eq!(theme.status_palette_fg, theme.status_bar_fg);
assert_eq!(theme.status_palette_bg, theme.status_bar_bg);
assert_eq!(theme.status_lsp_on_fg, theme.status_bar_fg);
assert_eq!(theme.status_lsp_on_bg, theme.status_bar_bg);
let palette_style = StatusBarRenderer::element_style(
ElementKind::Palette,
&theme,
StatusBarHover::None,
WarningLevel::None,
LspIndicatorState::None,
);
assert_eq!(palette_style.fg, Some(theme.status_palette_fg));
assert_eq!(palette_style.bg, Some(theme.status_palette_bg));
let lsp_on_style = StatusBarRenderer::element_style(
ElementKind::Lsp,
&theme,
StatusBarHover::None,
WarningLevel::None,
LspIndicatorState::On,
);
assert_eq!(lsp_on_style.fg, Some(theme.status_lsp_on_fg));
assert_eq!(lsp_on_style.bg, Some(theme.status_lsp_on_bg));
let lsp_off_style = StatusBarRenderer::element_style(
ElementKind::Lsp,
&theme,
StatusBarHover::None,
WarningLevel::None,
LspIndicatorState::Off,
);
assert_eq!(lsp_off_style.fg, Some(theme.status_lsp_actionable_fg));
assert_eq!(lsp_off_style.bg, Some(theme.status_lsp_actionable_bg));
let lsp_error_style = StatusBarRenderer::element_style(
ElementKind::Lsp,
&theme,
StatusBarHover::None,
WarningLevel::None,
LspIndicatorState::Error,
);
assert_eq!(lsp_error_style.fg, Some(theme.diagnostic_error_fg));
assert_eq!(lsp_error_style.bg, Some(theme.diagnostic_error_bg));
}
#[test]
fn test_status_palette_and_lsp_on_keys_override_independently() {
let theme_json = r#"{
"name":"t",
"editor":{},
"ui":{
"status_bar_fg":"White",
"status_bar_bg":"DarkGray",
"status_palette_fg":"Black",
"status_palette_bg":"Yellow",
"status_lsp_on_fg":"Black",
"status_lsp_on_bg":"Cyan"
},
"search":{},
"diagnostic":{},
"syntax":{}
}"#;
let theme = crate::view::theme::Theme::from_json(theme_json).expect("theme should parse");
assert_ne!(theme.status_palette_fg, theme.status_bar_fg);
assert_ne!(theme.status_palette_bg, theme.status_bar_bg);
assert_ne!(theme.status_lsp_on_fg, theme.status_bar_fg);
assert_ne!(theme.status_lsp_on_bg, theme.status_bar_bg);
}
#[test]
fn test_remote_indicator_override_state_projection() {
assert_eq!(
RemoteIndicatorOverride::Local.state(),
RemoteIndicatorState::Local
);
assert_eq!(
RemoteIndicatorOverride::Connecting { label: None }.state(),
RemoteIndicatorState::Connecting
);
assert_eq!(
RemoteIndicatorOverride::Connected { label: None }.state(),
RemoteIndicatorState::Connected
);
assert_eq!(
RemoteIndicatorOverride::FailedAttach { error: None }.state(),
RemoteIndicatorState::FailedAttach
);
assert_eq!(
RemoteIndicatorOverride::Disconnected { label: None }.state(),
RemoteIndicatorState::Disconnected
);
}
#[test]
fn test_cursor_position_widths_stable_across_cursor_movement() {
let line_count = 50;
let widths: Vec<usize> = [(1, 1), (5, 12), (12, 5), (50, 100), (1, 1)]
.into_iter()
.map(|(ln, col)| format_cursor_position(ln, col, line_count).len())
.collect();
assert!(
widths.windows(2).all(|w| w[0] == w[1]),
"rendered widths drift across cursor movements: {widths:?}"
);
}
#[test]
fn test_cursor_position_preserves_natural_number_text() {
let text = format_cursor_position(1, 1, 50);
assert!(
text.starts_with("Ln 1, Col 1"),
"expected text to start with natural numbers, got {text:?}"
);
assert!(
text.ends_with(' '),
"expected trailing padding, got {text:?}"
);
}
#[test]
fn test_cursor_position_no_padding_for_single_line_buffer() {
let text = format_cursor_position(1, 1, 1);
assert_eq!(text.len(), 13);
assert!(text.starts_with("Ln 1, Col 1"));
}
#[test]
fn test_cursor_position_does_not_shrink_below_actual() {
let text = format_cursor_position(99, 99999, 50);
assert_eq!(text, "Ln 99, Col 99999");
}
#[test]
fn test_cursor_position_compact_widths_stable() {
let line_count = 50;
let widths: Vec<usize> = [(1, 1), (5, 12), (12, 5), (50, 100), (1, 1)]
.into_iter()
.map(|(ln, col)| format_cursor_position_compact(ln, col, line_count).len())
.collect();
assert!(
widths.windows(2).all(|w| w[0] == w[1]),
"compact widths drift across cursor movements: {widths:?}"
);
}
#[test]
fn test_cursor_position_compact_preserves_natural_text() {
let text = format_cursor_position_compact(1, 1, 50);
assert!(
text.starts_with("1:1"),
"expected text to start with natural numbers, got {text:?}"
);
}
#[test]
fn test_cursor_position_scales_with_line_count() {
let short = format_cursor_position(1, 1, 9);
let long = format_cursor_position(1, 1, 10_000);
assert!(
long.len() > short.len(),
"wider buffers should reserve more width: {short:?} vs {long:?}"
);
let top = format_cursor_position(1, 1, 10_000);
let high = format_cursor_position(9_999, 999, 10_000);
assert_eq!(top.len(), high.len());
}
}