use anyhow::{Result, anyhow};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct StatusItemId(String);
impl StatusItemId {
pub fn new(id: impl Into<String>) -> Self {
Self(id.into())
}
}
impl std::fmt::Display for StatusItemId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum StatusItemAlignment {
Left,
Center,
Right,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StatusItem {
pub id: StatusItemId,
pub text: String,
pub tooltip: Option<String>,
pub alignment: StatusItemAlignment,
pub priority: i32,
pub visible: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StatusBar {
items: Vec<StatusItem>,
#[serde(skip)]
index: HashMap<StatusItemId, usize>,
}
impl StatusBar {
pub fn new() -> Self {
Self {
items: Vec::new(),
index: HashMap::new(),
}
}
pub fn add_item(&mut self, item: StatusItem) {
if let Some(&idx) = self.index.get(&item.id) {
self.items[idx] = item;
} else {
let idx = self.items.len();
self.index.insert(item.id.clone(), idx);
self.items.push(item);
}
}
pub fn remove_item(&mut self, id: &StatusItemId) {
if let Some(idx) = self.index.remove(id) {
self.items.swap_remove(idx);
if idx < self.items.len() {
self.index.insert(self.items[idx].id.clone(), idx);
}
}
}
pub fn update_text(&mut self, id: &StatusItemId, text: String) -> Result<()> {
let item = self.get_mut(id)?;
item.text = text;
Ok(())
}
pub fn update_tooltip(&mut self, id: &StatusItemId, tooltip: Option<String>) -> Result<()> {
let item = self.get_mut(id)?;
item.tooltip = tooltip;
Ok(())
}
pub fn set_visible(&mut self, id: &StatusItemId, visible: bool) -> Result<()> {
let item = self.get_mut(id)?;
item.visible = visible;
Ok(())
}
pub fn items(&self, alignment: StatusItemAlignment) -> Vec<&StatusItem> {
let mut matched: Vec<&StatusItem> = self
.items
.iter()
.filter(|item| item.alignment == alignment)
.collect();
matched.sort_by_key(|b| std::cmp::Reverse(b.priority));
matched
}
pub fn all_items(&self) -> &[StatusItem] {
&self.items
}
pub fn get(&self, id: &StatusItemId) -> Option<&StatusItem> {
self.index.get(id).map(|&idx| &self.items[idx])
}
pub fn rebuild_index(&mut self) {
self.index.clear();
for (idx, item) in self.items.iter().enumerate() {
self.index.insert(item.id.clone(), idx);
}
}
fn get_mut(&mut self, id: &StatusItemId) -> Result<&mut StatusItem> {
let idx = *self
.index
.get(id)
.ok_or_else(|| anyhow!("status item not found: {}", id))?;
Ok(&mut self.items[idx])
}
}
impl Default for StatusBar {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_item(id: &str, alignment: StatusItemAlignment, priority: i32) -> StatusItem {
StatusItem {
id: StatusItemId::new(id),
text: format!("{id} text"),
tooltip: None,
alignment,
priority,
visible: true,
}
}
#[test]
fn add_and_get_item() {
let mut bar = StatusBar::new();
let item = make_item("branch", StatusItemAlignment::Left, 10);
bar.add_item(item);
let retrieved = bar.get(&StatusItemId::new("branch"));
assert!(retrieved.is_some());
assert_eq!(retrieved.unwrap().text, "branch text");
}
#[test]
fn add_duplicate_replaces() {
let mut bar = StatusBar::new();
bar.add_item(make_item("branch", StatusItemAlignment::Left, 10));
let mut replacement = make_item("branch", StatusItemAlignment::Right, 5);
replacement.text = "updated".into();
bar.add_item(replacement);
assert_eq!(bar.all_items().len(), 1);
let item = bar.get(&StatusItemId::new("branch")).unwrap();
assert_eq!(item.text, "updated");
assert_eq!(item.alignment, StatusItemAlignment::Right);
}
#[test]
fn remove_item() {
let mut bar = StatusBar::new();
bar.add_item(make_item("a", StatusItemAlignment::Left, 1));
bar.add_item(make_item("b", StatusItemAlignment::Left, 2));
bar.add_item(make_item("c", StatusItemAlignment::Left, 3));
bar.remove_item(&StatusItemId::new("a"));
assert!(bar.get(&StatusItemId::new("a")).is_none());
assert_eq!(bar.all_items().len(), 2);
assert!(bar.get(&StatusItemId::new("b")).is_some());
assert!(bar.get(&StatusItemId::new("c")).is_some());
}
#[test]
fn remove_nonexistent_is_noop() {
let mut bar = StatusBar::new();
bar.add_item(make_item("a", StatusItemAlignment::Left, 1));
bar.remove_item(&StatusItemId::new("missing"));
assert_eq!(bar.all_items().len(), 1);
}
#[test]
fn update_text_success() {
let mut bar = StatusBar::new();
bar.add_item(make_item("info", StatusItemAlignment::Center, 0));
bar.update_text(&StatusItemId::new("info"), "new text".into())
.unwrap();
assert_eq!(
bar.get(&StatusItemId::new("info")).unwrap().text,
"new text"
);
}
#[test]
fn update_text_missing_item_errors() {
let mut bar = StatusBar::new();
let result = bar.update_text(&StatusItemId::new("missing"), "text".into());
assert!(result.is_err());
}
#[test]
fn update_tooltip() {
let mut bar = StatusBar::new();
bar.add_item(make_item("info", StatusItemAlignment::Left, 0));
bar.update_tooltip(&StatusItemId::new("info"), Some("a tip".into()))
.unwrap();
assert_eq!(
bar.get(&StatusItemId::new("info"))
.unwrap()
.tooltip
.as_deref(),
Some("a tip")
);
bar.update_tooltip(&StatusItemId::new("info"), None)
.unwrap();
assert!(
bar.get(&StatusItemId::new("info"))
.unwrap()
.tooltip
.is_none()
);
}
#[test]
fn update_tooltip_missing_item_errors() {
let mut bar = StatusBar::new();
let result = bar.update_tooltip(&StatusItemId::new("gone"), Some("tip".into()));
assert!(result.is_err());
}
#[test]
fn set_visible() {
let mut bar = StatusBar::new();
bar.add_item(make_item("x", StatusItemAlignment::Left, 0));
bar.set_visible(&StatusItemId::new("x"), false).unwrap();
assert!(!bar.get(&StatusItemId::new("x")).unwrap().visible);
bar.set_visible(&StatusItemId::new("x"), true).unwrap();
assert!(bar.get(&StatusItemId::new("x")).unwrap().visible);
}
#[test]
fn set_visible_missing_item_errors() {
let mut bar = StatusBar::new();
let result = bar.set_visible(&StatusItemId::new("gone"), true);
assert!(result.is_err());
}
#[test]
fn items_filtered_and_sorted_by_priority() {
let mut bar = StatusBar::new();
bar.add_item(make_item("low", StatusItemAlignment::Left, 1));
bar.add_item(make_item("high", StatusItemAlignment::Left, 100));
bar.add_item(make_item("mid", StatusItemAlignment::Left, 50));
bar.add_item(make_item("right_item", StatusItemAlignment::Right, 999));
let left_items = bar.items(StatusItemAlignment::Left);
assert_eq!(left_items.len(), 3);
assert_eq!(left_items[0].id, StatusItemId::new("high"));
assert_eq!(left_items[1].id, StatusItemId::new("mid"));
assert_eq!(left_items[2].id, StatusItemId::new("low"));
let right_items = bar.items(StatusItemAlignment::Right);
assert_eq!(right_items.len(), 1);
assert_eq!(right_items[0].id, StatusItemId::new("right_item"));
let center_items = bar.items(StatusItemAlignment::Center);
assert!(center_items.is_empty());
}
#[test]
fn all_items_returns_full_collection() {
let mut bar = StatusBar::new();
bar.add_item(make_item("a", StatusItemAlignment::Left, 0));
bar.add_item(make_item("b", StatusItemAlignment::Center, 0));
bar.add_item(make_item("c", StatusItemAlignment::Right, 0));
assert_eq!(bar.all_items().len(), 3);
}
#[test]
fn get_returns_none_for_missing() {
let bar = StatusBar::new();
assert!(bar.get(&StatusItemId::new("nope")).is_none());
}
#[test]
fn default_creates_empty_bar() {
let bar = StatusBar::default();
assert!(bar.all_items().is_empty());
}
#[test]
fn remove_then_add_reuses_correctly() {
let mut bar = StatusBar::new();
bar.add_item(make_item("a", StatusItemAlignment::Left, 1));
bar.add_item(make_item("b", StatusItemAlignment::Left, 2));
bar.remove_item(&StatusItemId::new("a"));
bar.add_item(make_item("c", StatusItemAlignment::Left, 3));
assert_eq!(bar.all_items().len(), 2);
assert!(bar.get(&StatusItemId::new("a")).is_none());
assert!(bar.get(&StatusItemId::new("b")).is_some());
assert!(bar.get(&StatusItemId::new("c")).is_some());
}
#[test]
fn status_item_id_display() {
let id = StatusItemId::new("git-branch");
assert_eq!(format!("{id}"), "git-branch");
}
#[test]
fn serialization_roundtrip() {
let mut bar = StatusBar::new();
bar.add_item(StatusItem {
id: StatusItemId::new("line"),
text: "Ln 42, Col 10".into(),
tooltip: Some("Cursor position".into()),
alignment: StatusItemAlignment::Right,
priority: 50,
visible: true,
});
let json = serde_json::to_string(&bar).unwrap();
let mut restored: StatusBar = serde_json::from_str(&json).unwrap();
restored.rebuild_index();
assert_eq!(restored.all_items().len(), 1);
let item = restored.get(&StatusItemId::new("line")).unwrap();
assert_eq!(item.text, "Ln 42, Col 10");
assert_eq!(item.tooltip.as_deref(), Some("Cursor position"));
assert_eq!(item.alignment, StatusItemAlignment::Right);
assert_eq!(item.priority, 50);
}
#[test]
fn negative_priority_ordering() {
let mut bar = StatusBar::new();
bar.add_item(make_item("neg", StatusItemAlignment::Left, -10));
bar.add_item(make_item("zero", StatusItemAlignment::Left, 0));
bar.add_item(make_item("pos", StatusItemAlignment::Left, 10));
let items = bar.items(StatusItemAlignment::Left);
assert_eq!(items[0].id, StatusItemId::new("pos"));
assert_eq!(items[1].id, StatusItemId::new("zero"));
assert_eq!(items[2].id, StatusItemId::new("neg"));
}
}