use regex::Regex;
use serde_json::{json, Map, Value};
use std::sync::OnceLock;
#[allow(non_snake_case)]
pub fn WORKSPACE_REGEX() -> &'static Regex {
static R: OnceLock<Regex> = OnceLock::new();
R.get_or_init(|| Regex::new(r"^[0-9]+: ?").unwrap())
}
#[derive(Debug, Clone, Copy)]
pub struct WorkspaceFlags {
pub focused: bool,
pub urgent: bool,
pub visible: bool,
}
pub fn workspace_groups(w: WorkspaceFlags) -> Vec<String> {
let mut group: Vec<String> = Vec::new();
if w.focused {
group.push("workspace:focused".to_string());
group.push("w_focused".to_string());
}
if w.urgent {
group.push("workspace:urgent".to_string());
group.push("w_urgent".to_string());
}
if w.visible {
group.push("workspace:visible".to_string());
group.push("w_visible".to_string());
}
group.push("workspace".to_string());
group
}
pub fn format_name(name: &str, strip: bool) -> String {
if strip {
WORKSPACE_REGEX().replace(name, "").into_owned()
} else {
name.to_string()
}
}
#[derive(Debug, Clone)]
pub struct WorkspaceContainer {
pub window_classes: Vec<String>,
pub scratchpad_states: Vec<String>,
}
pub fn is_empty_workspace(w: WorkspaceFlags, container: &WorkspaceContainer) -> bool {
if w.focused || w.visible {
return false;
}
container.window_classes.is_empty()
}
pub fn ws_icons() -> Map<String, Value> {
let mut m = Map::new();
m.insert("multiple".to_string(), Value::String("M".into()));
m
}
pub fn get_icon(
container: &WorkspaceContainer,
separator: &str,
icons: &Map<String, Value>,
show_multiple_icons: bool,
) -> String {
let mut icons_merged = ws_icons();
for (k, v) in icons {
icons_merged.insert(k.clone(), v.clone());
}
let wins: Vec<&String> = container
.window_classes
.iter()
.zip(container.scratchpad_states.iter())
.filter_map(|(wc, ss)| if ss == "none" { Some(wc) } else { None })
.collect();
if wins.is_empty() {
return String::new();
}
let mut result = String::new();
let mut cnt: u32 = 0;
for (key, val_v) in &icons_merged {
let val = val_v.as_str().unwrap_or("");
if val.is_empty() {
continue;
}
if wins.iter().any(|wc| !wc.is_empty() && wc.contains(key)) {
if cnt > 0 {
result.push_str(separator);
}
result.push_str(val);
cnt += 1;
}
}
if !show_multiple_icons && cnt > 1 {
if let Some(multi) = icons_merged.get("multiple").and_then(|v| v.as_str()) {
return multi.to_string();
}
return String::new();
}
result
}
pub fn mode(current_mode: &str, names: &Map<String, Value>) -> Option<String> {
mode_segment(current_mode, names)
}
pub fn sort_ws(
ws: &[String],
sort_workspaces: bool,
priority_workspaces: &[String],
) -> Vec<String> {
let working: Vec<String> = if sort_workspaces {
let mut v: Vec<String> = ws.to_vec();
v.sort_by_key(|a| natural_key(a));
v
} else {
ws.to_vec()
};
priority_sort_workspaces(&working, priority_workspaces)
}
pub fn mode_segment(current_mode: &str, names: &Map<String, Value>) -> Option<String> {
if let Some(translation) = names.get(current_mode) {
match translation {
Value::Null => None,
Value::String(s) => Some(s.clone()),
other => Some(other.to_string()),
}
} else {
Some(current_mode.to_string())
}
}
#[derive(Debug, Clone)]
pub struct ScratchpadFlags {
pub urgent: bool,
pub first_node_focused: bool,
pub workspace_name: String,
}
pub fn scratchpad_groups(w: &ScratchpadFlags) -> Vec<String> {
let mut group: Vec<String> = Vec::new();
if w.urgent {
group.push("scratchpad:urgent".to_string());
}
if w.first_node_focused {
group.push("scratchpad:focused".to_string());
}
if w.workspace_name != "__i3_scratch" {
group.push("scratchpad:visible".to_string());
}
group.push("scratchpad".to_string());
group
}
pub fn scratchpad_icons() -> Map<String, Value> {
let mut m = Map::new();
m.insert("fresh".to_string(), Value::String("O".into()));
m.insert("changed".to_string(), Value::String("X".into()));
m
}
pub fn active_window_contents(title: &str, window_class: &str, cutoff: usize) -> String {
if title.chars().count() > cutoff {
window_class.to_string()
} else {
title.to_string()
}
}
pub fn natural_key(name: &str) -> Vec<String> {
static R: OnceLock<Regex> = OnceLock::new();
let re = R.get_or_init(|| Regex::new(r"(\d+)").unwrap());
let mut out: Vec<String> = Vec::new();
let mut last_end = 0;
for m in re.find_iter(name) {
if m.start() > last_end {
out.push(name[last_end..m.start()].to_string());
}
out.push(format!("{:0>10}", m.as_str()));
last_end = m.end();
}
if last_end < name.len() {
out.push(name[last_end..].to_string());
}
out
}
pub fn priority_sort_workspaces(workspaces: &[String], priority_names: &[String]) -> Vec<String> {
let mut result: Vec<String> = Vec::new();
for priority in priority_names {
for w in workspaces {
if w == priority {
result.push(w.clone());
}
}
}
for w in workspaces {
if !priority_names.contains(w) {
result.push(w.clone());
}
}
result
}
pub fn workspace_default_format(strip: bool) -> &'static str {
if strip {
"{stripped_name}"
} else {
"{name}"
}
}
pub fn scratchpad_entry(
scratchpad_state: &str,
flags: &ScratchpadFlags,
icons: &Map<String, Value>,
) -> Option<Value> {
if scratchpad_state == "none" {
return None;
}
let contents = icons
.get(scratchpad_state)
.and_then(|v| v.as_str())
.map(String::from)
.or_else(|| {
icons
.get("changed")
.and_then(|v| v.as_str())
.map(String::from)
})
.unwrap_or_default();
let groups: Vec<Value> = scratchpad_groups(flags)
.into_iter()
.map(Value::String)
.collect();
Some(json!({
"contents": contents,
"highlight_groups": groups,
}))
}
pub fn active_window(title: &str, window_class: &str, cutoff: usize) -> String {
active_window_contents(title, window_class, cutoff)
}
pub fn build_workspace_entry(
format: &str,
name: &str,
number: i64,
container: &WorkspaceContainer,
icons: &Map<String, Value>,
strip: usize,
) -> Value {
let stripped_idx = std::cmp::min(name.chars().count(), strip);
let trimmed_name: String = name.chars().skip(stripped_idx).collect();
let stripped_name = format_name(name, true);
let icon = get_icon(container, "", icons, false);
let multi_icon = get_icon(container, " ", icons, true);
let contents = format
.replace("{name}", &trimmed_name)
.replace("{stripped_name}", &stripped_name)
.replace("{number}", &number.to_string())
.replace("{icon}", &icon)
.replace("{multi_icon}", &multi_icon);
json!({
"contents": contents,
})
}
pub fn workspace(
name: &str,
num: i64,
flags: WorkspaceFlags,
icon: &str,
multi_icon: &str,
strip: bool,
format: Option<&str>,
) -> Map<String, Value> {
let f = format.unwrap_or_else(|| workspace_default_format(strip));
let contents = f
.replace("{name}", name)
.replace("{stripped_name}", &format_name(name, true))
.replace("{number}", &num.to_string())
.replace("{icon}", icon)
.replace("{multi_icon}", multi_icon);
let mut seg = Map::new();
seg.insert("contents".to_string(), Value::String(contents));
seg.insert(
"highlight_groups".to_string(),
Value::Array(
workspace_groups(flags)
.into_iter()
.map(Value::String)
.collect(),
),
);
seg
}
pub fn scratchpad(windows: &[(&str, ScratchpadFlags)], icons: &Map<String, Value>) -> Vec<Value> {
let mut out: Vec<Value> = Vec::with_capacity(windows.len());
for (state, flags) in windows {
if let Some(entry) = scratchpad_entry(state, flags, icons) {
out.push(entry);
}
}
out
}
pub fn workspaces() -> &'static str {
"see powerline/segments/i3wm.py:65-174"
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn workspace_regex_strips_leading_number_and_optional_space() {
assert_eq!(WORKSPACE_REGEX().replace("1: web", ""), "web");
assert_eq!(WORKSPACE_REGEX().replace("23:term", ""), "term");
assert_eq!(WORKSPACE_REGEX().replace("plain", ""), "plain");
}
#[test]
fn workspace_groups_focused_only() {
let g = workspace_groups(WorkspaceFlags {
focused: true,
urgent: false,
visible: false,
});
assert_eq!(g, vec!["workspace:focused", "w_focused", "workspace"]);
}
#[test]
fn workspace_groups_urgent_only() {
let g = workspace_groups(WorkspaceFlags {
focused: false,
urgent: true,
visible: false,
});
assert_eq!(g, vec!["workspace:urgent", "w_urgent", "workspace"]);
}
#[test]
fn workspace_groups_visible_only() {
let g = workspace_groups(WorkspaceFlags {
focused: false,
urgent: false,
visible: true,
});
assert_eq!(g, vec!["workspace:visible", "w_visible", "workspace"]);
}
#[test]
fn workspace_groups_none_returns_just_workspace() {
let g = workspace_groups(WorkspaceFlags {
focused: false,
urgent: false,
visible: false,
});
assert_eq!(g, vec!["workspace"]);
}
#[test]
fn workspace_groups_all_flags_set_returns_all_classes() {
let g = workspace_groups(WorkspaceFlags {
focused: true,
urgent: true,
visible: true,
});
assert_eq!(
g,
vec![
"workspace:focused",
"w_focused",
"workspace:urgent",
"w_urgent",
"workspace:visible",
"w_visible",
"workspace"
]
);
}
#[test]
fn format_name_strip_true_removes_leading_number() {
assert_eq!(format_name("1: web", true), "web");
assert_eq!(format_name("42:term", true), "term");
}
#[test]
fn format_name_strip_false_passes_through() {
assert_eq!(format_name("1: web", false), "1: web");
}
#[test]
fn is_empty_workspace_focused_returns_false() {
let c = WorkspaceContainer {
window_classes: Vec::new(),
scratchpad_states: Vec::new(),
};
let r = is_empty_workspace(
WorkspaceFlags {
focused: true,
urgent: false,
visible: false,
},
&c,
);
assert!(!r);
}
#[test]
fn is_empty_workspace_visible_returns_false() {
let c = WorkspaceContainer {
window_classes: Vec::new(),
scratchpad_states: Vec::new(),
};
let r = is_empty_workspace(
WorkspaceFlags {
focused: false,
urgent: false,
visible: true,
},
&c,
);
assert!(!r);
}
#[test]
fn is_empty_workspace_with_leaves_returns_false() {
let c = WorkspaceContainer {
window_classes: vec!["Firefox".to_string()],
scratchpad_states: vec!["none".to_string()],
};
let r = is_empty_workspace(
WorkspaceFlags {
focused: false,
urgent: false,
visible: false,
},
&c,
);
assert!(!r);
}
#[test]
fn is_empty_workspace_unfocused_invisible_no_leaves_returns_true() {
let c = WorkspaceContainer {
window_classes: Vec::new(),
scratchpad_states: Vec::new(),
};
let r = is_empty_workspace(
WorkspaceFlags {
focused: false,
urgent: false,
visible: false,
},
&c,
);
assert!(r);
}
#[test]
fn ws_icons_contains_multiple_entry() {
let i = ws_icons();
assert_eq!(i.get("multiple"), Some(&Value::String("M".into())));
}
#[test]
fn get_icon_no_windows_returns_empty() {
let c = WorkspaceContainer {
window_classes: Vec::new(),
scratchpad_states: Vec::new(),
};
let icons = ws_icons();
assert_eq!(get_icon(&c, "", &icons, false), "");
}
#[test]
fn get_icon_matches_window_class_substring() {
let c = WorkspaceContainer {
window_classes: vec!["Firefox".to_string()],
scratchpad_states: vec!["none".to_string()],
};
let mut icons = Map::new();
icons.insert("Firefox".to_string(), Value::String("🦊".into()));
let r = get_icon(&c, "", &icons, true);
assert_eq!(r, "🦊");
}
#[test]
fn get_icon_collapses_multiple_to_multi_icon_when_not_show_all() {
let c = WorkspaceContainer {
window_classes: vec!["Firefox".to_string(), "Terminal".to_string()],
scratchpad_states: vec!["none".to_string(), "none".to_string()],
};
let mut icons = Map::new();
icons.insert("Firefox".to_string(), Value::String("🦊".into()));
icons.insert("Terminal".to_string(), Value::String("⚙".into()));
let r = get_icon(&c, "", &icons, false);
assert_eq!(r, "M"); }
#[test]
fn get_icon_skips_scratchpad_windows() {
let c = WorkspaceContainer {
window_classes: vec!["Hidden".to_string()],
scratchpad_states: vec!["fresh".to_string()],
};
let mut icons = Map::new();
icons.insert("Hidden".to_string(), Value::String("H".into()));
let r = get_icon(&c, "", &icons, true);
assert_eq!(r, "");
}
#[test]
fn mode_segment_default_hidden_returns_none() {
let mut names = Map::new();
names.insert("default".to_string(), Value::Null);
assert_eq!(mode_segment("default", &names), None);
}
#[test]
fn mode_segment_named_returns_translation() {
let mut names = Map::new();
names.insert("resize".to_string(), Value::String("[RESIZE]".into()));
assert_eq!(mode_segment("resize", &names), Some("[RESIZE]".to_string()));
}
#[test]
fn mode_segment_unknown_passes_through() {
let names = Map::new();
assert_eq!(mode_segment("normal", &names), Some("normal".to_string()));
}
#[test]
fn scratchpad_groups_urgent_focused_visible() {
let g = scratchpad_groups(&ScratchpadFlags {
urgent: true,
first_node_focused: true,
workspace_name: "1: web".to_string(),
});
assert_eq!(
g,
vec![
"scratchpad:urgent",
"scratchpad:focused",
"scratchpad:visible",
"scratchpad"
]
);
}
#[test]
fn scratchpad_groups_on_scratch_workspace_omits_visible() {
let g = scratchpad_groups(&ScratchpadFlags {
urgent: false,
first_node_focused: false,
workspace_name: "__i3_scratch".to_string(),
});
assert_eq!(g, vec!["scratchpad"]);
}
#[test]
fn scratchpad_icons_contains_fresh_and_changed() {
let i = scratchpad_icons();
assert_eq!(i.get("fresh"), Some(&Value::String("O".into())));
assert_eq!(i.get("changed"), Some(&Value::String("X".into())));
}
#[test]
fn active_window_returns_title_when_short() {
let r = active_window_contents("My Window", "Firefox", 100);
assert_eq!(r, "My Window");
}
#[test]
fn active_window_returns_class_when_title_too_long() {
let r = active_window_contents("very very long title", "Firefox", 5);
assert_eq!(r, "Firefox");
}
#[test]
fn build_workspace_entry_substitutes_name() {
let c = WorkspaceContainer {
window_classes: Vec::new(),
scratchpad_states: Vec::new(),
};
let icons = ws_icons();
let e = build_workspace_entry("{name}", "web", 1, &c, &icons, 0);
assert_eq!(e["contents"], "web");
}
#[test]
fn build_workspace_entry_substitutes_stripped_name() {
let c = WorkspaceContainer {
window_classes: Vec::new(),
scratchpad_states: Vec::new(),
};
let icons = ws_icons();
let e = build_workspace_entry("{stripped_name}", "1: web", 1, &c, &icons, 0);
assert_eq!(e["contents"], "web");
}
#[test]
fn build_workspace_entry_substitutes_number() {
let c = WorkspaceContainer {
window_classes: Vec::new(),
scratchpad_states: Vec::new(),
};
let icons = ws_icons();
let e = build_workspace_entry("{number}", "1: web", 7, &c, &icons, 0);
assert_eq!(e["contents"], "7");
}
#[test]
fn build_workspace_entry_strip_skips_leading_chars() {
let c = WorkspaceContainer {
window_classes: Vec::new(),
scratchpad_states: Vec::new(),
};
let icons = ws_icons();
let e = build_workspace_entry("{name}", "1: web", 1, &c, &icons, 3);
assert_eq!(e["contents"], "web");
}
#[test]
fn natural_key_orders_digits_numerically() {
let k2 = natural_key("ws2");
let k10 = natural_key("ws10");
assert!(k2 < k10);
}
#[test]
fn natural_key_handles_pure_digits() {
let k1 = natural_key("1");
let k10 = natural_key("10");
assert!(k1 < k10);
}
#[test]
fn natural_key_handles_no_digits() {
let k = natural_key("web");
assert_eq!(k, vec!["web".to_string()]);
}
#[test]
fn natural_key_alternating_chunks() {
let k = natural_key("ws10:dev");
assert_eq!(k.len(), 3);
assert_eq!(k[0], "ws");
assert_eq!(k[2], ":dev");
assert!(k[1].ends_with("10"));
}
#[test]
fn priority_sort_workspaces_puts_priority_names_first() {
let ws = vec![
"a".to_string(),
"b".to_string(),
"c".to_string(),
"d".to_string(),
];
let priority = vec!["c".to_string(), "a".to_string()];
let r = priority_sort_workspaces(&ws, &priority);
assert_eq!(r, vec!["c", "a", "b", "d"]);
}
#[test]
fn priority_sort_workspaces_empty_priority_preserves_order() {
let ws = vec!["x".to_string(), "y".to_string(), "z".to_string()];
let r = priority_sort_workspaces(&ws, &[]);
assert_eq!(r, vec!["x", "y", "z"]);
}
#[test]
fn priority_sort_workspaces_priority_not_in_list_is_skipped() {
let ws = vec!["a".to_string(), "b".to_string()];
let priority = vec!["missing".to_string()];
let r = priority_sort_workspaces(&ws, &priority);
assert_eq!(r, vec!["a", "b"]);
}
#[test]
fn sort_ws_sort_workspaces_true_applies_natural_key_order() {
let ws = vec!["10".to_string(), "2".to_string(), "1".to_string()];
let r = sort_ws(&ws, true, &[]);
assert_eq!(r, vec!["1", "2", "10"]);
}
#[test]
fn sort_ws_sort_workspaces_false_preserves_input_order() {
let ws = vec!["10".to_string(), "2".to_string(), "1".to_string()];
let r = sort_ws(&ws, false, &[]);
assert_eq!(r, vec!["10", "2", "1"]);
}
#[test]
fn sort_ws_priority_workspaces_pin_to_front() {
let ws = vec!["c".to_string(), "a".to_string(), "b".to_string()];
let priority = vec!["b".to_string()];
let r = sort_ws(&ws, true, &priority);
assert_eq!(r, vec!["b", "a", "c"]);
}
#[test]
fn mode_alias_dispatches_to_mode_segment() {
let mut names = Map::new();
names.insert("v".to_string(), Value::String("VIS".to_string()));
assert_eq!(mode("v", &names), Some("VIS".to_string()));
assert_eq!(mode("unmapped", &names), Some("unmapped".to_string()));
}
#[test]
fn workspace_default_format_strip_returns_stripped_name() {
assert_eq!(workspace_default_format(true), "{stripped_name}");
}
#[test]
fn workspace_default_format_no_strip_returns_name() {
assert_eq!(workspace_default_format(false), "{name}");
}
#[test]
fn scratchpad_entry_none_state_returns_none() {
let flags = ScratchpadFlags {
urgent: false,
first_node_focused: true,
workspace_name: "1".to_string(),
};
let icons = scratchpad_icons();
assert!(scratchpad_entry("none", &flags, &icons).is_none());
}
#[test]
fn scratchpad_entry_known_state_uses_matching_icon() {
let flags = ScratchpadFlags {
urgent: false,
first_node_focused: false,
workspace_name: "__i3_scratch".to_string(),
};
let icons = scratchpad_icons();
let entry = scratchpad_entry("fresh", &flags, &icons).unwrap();
assert_eq!(entry["contents"], "O");
}
#[test]
fn scratchpad_entry_unknown_state_falls_back_to_changed_icon() {
let flags = ScratchpadFlags {
urgent: false,
first_node_focused: false,
workspace_name: "__i3_scratch".to_string(),
};
let icons = scratchpad_icons();
let entry = scratchpad_entry("bogus_state", &flags, &icons).unwrap();
assert_eq!(entry["contents"], "X");
}
#[test]
fn scratchpad_entry_emits_highlight_groups_from_flags() {
let flags = ScratchpadFlags {
urgent: true,
first_node_focused: false,
workspace_name: "__i3_scratch".to_string(),
};
let icons = scratchpad_icons();
let entry = scratchpad_entry("changed", &flags, &icons).unwrap();
let groups = entry["highlight_groups"].as_array().unwrap();
let strs: Vec<&str> = groups.iter().filter_map(|v| v.as_str()).collect();
assert!(strs.contains(&"scratchpad:urgent"));
assert!(strs.contains(&"scratchpad"));
}
#[test]
fn active_window_returns_title_under_cutoff() {
assert_eq!(active_window("short", "Class", 100), "short");
}
#[test]
fn active_window_returns_class_when_title_exceeds_cutoff() {
let long_title = "a".repeat(200);
assert_eq!(active_window(&long_title, "MyClass", 100), "MyClass");
}
#[test]
fn workspace_uses_supplied_format_string() {
let flags = WorkspaceFlags {
focused: true,
urgent: false,
visible: false,
};
let seg = workspace("1: web", 1, flags, "🌐", "🌐 ", false, Some("{name}"));
assert_eq!(seg["contents"], "1: web");
let groups = seg["highlight_groups"].as_array().unwrap();
assert!(groups.iter().any(|g| g == "workspace:focused"));
}
#[test]
fn workspace_substitutes_stripped_name_when_strip_true() {
let flags = WorkspaceFlags {
focused: false,
urgent: false,
visible: false,
};
let seg = workspace("1: web", 1, flags, "", "", true, None);
assert_eq!(seg["contents"], "web");
}
#[test]
fn scratchpad_emits_one_entry_per_non_none_window() {
let mut icons = Map::new();
icons.insert("fresh".to_string(), Value::String("O".to_string()));
icons.insert("changed".to_string(), Value::String("X".to_string()));
let flags = ScratchpadFlags {
urgent: false,
first_node_focused: true,
workspace_name: "1".to_string(),
};
let windows = vec![
("fresh", flags.clone()),
("changed", flags.clone()),
("none", flags),
];
let r = scratchpad(&windows, &icons);
assert_eq!(r.len(), 2);
assert_eq!(r[0]["contents"], "O");
assert_eq!(r[1]["contents"], "X");
}
}