use std::path::Path;
use crate::state::EditorState;
use crate::view::prompt::Prompt;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use ratatui::Frame;
#[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) {
format!("{}...", &path_str[..max_len.saturating_sub(3)])
} 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 {
format!("/{}...", &last[..truncate_to])
} else {
format!("/{}", last)
}
} else {
format!("/{}", suffix_parts.join("/"))
};
TruncatedPath {
prefix,
truncated: true,
suffix,
}
}
pub struct StatusBarRenderer;
impl StatusBarRenderer {
pub fn render_status_bar(
frame: &mut Frame,
area: Rect,
state: &mut EditorState,
status_message: &Option<String>,
plugin_status_message: &Option<String>,
lsp_status: &str,
theme: &crate::view::theme::Theme,
display_name: &str,
keybindings: &crate::input::keybindings::KeybindingResolver,
chord_state: &[(crossterm::event::KeyCode, crossterm::event::KeyModifiers)],
update_available: Option<&str>,
) {
Self::render_status(
frame,
area,
state,
status_message,
plugin_status_message,
lsp_status,
theme,
display_name,
keybindings,
chord_state,
update_available,
);
}
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 cursor_x = (prompt.message.len() + prompt.cursor_pos) 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();
spans.push(Span::styled("Open: ", base_style));
let prefix_len = 6; 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(),
}
};
let dir_display_len = 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)
};
let len = truncated.prefix.len() + "/[...]".len() + suffix_with_slash.len();
spans.push(Span::styled(suffix_with_slash, dir_style));
len
} else {
let path_display = if truncated.suffix.ends_with('/') {
truncated.suffix.clone()
} else {
format!("{}/", truncated.suffix)
};
let len = path_display.len();
spans.push(Span::styled(path_display, dir_style));
len
};
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 cursor_offset = prefix_len + dir_display_len + prompt.cursor_pos;
let cursor_x = cursor_offset as u16;
if cursor_x < area.width {
frame.set_cursor_position((area.x + cursor_x, area.y));
}
}
fn render_status(
frame: &mut Frame,
area: Rect,
state: &mut EditorState,
status_message: &Option<String>,
plugin_status_message: &Option<String>,
lsp_status: &str,
theme: &crate::view::theme::Theme,
display_name: &str,
keybindings: &crate::input::keybindings::KeybindingResolver,
chord_state: &[(crossterm::event::KeyCode, crossterm::event::KeyModifiers)],
update_available: Option<&str>,
) {
let filename = display_name;
let modified = if state.buffer.is_modified() {
" [+]"
} else {
""
};
let chord_display = if !chord_state.is_empty() {
let chord_str = chord_state
.iter()
.map(|(code, modifiers)| {
crate::input::keybindings::format_keybinding(code, modifiers)
})
.collect::<Vec<_>>()
.join(" ");
format!(" [{}]", chord_str)
} else {
String::new()
};
let mode_label = match state.view_mode {
crate::state::ViewMode::Compose => " | Compose",
_ => "",
};
let cursor = *state.primary_cursor();
let (line, col) = {
let cursor_iter = state.buffer.line_iterator(cursor.position, 80);
let line_start = cursor_iter.current_position();
let col = cursor.position.saturating_sub(line_start);
let line_num = state.primary_cursor_line_number.value();
(line_num, col)
};
let diagnostics = state.overlays.all();
let mut error_count = 0;
let mut warning_count = 0;
let mut info_count = 0;
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,
}
}
}
let diagnostics_summary = if error_count + warning_count + info_count > 0 {
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));
}
format!(" | {}", parts.join(" "))
} else {
String::new()
};
let cursor_count_indicator = if state.cursors.count() > 1 {
format!(" | {} cursors", state.cursors.count())
} else {
String::new()
};
let lsp_indicator = if !lsp_status.is_empty() {
format!(" | {}", lsp_status)
} else {
String::new()
};
let mut message_parts: Vec<&str> = Vec::new();
if let Some(msg) = status_message {
if !msg.is_empty() {
message_parts.push(msg);
}
}
if let Some(msg) = plugin_status_message {
if !msg.is_empty() {
message_parts.push(msg);
}
}
let message_suffix = if message_parts.is_empty() {
String::new()
} else {
format!(" | {}", message_parts.join(" | "))
};
let base_status = format!(
"{filename}{modified} | Ln {line}, Col {col}{diagnostics_summary}{cursor_count_indicator}{lsp_indicator}"
);
let left_status = format!("{base_status}{chord_display}{message_suffix}");
let update_indicator = update_available.map(|version| format!(" Update: v{} ", version));
let update_width = update_indicator.as_ref().map(|s| s.len()).unwrap_or(0);
let cmd_palette_shortcut = keybindings
.get_keybinding_for_action(
&crate::input::keybindings::Action::CommandPalette,
crate::input::keybindings::KeyContext::Global,
)
.unwrap_or_else(|| "?".to_string());
let cmd_palette_indicator = format!("Palette: {}", cmd_palette_shortcut);
let padded_cmd_palette = format!(" {} ", cmd_palette_indicator);
let available_width = area.width as usize;
let cmd_palette_width = padded_cmd_palette.len();
let right_side_width = update_width + cmd_palette_width;
let spans = if available_width >= 15 {
let left_max_width = if available_width > right_side_width + 1 {
available_width - right_side_width - 1 } else {
1 };
let mut spans = vec![];
let left_char_count = left_status.chars().count();
let displayed_left = if left_char_count > left_max_width {
let truncate_at = left_max_width.saturating_sub(3); if truncate_at > 0 {
let truncated: String = left_status.chars().take(truncate_at).collect();
format!("{}...", truncated)
} else {
String::from("...")
}
} else {
left_status.clone()
};
spans.push(Span::styled(
displayed_left.clone(),
Style::default()
.fg(theme.status_bar_fg)
.bg(theme.status_bar_bg),
));
let displayed_left_len = displayed_left.chars().count();
if displayed_left_len + right_side_width < available_width {
let padding_len = available_width - displayed_left_len - right_side_width;
spans.push(Span::styled(
" ".repeat(padding_len),
Style::default()
.fg(theme.status_bar_fg)
.bg(theme.status_bar_bg),
));
} else if displayed_left_len < available_width {
spans.push(Span::styled(
" ",
Style::default()
.fg(theme.status_bar_fg)
.bg(theme.status_bar_bg),
));
}
if let Some(ref update_text) = update_indicator {
spans.push(Span::styled(
update_text.clone(),
Style::default()
.fg(theme.menu_highlight_fg)
.bg(theme.menu_dropdown_bg),
));
}
spans.push(Span::styled(
padded_cmd_palette.clone(),
Style::default()
.fg(theme.help_indicator_fg)
.bg(theme.help_indicator_bg),
));
let total_width = displayed_left_len
+ if displayed_left_len + right_side_width < available_width {
available_width - displayed_left_len - right_side_width
} else if displayed_left_len < available_width {
1
} else {
0
}
+ right_side_width;
if total_width < available_width {
spans.push(Span::styled(
" ".repeat(available_width - total_width),
Style::default()
.fg(theme.status_bar_fg)
.bg(theme.status_bar_bg),
));
}
spans
} else {
let mut spans = vec![];
let left_char_count = left_status.chars().count();
let displayed_left = if left_char_count > available_width {
let truncate_at = available_width.saturating_sub(3);
if truncate_at > 0 {
let truncated: String = left_status.chars().take(truncate_at).collect();
format!("{}...", truncated)
} else {
left_status.chars().take(available_width).collect()
}
} else {
left_status.clone()
};
spans.push(Span::styled(
displayed_left.clone(),
Style::default()
.fg(theme.status_bar_fg)
.bg(theme.status_bar_bg),
));
if displayed_left.len() < available_width {
spans.push(Span::styled(
" ".repeat(available_width - displayed_left.len()),
Style::default()
.fg(theme.status_bar_fg)
.bg(theme.status_bar_bg),
));
}
spans
};
let status_line = Paragraph::new(Line::from(spans));
frame.render_widget(status_line, area);
}
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,
) {
let base_style = Style::default()
.fg(theme.menu_dropdown_fg)
.bg(theme.menu_dropdown_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(ratatui::style::Color::Rgb(140, 140, 140))
.bg(theme.menu_dropdown_bg);
let mut spans = Vec::new();
spans.push(Span::styled(" ", base_style));
spans.push(Span::styled(
case_checkbox,
if case_sensitive {
active_style
} else {
base_style
},
));
spans.push(Span::styled(" Case Sensitive", base_style));
if let Some(shortcut) = &case_shortcut {
spans.push(Span::styled(format!(" ({})", shortcut), shortcut_style));
}
spans.push(Span::styled(" ", base_style));
spans.push(Span::styled(
word_checkbox,
if whole_word { active_style } else { base_style },
));
spans.push(Span::styled(" Whole Word", base_style));
if let Some(shortcut) = &word_shortcut {
spans.push(Span::styled(format!(" ({})", shortcut), shortcut_style));
}
spans.push(Span::styled(" ", base_style));
spans.push(Span::styled(
regex_checkbox,
if use_regex { active_style } else { base_style },
));
spans.push(Span::styled(" Regex", base_style));
if let Some(shortcut) = ®ex_shortcut {
spans.push(Span::styled(format!(" ({})", shortcut), shortcut_style));
}
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));
spans.push(Span::styled(
confirm_checkbox,
if confirm_value {
active_style
} else {
base_style
},
));
spans.push(Span::styled(" Confirm each", base_style));
if let Some(shortcut) = &confirm_shortcut {
spans.push(Span::styled(format!(" ({})", shortcut), shortcut_style));
}
}
let current_width: usize = spans.iter().map(|s| s.content.len()).sum();
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);
}
}
#[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_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");
}
}