status bar
git clone https://git.lucas.co/cce-status-interface.git
refactor: split main.rs into tray/cloud/stats/config/listeners modules (proposal phase 1)
Mechanical moves only — no logic changes:
- tray.rs: SNI watcher/host D-Bus interfaces, item fetching, icon loading
- cloud.rs: cce-cloud popups (DBusMenu proxy, menu paging, window picker)
- stats.rs: /proc//sys/pactl readers and the stats polling task
- config.rs: cached KDL config lookup, color/font/dimension readers,
cce/ccectl/cce-cloud binary resolution
- listeners.rs: compositor status-feed and switcher-trigger sockets
Moved items are re-exported at the crate root so all call sites keep
their pre-split names. The window-picker spawn body left
trigger_switcher as cloud::spawn_window_picker with the source string
as a parameter — the only signature change in the split.
main.rs: 3672 -> 2172 lines.
Co-Authored-By: Claude Fable 5 <[email protected]>
src/cloud.rs | 484 +++++++++++++++++
src/config.rs | 242 +++++++++
src/listeners.rs | 75 +++
src/main.rs | 1532 +-----------------------------------------------------
src/stats.rs | 209 ++++++++
src/tray.rs | 543 +++++++++++++++++++
6 files changed, 1569 insertions(+), 1516 deletions(-)
diff --git a/src/cloud.rs b/src/cloud.rs
new file mode 100644
index 0000000..040badd
--- /dev/null
+++ b/src/cloud.rs
@@ -0,0 +1,484 @@
+//! cce-cloud popups: the window picker and D-Bus menus rendered by spawning
+//! a `cce-cloud` process fed JSON pages on stdin.
+
+use crate::{parse_ccectl_windows, CustomEvent};
+use crate::config::{get_cce_cloud_cmd, get_ccectl_cmd};
+
+#[zbus::proxy(
+ interface = "com.canonical.dbusmenu",
+ default_path = "/StatusNotifierItem/menu"
+)]
+pub(crate) trait DBusMenu {
+ fn get_layout(
+ &self,
+ parent_id: i32,
+ recursion_depth: i32,
+ property_names: Vec<String>,
+ ) -> zbus::Result<(u32, (i32, std::collections::HashMap<String, zbus::zvariant::OwnedValue>, Vec<zbus::zvariant::OwnedValue>))>;
+
+ fn event(
+ &self,
+ id: i32,
+ event_id: &str,
+ data: &zbus::zvariant::Value<'_>,
+ timestamp: u32,
+ ) -> zbus::Result<()>;
+
+ fn about_to_show(&self, id: i32) -> zbus::Result<bool>;
+}
+
+pub(crate) struct MenuItem {
+ id: i32,
+ label: String,
+ enabled: bool,
+ is_separator: bool,
+ toggle_state: i32, // -1 if not toggleable, 0 if unchecked, 1 if checked
+ children: Vec<MenuItem>,
+}
+
+pub(crate) fn parse_menu_item(
+ id: i32,
+ mut properties: std::collections::HashMap<String, zbus::zvariant::OwnedValue>,
+ children_vals: Vec<zbus::zvariant::OwnedValue>,
+) -> Option<MenuItem> {
+ let type_: String = properties.remove("type")
+ .and_then(|v| {
+ let s: Result<String, _> = v.try_into();
+ s.ok()
+ })
+ .unwrap_or_default();
+ let is_separator = type_ == "separator";
+
+ let label: String = properties.remove("label")
+ .and_then(|v| {
+ let s: Result<String, _> = v.try_into();
+ s.ok()
+ })
+ .unwrap_or_default();
+
+ let enabled: bool = properties.remove("enabled")
+ .and_then(|v| {
+ let b: Result<bool, _> = v.try_into();
+ b.ok()
+ })
+ .unwrap_or(true);
+
+ let toggle_state: i32 = properties.remove("toggle-state")
+ .and_then(|v| {
+ let i: Result<i32, _> = v.try_into();
+ i.ok()
+ })
+ .unwrap_or(-1);
+
+ let mut children = Vec::new();
+ for child_val in children_vals {
+ let child_val_inner = zbus::zvariant::Value::from(child_val);
+ if let Ok(child) = <(i32, std::collections::HashMap<String, zbus::zvariant::OwnedValue>, Vec<zbus::zvariant::OwnedValue>)>::try_from(child_val_inner) {
+ if let Some(parsed) = parse_menu_item(child.0, child.1, child.2) {
+ children.push(parsed);
+ }
+ }
+ }
+
+ Some(MenuItem {
+ id,
+ label,
+ enabled,
+ is_separator,
+ toggle_state,
+ children,
+ })
+}
+
+pub(crate) fn get_currently_focused_window() -> Option<String> {
+ let output = std::process::Command::new(get_ccectl_cmd())
+ .arg("windows")
+ .output();
+ if let Ok(out) = output {
+ let stdout_str = String::from_utf8_lossy(&out.stdout);
+ for line in stdout_str.lines() {
+ let focused = if let Some(idx) = line.find("focused=") {
+ let rest = &line[idx + 8..];
+ let end = rest.find(' ').unwrap_or(rest.len());
+ rest[..end].trim() == "true"
+ } else {
+ false
+ };
+
+ if focused {
+ let app_id = if let Some(idx) = line.find("app_id=") {
+ let rest = &line[idx + 7..];
+ let end = rest.find(' ').unwrap_or(rest.len());
+ rest[..end].to_string()
+ } else {
+ continue;
+ };
+ if app_id == "cce-status" || app_id == "cce-cloud" {
+ continue;
+ }
+
+ // Return the unique window ID if present, otherwise fall back to app_id
+ let id = if let Some(idx) = line.find("window id=") {
+ let rest = &line[idx + 10..];
+ let end = rest.find(' ').unwrap_or(rest.len());
+ rest[..end].to_string()
+ } else {
+ app_id
+ };
+ return Some(id);
+ }
+ }
+ }
+ None
+}
+
+pub(crate) async fn show_cce_cloud_menu(
+ conn: &zbus::Connection,
+ destination: &str,
+ menu_path: &str,
+ x_pos: i32,
+ y_pos: i32,
+ align_right: bool,
+ thread_sender: calloop::channel::Sender<CustomEvent>,
+ source: String,
+ parent_app_id: String,
+) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
+ let mut last_spawned_pid = 0;
+
+ let res = async {
+ let menu_proxy = DBusMenuProxy::builder(conn)
+ .destination(destination)?
+ .path(menu_path)?
+ .build()
+ .await?;
+
+ let _ = menu_proxy.about_to_show(0).await;
+ let (_, layout) = menu_proxy.get_layout(0, 5, vec![]).await?;
+
+ let root_item = match parse_menu_item(layout.0, layout.1, layout.2) {
+ Some(item) => item,
+ None => return Ok(()),
+ };
+
+ // Assign page indices to submenus.
+ let mut page_indices = std::collections::HashMap::new();
+ page_indices.insert(root_item.id, 0);
+ let mut parent_pages = std::collections::HashMap::new();
+ let mut next_page = 1;
+
+ fn assign_pages(
+ item: &MenuItem,
+ current_page: usize,
+ page_indices: &mut std::collections::HashMap<i32, usize>,
+ parent_pages: &mut std::collections::HashMap<usize, usize>,
+ next_page: &mut usize,
+ ) {
+ for child in &item.children {
+ if child.is_separator || !child.enabled {
+ continue;
+ }
+ if !child.children.is_empty() && *next_page < 16 {
+ let child_page = *next_page;
+ page_indices.insert(child.id, child_page);
+ parent_pages.insert(child_page, current_page);
+ *next_page += 1;
+ assign_pages(child, child_page, page_indices, parent_pages, next_page);
+ }
+ }
+ }
+
+ assign_pages(&root_item, 0, &mut page_indices, &mut parent_pages, &mut next_page);
+
+ #[derive(Debug, Clone)]
+ struct LocalWidget {
+ widget_type: String,
+ text: String,
+ id: Option<String>,
+ target_page: Option<usize>,
+ }
+
+ #[derive(Debug, Clone)]
+ struct LocalPage {
+ title: String,
+ widgets: Vec<LocalWidget>,
+ }
+
+ let mut pages = vec![LocalPage {
+ title: "".to_string(),
+ widgets: Vec::new(),
+ }; next_page];
+
+ fn build_pages(
+ item: &MenuItem,
+ current_page: usize,
+ page_indices: &std::collections::HashMap<i32, usize>,
+ parent_pages: &std::collections::HashMap<usize, usize>,
+ pages: &mut [LocalPage],
+ ) {
+ let mut widgets = Vec::new();
+
+ if current_page > 0 {
+ if let Some(&parent_page) = parent_pages.get(¤t_page) {
+ widgets.push(LocalWidget {
+ widget_type: "button".to_string(),
+ text: "< Back".to_string(),
+ id: Some(format!("back_to_{}", parent_page)),
+ target_page: Some(parent_page),
+ });
+ }
+ }
+
+ for child in &item.children {
+ if child.is_separator || !child.enabled {
+ continue;
+ }
+
+ let mut display_label = if child.toggle_state == 1 {
+ format!("[x] {}", child.label)
+ } else if child.toggle_state == 0 {
+ format!("[ ] {}", child.label)
+ } else {
+ child.label.clone()
+ };
+
+ if !child.children.is_empty() {
+ if let Some(&target_page) = page_indices.get(&child.id) {
+ display_label = format!("{} >", display_label);
+
+ widgets.push(LocalWidget {
+ widget_type: "button".to_string(),
+ text: display_label,
+ id: Some(format!("submenu_{}", child.id)),
+ target_page: Some(target_page),
+ });
+
+ build_pages(child, target_page, page_indices, parent_pages, pages);
+ } else {
+ widgets.push(LocalWidget {
+ widget_type: "button".to_string(),
+ text: display_label,
+ id: Some(format!("item_{}", child.id)),
+ target_page: None,
+ });
+ }
+ } else {
+ widgets.push(LocalWidget {
+ widget_type: "button".to_string(),
+ text: display_label,
+ id: Some(format!("item_{}", child.id)),
+ target_page: None,
+ });
+ }
+ }
+
+ let title = if item.label.is_empty() {
+ if current_page == 0 {
+ "Tray Menu".to_string()
+ } else {
+ "".to_string()
+ }
+ } else {
+ item.label.clone()
+ };
+
+ pages[current_page] = LocalPage {
+ title,
+ widgets,
+ };
+ }
+
+ build_pages(&root_item, 0, &page_indices, &parent_pages, &mut pages);
+
+ // Serialize to JSON value
+ let mut pages_json = Vec::new();
+ for page in pages {
+ let mut widgets_json = Vec::new();
+ for w in page.widgets {
+ let mut w_val = serde_json::json!({
+ "type": w.widget_type,
+ "text": w.text,
+ });
+ if let Some(id) = w.id {
+ w_val["id"] = serde_json::Value::String(id);
+ }
+ if let Some(tp) = w.target_page {
+ w_val["target_page"] = serde_json::Value::Number(tp.into());
+ }
+ widgets_json.push(w_val);
+ }
+ pages_json.push(serde_json::json!({
+ "title": page.title,
+ "widgets": widgets_json,
+ }));
+ }
+
+ let layout_json = serde_json::json!({
+ "width": 260,
+ "pages": pages_json,
+ });
+ let layout_str = layout_json.to_string();
+
+ let mut cmd_args = vec![
+ "--json".to_string(),
+ "-x".to_string(),
+ x_pos.to_string(),
+ "-y".to_string(),
+ y_pos.to_string(),
+ "--parent-app-id".to_string(),
+ parent_app_id,
+ ];
+ if align_right {
+ cmd_args.push("--align-right".to_string());
+ }
+
+ let mut child = std::process::Command::new(get_cce_cloud_cmd())
+ .args(&cmd_args)
+ .stdin(std::process::Stdio::piped())
+ .stdout(std::process::Stdio::piped())
+ .stderr(std::process::Stdio::inherit())
+ .spawn()?;
+
+ let pid = child.id();
+ last_spawned_pid = pid;
+ let _ = thread_sender.send(CustomEvent::CloudSpawned { pid, source: source.clone() });
+
+ if let Some(mut stdin) = child.stdin.take() {
+ use std::io::Write;
+ stdin.write_all(layout_str.as_bytes())?;
+ }
+
+ let output = child.wait_with_output()?;
+ if output.status.success() {
+ let stdout_str = String::from_utf8_lossy(&output.stdout);
+ if let Ok(parsed_json) = serde_json::from_str::<serde_json::Value>(stdout_str.trim()) {
+ if let Some(btn_id) = parsed_json.get("button").and_then(|v| v.as_str()) {
+ if btn_id.starts_with("item_") {
+ if let Ok(item_id) = btn_id["item_".len()..].parse::<i32>() {
+ let timestamp = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_secs() as u32;
+ let val = zbus::zvariant::Value::from("");
+ let _ = menu_proxy.event(item_id, "clicked", &val, timestamp).await;
+ }
+ }
+ }
+ }
+ }
+ Ok::<(), Box<dyn std::error::Error + Send + Sync>>(())
+ }.await;
+
+ let _ = thread_sender.send(CustomEvent::CloudClosed { pid: last_spawned_pid, source });
+ res
+}
+
+
+/// Spawn the click-to-pick window list as a cce-cloud dmenu process, report
+/// lifecycle via CloudSpawned/CloudClosed, and focus the picked window.
+pub(crate) fn spawn_window_picker(
+ x_pos: i32,
+ y_pos: i32,
+ thread_sender: calloop::channel::Sender<CustomEvent>,
+ source: String,
+) {
+ std::thread::spawn(move || {
+ // Run "ccectl windows" to fetch the windows list
+ let output = std::process::Command::new(get_ccectl_cmd())
+ .arg("windows")
+ .output();
+
+ let windows = if let Ok(out) = output {
+ parse_ccectl_windows(&String::from_utf8_lossy(&out.stdout))
+ } else {
+ Vec::new()
+ };
+
+ if windows.is_empty() {
+ // If there are no windows, don't open a switcher and clear state
+ let _ = thread_sender.send(CustomEvent::CloudClosed { pid: 0, source: source.clone() });
+ return;
+ }
+
+ // Format items for dmenu, keeping the stable order returned by ccectl
+ let mut input_str = String::new();
+ for (_, app_id, title, _) in &windows {
+ let display = if title.is_empty() {
+ app_id.clone()
+ } else {
+ format!("{} ({})", title, app_id)
+ };
+ input_str.push_str(&display);
+ input_str.push('\n');
+ }
+
+ let cmd_args = vec![
+ "--dmenu".to_string(),
+ "-p".to_string(),
+ "Windows:".to_string(),
+ "-x".to_string(),
+ x_pos.to_string(),
+ "-y".to_string(),
+ y_pos.to_string(),
+ ];
+
+ let mut child = match std::process::Command::new(get_cce_cloud_cmd())
+ .args(&cmd_args)
+ .stdin(std::process::Stdio::piped())
+ .stdout(std::process::Stdio::piped())
+ .stderr(std::process::Stdio::inherit())
+ .spawn()
+ {
+ Ok(c) => c,
+ Err(e) => {
+ eprintln!("[switcher] Failed to spawn cce-cloud: {:?}", e);
+ let _ = thread_sender.send(CustomEvent::CloudClosed { pid: 0, source: source.clone() });
+ return;
+ }
+ };
+
+ let pid = child.id();
+ let mut stdin = child.stdin.take().unwrap();
+ let mut stdout = child.stdout.take().unwrap();
+
+ // Write the item list, then drop stdin so cce-cloud sees EOF.
+ use std::io::Write;
+ let _ = stdin.write_all(input_str.as_bytes());
+ let _ = stdin.flush();
+ drop(stdin);
+
+ // Spawn stdout reader
+ let (stdout_tx, stdout_rx) = std::sync::mpsc::channel();
+ std::thread::spawn(move || {
+ let mut out_str = String::new();
+ use std::io::Read;
+ let _ = stdout.read_to_string(&mut out_str);
+ let _ = stdout_tx.send(out_str);
+ });
+
+ let _ = thread_sender.send(CustomEvent::CloudSpawned { pid, source: source.clone() });
+
+ let _ = child.wait();
+ let stdout_str = stdout_rx.recv().unwrap_or_default();
+
+ let selected = stdout_str.trim().to_string();
+ if !selected.is_empty() {
+ // Find the matched window
+ for (id, app_id, title, _) in windows {
+ let display = if title.is_empty() {
+ app_id.clone()
+ } else {
+ format!("{} ({})", title, app_id)
+ };
+ if display == selected {
+ eprintln!("[switcher] Selecting window title: {}, app_id: {}, id: {}", title, app_id, id);
+ let _ = std::process::Command::new(get_ccectl_cmd())
+ .args(["focus-window", &id])
+ .spawn();
+ break;
+ }
+ }
+ }
+
+ let _ = thread_sender.send(CustomEvent::CloudClosed { pid, source: source.clone() });
+ });
+}
diff --git a/src/config.rs b/src/config.rs
new file mode 100644
index 0000000..6753a73
--- /dev/null
+++ b/src/config.rs
@@ -0,0 +1,242 @@
+//! Config access: cached KDL config lookup, color/font/dimension readers,
+//! and resolution of the cce/ccectl/cce-cloud binaries.
+
+pub(crate) fn parse_json(content: &str) -> serde_json::Value {
+ cce_ui::config::parse_kdl_to_json(content)
+}
+
+pub(crate) fn json_find_key<'a>(val: &'a serde_json::Value, key: &str) -> Option<&'a serde_json::Value> {
+ fn find_recursive<'a>(val: &'a serde_json::Value, key: &str) -> Option<&'a serde_json::Value> {
+ if let Some(obj) = val.as_object() {
+ if let Some(v) = obj.get(key) {
+ return Some(v);
+ }
+ let parts: Vec<&str> = key.split('_').collect();
+ for i in 1..parts.len() {
+ let (prefix_parts, suffix_parts) = parts.split_at(i);
+ let prefix = prefix_parts.join("_");
+ let suffix = suffix_parts.join("_");
+ if let Some(sub_val) = obj.get(&prefix) {
+ if let Some(found) = find_recursive(sub_val, &suffix) {
+ return Some(found);
+ }
+ }
+ }
+ for (_, sub_val) in obj.iter() {
+ if let Some(found) = find_recursive(sub_val, key) {
+ return Some(found);
+ }
+ }
+ }
+ None
+ }
+ find_recursive(val, key)
+}
+
+pub(crate) fn get_cached_config() -> serde_json::Value {
+ cce_ui::config::cached_config()
+}
+
+pub(crate) fn get_cached_config_content() -> String {
+ cce_ui::config::cached_config_content()
+}
+
+pub(crate) fn read_normal_color_from_config() -> Option<[f32; 4]> {
+ let content = get_cached_config_content();
+ parse_srgb_color_from_key(&content, "status_normal_color")
+}
+
+pub(crate) fn read_disabled_color_from_config() -> Option<[f32; 4]> {
+ let content = get_cached_config_content();
+ parse_srgb_color_from_key(&content, "disabled_color")
+}
+
+pub(crate) fn parse_srgb_color_from_key(content: &str, key: &str) -> Option<[f32; 4]> {
+ let val = parse_json(content);
+ if let Some(s) = json_find_key(&val, key).and_then(|v| v.as_str()) {
+ if let Some(rgb) = parse_hex(s) {
+ let r = rgb[0] as f32 / 255.0;
+ let g = rgb[1] as f32 / 255.0;
+ let b = rgb[2] as f32 / 255.0;
+ return Some([r, g, b, 1.0]);
+ }
+ }
+ None
+}
+
+pub(crate) fn read_status_font_from_config() -> String {
+ let val = get_cached_config();
+ if let Some(font_str) = json_find_key(&val, "status_font").and_then(|v| v.as_str()) {
+ return font_str.to_string();
+ }
+
+ let font_conf_path = cce_ui::config::config_home().join("fontconfig").join("fonts.conf");
+ if let Ok(content) = std::fs::read_to_string(&font_conf_path) {
+ if let Some(font) = parse_font_for_alias(&content, "status-interface") {
+ return font;
+ }
+ }
+ "sans-serif".to_string()
+}
+
+pub(crate) fn read_status_height_from_config() -> f32 {
+ let val = get_cached_config();
+ json_find_key(&val, "bar_height").and_then(|v| v.as_f64()).map(|n| n as f32).unwrap_or(28.0)
+}
+
+pub(crate) fn read_status_font_size_from_config() -> f32 {
+ let val = get_cached_config();
+
+ if let Some(font_str) = json_find_key(&val, "status_font").and_then(|v| v.as_str()) {
+ let (_, parsed_size) = cce_ui::layout::parse_font_string(font_str);
+ if let Some(size) = parsed_size {
+ return size;
+ }
+ }
+
+ json_find_key(&val, "status_font_size").and_then(|v| v.as_f64()).map(|n| n as f32).unwrap_or(11.0)
+}
+
+pub(crate) fn read_status_padding_from_config() -> f32 {
+ let val = get_cached_config();
+ json_find_key(&val, "status_padding").and_then(|v| v.as_f64()).map(|n| n as f32).unwrap_or(8.0)
+}
+
+pub(crate) fn read_status_module_spacing_from_config() -> f32 {
+ let val = get_cached_config();
+ json_find_key(&val, "status_module_spacing").and_then(|v| v.as_f64()).map(|n| n as f32).unwrap_or(8.0)
+}
+
+pub(crate) fn read_separator_color_from_config() -> Option<[f32; 4]> {
+ let content = get_cached_config_content();
+ parse_color_from_key(&content, "status_separator_color")
+}
+
+
+
+pub(crate) fn parse_font_for_alias(content: &str, alias: &str) -> Option<String> {
+ let lines: Vec<&str> = content.lines().collect();
+ for i in 0..lines.len() {
+ let line = lines[i].trim();
+ if line.contains("<test") && line.contains("name=\"family\"") && line.contains(&format!("<string>{}</string>", alias)) {
+ for j in (i + 1)..(i + 6).min(lines.len()) {
+ let next_line = lines[j].trim();
+ if next_line.contains("<edit") {
+ for k in (j + 1)..(j + 6).min(lines.len()) {
+ let str_line = lines[k].trim();
+ if str_line.contains("<string>") && str_line.contains("</string>") {
+ if let Some(start) = str_line.find("<string>") {
+ if let Some(end) = str_line.find("</string>") {
+ return Some(str_line[start + 8..end].to_string());
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ None
+}
+
+pub(crate) fn read_bg_color_from_config() -> Option<[f32; 4]> {
+ let content = get_cached_config_content();
+ parse_color_from_key(&content, "background_color")
+ .or_else(|| parse_color_from_key(&content, "low_color"))
+ .or_else(|| parse_color_from_key(&content, "desktop_gap_color"))
+}
+
+pub(crate) fn parse_color_from_key(content: &str, key: &str) -> Option<[f32; 4]> {
+ let val = parse_json(content);
+ if let Some(s) = json_find_key(&val, key).and_then(|v| v.as_str()) {
+ if let Some(rgba) = parse_hex_rgba(s) {
+ let r = (rgba[0] as f32 / 255.0).powf(2.2);
+ let g = (rgba[1] as f32 / 255.0).powf(2.2);
+ let b = (rgba[2] as f32 / 255.0).powf(2.2);
+ let a = rgba[3] as f32 / 255.0;
+ return Some([r, g, b, a]);
+ }
+ }
+ None
+}
+
+pub(crate) fn parse_hex(s: &str) -> Option<[u8; 3]> {
+ cce_ui::color::parse_hex_bytes(s).map(|[r, g, b, _]| [r, g, b])
+}
+pub(crate) fn parse_hex_rgba(s: &str) -> Option<[u8; 4]> {
+ cce_ui::color::parse_hex_bytes(s)
+}
+
+pub(crate) fn parse_rgba_color_from_key(content: &str, key: &str) -> Option<[f32; 4]> {
+ let val = parse_json(content);
+ if let Some(s) = json_find_key(&val, key).and_then(|v| v.as_str()) {
+ if let Some(rgba) = parse_hex_rgba(s) {
+ let r = (rgba[0] as f32 / 255.0).powf(2.2);
+ let g = (rgba[1] as f32 / 255.0).powf(2.2);
+ let b = (rgba[2] as f32 / 255.0).powf(2.2);
+ let a = rgba[3] as f32 / 255.0;
+ return Some([r, g, b, a]);
+ }
+ }
+ None
+}
+
+pub(crate) fn read_status_background_blur_from_config() -> f32 {
+ let val = get_cached_config();
+ json_find_key(&val, "status_background_blur").and_then(|v| v.as_f64()).map(|n| n as f32).unwrap_or(0.0)
+}
+
+pub(crate) fn read_status_box_background_color_from_config() -> Option<[f32; 4]> {
+ let content = get_cached_config_content();
+ let mut color = parse_rgba_color_from_key(&content, "status_background_color")
+ .unwrap_or_else(|| {
+ let r = (0x15 as f32 / 255.0).powf(2.2);
+ let g = (0x15 as f32 / 255.0).powf(2.2);
+ let b = (0x20 as f32 / 255.0).powf(2.2);
+ [r, g, b, 0.9]
+ });
+
+ let blur = read_status_background_blur_from_config();
+
+ // Scale RGB by (1.0 - blur) to apply tint factor while keeping alpha as full opacity for the blur shader
+ color[0] *= 1.0 - blur;
+ color[1] *= 1.0 - blur;
+ color[2] *= 1.0 - blur;
+
+ Some(color)
+}
+
+pub(crate) fn read_status_box_corner_radius_from_config() -> f32 {
+ let val = get_cached_config();
+ json_find_key(&val, "status_box_corner_radius").and_then(|v| v.as_f64()).map(|n| n as f32).unwrap_or(4.0)
+}
+
+pub(crate) fn get_cce_cloud_cmd() -> String {
+ if let Ok(home) = std::env::var("HOME") {
+ let path = format!("{}/.local/bin/cce-cloud", home);
+ if std::path::Path::new(&path).exists() {
+ return path;
+ }
+ }
+ "cce-cloud".to_string()
+}
+
+pub(crate) fn get_cce_cmd() -> String {
+ if let Ok(home) = std::env::var("HOME") {
+ let path = format!("{}/.local/bin/cce", home);
+ if std::path::Path::new(&path).exists() {
+ return path;
+ }
+ }
+ "cce".to_string()
+}
+
+pub(crate) fn get_ccectl_cmd() -> String {
+ if let Ok(home) = std::env::var("HOME") {
+ let path = format!("{}/.local/bin/ccectl", home);
+ if std::path::Path::new(&path).exists() {
+ return path;
+ }
+ }
+ "ccectl".to_string()
+}
diff --git a/src/listeners.rs b/src/listeners.rs
new file mode 100644
index 0000000..1bbca25
--- /dev/null
+++ b/src/listeners.rs
@@ -0,0 +1,75 @@
+//! Compositor push-update listeners: the status-feed subscriptions and the
+//! switcher trigger socket.
+
+use crate::CustomEvent;
+
+pub(crate) async fn spawn_status_listener(sub: &'static str, sender: calloop::channel::Sender<CustomEvent>) {
+ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
+ use tokio::net::UnixStream;
+ loop {
+ let socket_path = match std::env::var("WAYLAND_DISPLAY") {
+ Ok(display) => {
+ let primary = format!("/tmp/cce-status-interface-{}.sock", display);
+ if std::path::Path::new(&primary).exists() {
+ primary
+ } else {
+ format!("/tmp/cce-status-{}.sock", display)
+ }
+ }
+ Err(_) => {
+ let primary = "/tmp/cce-status-interface.sock".to_string();
+ if std::path::Path::new(&primary).exists() {
+ primary
+ } else {
+ "/tmp/cce-status.sock".to_string()
+ }
+ }
+ };
+ if let Ok(mut stream) = UnixStream::connect(&socket_path).await {
+ eprintln!("[status-listener] connected to {} for sub '{}'", socket_path, sub);
+ if stream.write_all(format!("{}\n", sub).as_bytes()).await.is_ok() {
+ let mut reader = BufReader::new(stream);
+ let mut line = String::new();
+ while reader.read_line(&mut line).await.unwrap_or(0) > 0 {
+ let val = line.trim().to_string();
+ eprintln!("[status-listener] received '{}' update: '{}'", sub, val);
+ if !val.is_empty() {
+ let ev = match sub {
+ "viewport" => CustomEvent::ViewportUpdated(val.clone()),
+ "layout" => CustomEvent::LayoutUpdated(val.clone()),
+ "title" => CustomEvent::TitleUpdated(val.clone()),
+ "modifiers" => CustomEvent::ModifiersUpdated(val.clone()),
+ _ => unreachable!(),
+ };
+ let _ = sender.send(ev);
+ }
+ line.clear();
+ }
+ }
+ }
+ tokio::time::sleep(std::time::Duration::from_secs(1)).await;
+ }
+}
+
+pub(crate) async fn spawn_switcher_listener(sender: calloop::channel::Sender<CustomEvent>) {
+ use tokio::io::AsyncBufReadExt;
+ use tokio::net::UnixListener;
+ let display = std::env::var("WAYLAND_DISPLAY").unwrap_or_else(|_| "wayland-0".to_string());
+ let socket_path = format!("/tmp/cce-status-interface-switcher-{}.sock", display);
+ let _ = std::fs::remove_file(&socket_path);
+
+ if let Ok(listener) = UnixListener::bind(&socket_path) {
+ eprintln!("[switcher-listener] Listening on {}", socket_path);
+ loop {
+ if let Ok((stream, _)) = listener.accept().await {
+ let mut reader = tokio::io::BufReader::new(stream);
+ let mut line = String::new();
+ if reader.read_line(&mut line).await.is_ok() {
+ let _ = sender.send(CustomEvent::SwitcherTriggered);
+ }
+ }
+ }
+ } else {
+ eprintln!("[switcher-listener] Failed to bind to {}", socket_path);
+ }
+}
diff --git a/src/main.rs b/src/main.rs
index a4e9e5f..377ab7b 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,8 +1,21 @@
+mod cloud;
+mod config;
+mod listeners;
mod modules;
+mod stats;
+mod tray;
+
+// Re-export at the crate root so call sites (here and in modules.rs) keep
+// their pre-split names.
+pub(crate) use cloud::*;
+pub(crate) use config::*;
+pub(crate) use listeners::*;
+pub(crate) use stats::*;
+pub(crate) use tray::*;
+
use modules::{StatusModule, WindowModule, ClockModule, BatteryModule, VolumeModule, BrightnessModule, MemoryModule, CpuModule, TrayModule, LightSourceModule};
use std::collections::HashMap;
-use std::sync::Arc;
use glyphon::{
Attrs, Buffer, FontSystem, Metrics,
};
@@ -72,7 +85,7 @@ pub struct SystemStats {
}
#[derive(Debug, Clone)]
-enum CustomEvent {
+pub(crate) enum CustomEvent {
ViewportUpdated(String),
LayoutUpdated(String),
TitleUpdated(String),
@@ -151,86 +164,6 @@ pub(crate) fn parse_viewport_text(input: &str) -> Vec<([f32; 4], String)> {
result
}
-fn read_cpu_ticks() -> Option<(u64, u64)> {
- let stat = std::fs::read_to_string("/proc/stat").ok()?;
- let first_line = stat.lines().next()?;
- if first_line.starts_with("cpu ") {
- let parts: Vec<u64> = first_line
- .split_whitespace()
- .skip(1)
- .filter_map(|s| s.parse::<u64>().ok())
- .collect();
- if parts.len() >= 4 {
- let idle = parts[3];
- let total: u64 = parts.iter().sum();
- return Some((total, idle));
- }
- }
- None
-}
-
-fn read_memory_usage() -> Option<String> {
- let meminfo = std::fs::read_to_string("/proc/meminfo").ok()?;
- let mut total = 0.0;
- let mut free = 0.0;
- let mut buffers = 0.0;
- let mut cached = 0.0;
- for line in meminfo.lines() {
- if line.starts_with("MemTotal:") {
- total = line.split_whitespace().nth(1)?.parse::<f32>().ok()? / 1024.0 / 1024.0;
- } else if line.starts_with("MemFree:") {
- free = line.split_whitespace().nth(1)?.parse::<f32>().ok()? / 1024.0 / 1024.0;
- } else if line.starts_with("Buffers:") {
- buffers = line.split_whitespace().nth(1)?.parse::<f32>().ok()? / 1024.0 / 1024.0;
- } else if line.starts_with("Cached:") {
- cached = line.split_whitespace().nth(1)?.parse::<f32>().ok()? / 1024.0 / 1024.0;
- }
- }
- if total > 0.0 {
- let used = total - free - buffers - cached;
- Some(format!("Mem {:.1}/{:.1}G", used, total))
- } else {
- None
- }
-}
-
-fn read_battery_details() -> Option<(String, i32, bool)> {
- for bat in &["BAT0", "BAT1"] {
- let cap_path = format!("/sys/class/power_supply/{}/capacity", bat);
- let status_path = format!("/sys/class/power_supply/{}/status", bat);
- if let Ok(cap_str) = std::fs::read_to_string(&cap_path) {
- let cap_trimmed = cap_str.trim();
- let cap = cap_trimmed.parse::<i32>().unwrap_or(0);
- let status = std::fs::read_to_string(&status_path).unwrap_or_default();
- let is_charging = status.trim() == "Charging";
- let charge_symbol = if is_charging { "⚡" } else { "Bat" };
- return Some((format!("{} {}%", charge_symbol, cap_trimmed), cap, is_charging));
- }
- }
- None
-}
-
-fn read_brightness() -> Option<String> {
- let dir = std::fs::read_dir("/sys/class/backlight").ok()?;
- for entry in dir {
- if let Ok(entry) = entry {
- let path = entry.path();
- let cur_path = path.join("brightness");
- let max_path = path.join("max_brightness");
- if cur_path.exists() && max_path.exists() {
- let cur_str = std::fs::read_to_string(cur_path).ok()?;
- let max_str = std::fs::read_to_string(max_path).ok()?;
- let cur = cur_str.trim().parse::<f32>().ok()?;
- let max = max_str.trim().parse::<f32>().ok()?;
- if max > 0.0 {
- let pct = (cur / max * 100.0).round() as i32;
- return Some(format!("Bri {}%", pct));
- }
- }
- }
- }
- None
-}
pub struct RectWidget {
pub x: f32, pub y: f32, pub w: f32, pub h: f32,
@@ -832,109 +765,7 @@ impl StatusApp {
let x_pos = target_x as i32;
let y_pos = bar_height;
- let thread_sender = self.sender.clone();
- let switcher_source_clone = switcher_source.clone();
-
- std::thread::spawn(move || {
- // Run "ccectl windows" to fetch the windows list
- let output = std::process::Command::new(get_ccectl_cmd())
- .arg("windows")
- .output();
-
- let windows = if let Ok(out) = output {
- parse_ccectl_windows(&String::from_utf8_lossy(&out.stdout))
- } else {
- Vec::new()
- };
-
- if windows.is_empty() {
- // If there are no windows, don't open a switcher and clear state
- let _ = thread_sender.send(CustomEvent::CloudClosed { pid: 0, source: switcher_source_clone });
- return;
- }
-
- // Format items for dmenu, keeping the stable order returned by ccectl
- let mut input_str = String::new();
- for (_, app_id, title, _) in &windows {
- let display = if title.is_empty() {
- app_id.clone()
- } else {
- format!("{} ({})", title, app_id)
- };
- input_str.push_str(&display);
- input_str.push('\n');
- }
-
- let cmd_args = vec![
- "--dmenu".to_string(),
- "-p".to_string(),
- "Windows:".to_string(),
- "-x".to_string(),
- x_pos.to_string(),
- "-y".to_string(),
- y_pos.to_string(),
- ];
-
- let mut child = match std::process::Command::new(get_cce_cloud_cmd())
- .args(&cmd_args)
- .stdin(std::process::Stdio::piped())
- .stdout(std::process::Stdio::piped())
- .stderr(std::process::Stdio::inherit())
- .spawn()
- {
- Ok(c) => c,
- Err(e) => {
- eprintln!("[switcher] Failed to spawn cce-cloud: {:?}", e);
- let _ = thread_sender.send(CustomEvent::CloudClosed { pid: 0, source: switcher_source_clone });
- return;
- }
- };
-
- let pid = child.id();
- let mut stdin = child.stdin.take().unwrap();
- let mut stdout = child.stdout.take().unwrap();
-
- // Write the item list, then drop stdin so cce-cloud sees EOF.
- use std::io::Write;
- let _ = stdin.write_all(input_str.as_bytes());
- let _ = stdin.flush();
- drop(stdin);
-
- // Spawn stdout reader
- let (stdout_tx, stdout_rx) = std::sync::mpsc::channel();
- std::thread::spawn(move || {
- let mut out_str = String::new();
- use std::io::Read;
- let _ = stdout.read_to_string(&mut out_str);
- let _ = stdout_tx.send(out_str);
- });
-
- let _ = thread_sender.send(CustomEvent::CloudSpawned { pid, source: switcher_source_clone.clone() });
-
- let _ = child.wait();
- let stdout_str = stdout_rx.recv().unwrap_or_default();
-
- let selected = stdout_str.trim().to_string();
- if !selected.is_empty() {
- // Find the matched window
- for (id, app_id, title, _) in windows {
- let display = if title.is_empty() {
- app_id.clone()
- } else {
- format!("{} ({})", title, app_id)
- };
- if display == selected {
- eprintln!("[switcher] Selecting window title: {}, app_id: {}, id: {}", title, app_id, id);
- let _ = std::process::Command::new(get_ccectl_cmd())
- .args(["focus-window", &id])
- .spawn();
- break;
- }
- }
- }
-
- let _ = thread_sender.send(CustomEvent::CloudClosed { pid, source: switcher_source_clone });
- });
+ cloud::spawn_window_picker(x_pos, y_pos, self.sender.clone(), switcher_source);
}
}
@@ -1916,1115 +1747,12 @@ impl cce_ui::engine::Application for StatusApp {
fn handle_key_input(&mut self, _event: &KeyEvent, _needs_rebuild: &mut bool) -> Option<Self::Message> { None }
}
-async fn spawn_status_listener(sub: &'static str, sender: calloop::channel::Sender<CustomEvent>) {
- use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
- use tokio::net::UnixStream;
- loop {
- let socket_path = match std::env::var("WAYLAND_DISPLAY") {
- Ok(display) => {
- let primary = format!("/tmp/cce-status-interface-{}.sock", display);
- if std::path::Path::new(&primary).exists() {
- primary
- } else {
- format!("/tmp/cce-status-{}.sock", display)
- }
- }
- Err(_) => {
- let primary = "/tmp/cce-status-interface.sock".to_string();
- if std::path::Path::new(&primary).exists() {
- primary
- } else {
- "/tmp/cce-status.sock".to_string()
- }
- }
- };
- if let Ok(mut stream) = UnixStream::connect(&socket_path).await {
- eprintln!("[status-listener] connected to {} for sub '{}'", socket_path, sub);
- if stream.write_all(format!("{}\n", sub).as_bytes()).await.is_ok() {
- let mut reader = BufReader::new(stream);
- let mut line = String::new();
- while reader.read_line(&mut line).await.unwrap_or(0) > 0 {
- let val = line.trim().to_string();
- eprintln!("[status-listener] received '{}' update: '{}'", sub, val);
- if !val.is_empty() {
- let ev = match sub {
- "viewport" => CustomEvent::ViewportUpdated(val.clone()),
- "layout" => CustomEvent::LayoutUpdated(val.clone()),
- "title" => CustomEvent::TitleUpdated(val.clone()),
- "modifiers" => CustomEvent::ModifiersUpdated(val.clone()),
- _ => unreachable!(),
- };
- let _ = sender.send(ev);
- }
- line.clear();
- }
- }
- }
- tokio::time::sleep(std::time::Duration::from_secs(1)).await;
- }
-}
-
-async fn spawn_switcher_listener(sender: calloop::channel::Sender<CustomEvent>) {
- use tokio::io::AsyncBufReadExt;
- use tokio::net::UnixListener;
- let display = std::env::var("WAYLAND_DISPLAY").unwrap_or_else(|_| "wayland-0".to_string());
- let socket_path = format!("/tmp/cce-status-interface-switcher-{}.sock", display);
- let _ = std::fs::remove_file(&socket_path);
-
- if let Ok(listener) = UnixListener::bind(&socket_path) {
- eprintln!("[switcher-listener] Listening on {}", socket_path);
- loop {
- if let Ok((stream, _)) = listener.accept().await {
- let mut reader = tokio::io::BufReader::new(stream);
- let mut line = String::new();
- if reader.read_line(&mut line).await.is_ok() {
- let _ = sender.send(CustomEvent::SwitcherTriggered);
- }
- }
- }
- } else {
- eprintln!("[switcher-listener] Failed to bind to {}", socket_path);
- }
-}
-
-async fn read_volume() -> Option<(String, bool)> {
- let vol_output = match tokio::process::Command::new("pactl")
- .args(["get-sink-volume", "@DEFAULT_SINK@"])
- .output()
- .await
- {
- Ok(o) => o,
- Err(e) => {
- eprintln!("[read_volume] failed to spawn pactl: {:?}", e);
- return None;
- }
- };
- if !vol_output.status.success() {
- eprintln!("[read_volume] pactl get-sink-volume exited with error: {:?}", String::from_utf8_lossy(&vol_output.stderr));
- return None;
- }
- let vol_str = String::from_utf8_lossy(&vol_output.stdout);
-
- let mute_output = match tokio::process::Command::new("pactl")
- .args(["get-sink-mute", "@DEFAULT_SINK@"])
- .output()
- .await
- {
- Ok(o) => o,
- Err(e) => {
- eprintln!("[read_volume] failed to spawn pactl mute: {:?}", e);
- return None;
- }
- };
- if !mute_output.status.success() {
- eprintln!("[read_volume] pactl get-sink-mute exited with error: {:?}", String::from_utf8_lossy(&mute_output.stderr));
- return None;
- }
- let mute_str = String::from_utf8_lossy(&mute_output.stdout);
- let muted = mute_str.contains("yes");
-
- let mut pct = None;
- if let Some(pos) = vol_str.find('%') {
- let start = vol_str[..pos].rfind(|c: char| !c.is_ascii_digit()).map(|i| i + 1).unwrap_or(0);
- if let Ok(num) = vol_str[start..pos].parse::<u32>() {
- pct = Some(num);
- }
- }
-
- match (muted, pct) {
- (true, Some(p)) => Some((format!("Vol {}%", p), true)),
- (true, None) => Some(("Vol Muted".to_string(), true)),
- (false, Some(p)) => Some((format!("Vol {}%", p), false)),
- (false, None) => Some(("Vol N/A".to_string(), false)),
- }
-}
-
-fn get_initial_stats() -> SystemStats {
- let clock = chrono::Local::now().format("%A, %B %d, %Y %I:%M %p").to_string();
- let memory = read_memory_usage().unwrap_or_else(|| "Mem N/A".to_string());
-
- let (battery_str, battery_capacity, battery_charging) = if let Some((s, cap, chg)) = read_battery_details() {
- (s, cap, chg)
- } else {
- ("".to_string(), 0, false)
- };
- let (volume, volume_muted) = pollster::block_on(read_volume()).unwrap_or_else(|| ("".to_string(), false));
- let brightness = read_brightness().unwrap_or_default();
-
- SystemStats {
- clock,
- memory,
- cpu: "Cpu 0.0%".to_string(),
- battery: battery_str,
- battery_capacity,
- battery_charging,
- volume,
- volume_muted,
- brightness,
- }
-}
-
-async fn spawn_system_stats(sender: calloop::channel::Sender<CustomEvent>) {
- eprintln!("[spawn_system_stats] Starting system stats loop!");
- let mut last_cpu = read_cpu_ticks().unwrap_or((0, 0));
- loop {
- eprintln!("[spawn_system_stats] loop iteration start");
- let clock = chrono::Local::now().format("%A, %B %d, %Y %I:%M %p").to_string();
- let memory = read_memory_usage().unwrap_or_else(|| "Mem N/A".to_string());
-
- let cpu_str = if let Some(current_cpu) = read_cpu_ticks() {
- let total_diff = current_cpu.0 - last_cpu.0;
- let idle_diff = current_cpu.1 - last_cpu.1;
- last_cpu = current_cpu;
- if total_diff > 0 {
- let usage = 100.0 - (idle_diff as f32 * 100.0 / total_diff as f32);
- format!("Cpu {:.1}%", usage)
- } else {
- "Cpu 0.0%".to_string()
- }
- } else {
- "Cpu N/A".to_string()
- };
-
- let (battery_str, battery_capacity, battery_charging) = if let Some((s, cap, chg)) = read_battery_details() {
- (s, cap, chg)
- } else {
- ("".to_string(), 0, false)
- };
- let (volume, volume_muted) = read_volume().await.unwrap_or_else(|| ("".to_string(), false));
- let brightness = read_brightness().unwrap_or_default();
-
- let stats = SystemStats {
- clock,
- memory,
- cpu: cpu_str,
- battery: battery_str,
- battery_capacity,
- battery_charging,
- volume,
- volume_muted,
- brightness,
- };
- eprintln!("[spawn_system_stats] stats: {:?}", stats);
- let _ = sender.send(CustomEvent::SystemStatsUpdated(stats));
- tokio::time::sleep(std::time::Duration::from_secs(1)).await;
- }
-}
-
-#[derive(Debug, Clone)]
-pub struct NotifierAddress {
- pub destination: String,
- pub path: String,
-}
-
-impl NotifierAddress {
- pub fn from_notifier_service(service: &str, sender: &str) -> Result<Self, String> {
- if service.starts_with('/') {
- Ok(NotifierAddress {
- destination: sender.to_string(),
- path: service.to_string(),
- })
- } else if let Some((destination, path)) = service.split_once('/') {
- Ok(NotifierAddress {
- destination: destination.to_string(),
- path: format!("/{}", path),
- })
- } else if service.contains(':') {
- let split = service.split(':').collect::<Vec<&str>>();
- Ok(NotifierAddress {
- destination: format!(":{}", split[1]),
- path: "/StatusNotifierItem".to_string(),
- })
- } else {
- Ok(NotifierAddress {
- destination: service.to_string(),
- path: "/StatusNotifierItem".to_string(),
- })
- }
- }
-}
-
-#[zbus::proxy(
- interface = "org.kde.StatusNotifierItem",
- default_path = "/StatusNotifierItem"
-)]
-trait StatusNotifierItem {
- #[zbus(property)]
- fn id(&self) -> zbus::Result<String>;
-
- #[zbus(property)]
- fn category(&self) -> zbus::Result<String>;
-
- #[zbus(property)]
- fn status(&self) -> zbus::Result<String>;
-
- #[zbus(property)]
- fn title(&self) -> zbus::Result<String>;
-
- #[zbus(property)]
- fn icon_name(&self) -> zbus::Result<String>;
-
- #[zbus(property)]
- fn icon_theme_path(&self) -> zbus::Result<String>;
-
- #[zbus(property)]
- fn icon_pixmap(&self) -> zbus::Result<Vec<(i32, i32, Vec<u8>)>>;
-
- #[zbus(signal)]
- fn new_icon(&self) -> zbus::Result<()>;
- #[zbus(signal)]
- fn new_title(&self) -> zbus::Result<()>;
-
- #[zbus(signal)]
- fn new_status(&self) -> zbus::Result<()>;
-
- fn activate(&self, x: i32, y: i32) -> zbus::Result<()>;
- fn context_menu(&self, x: i32, y: i32) -> zbus::Result<()>;
-
- #[zbus(property)]
- fn item_is_menu(&self) -> zbus::Result<bool>;
-
- #[zbus(property)]
- fn menu(&self) -> zbus::Result<zbus::zvariant::OwnedObjectPath>;
-}
-
-#[zbus::proxy(
- interface = "com.canonical.dbusmenu",
- default_path = "/StatusNotifierItem/menu"
-)]
-trait DBusMenu {
- fn get_layout(
- &self,
- parent_id: i32,
- recursion_depth: i32,
- property_names: Vec<String>,
- ) -> zbus::Result<(u32, (i32, std::collections::HashMap<String, zbus::zvariant::OwnedValue>, Vec<zbus::zvariant::OwnedValue>))>;
-
- fn event(
- &self,
- id: i32,
- event_id: &str,
- data: &zbus::zvariant::Value<'_>,
- timestamp: u32,
- ) -> zbus::Result<()>;
-
- fn about_to_show(&self, id: i32) -> zbus::Result<bool>;
-}
-
-struct MenuItem {
- id: i32,
- label: String,
- enabled: bool,
- is_separator: bool,
- toggle_state: i32, // -1 if not toggleable, 0 if unchecked, 1 if checked
- children: Vec<MenuItem>,
-}
-
-fn parse_menu_item(
- id: i32,
- mut properties: std::collections::HashMap<String, zbus::zvariant::OwnedValue>,
- children_vals: Vec<zbus::zvariant::OwnedValue>,
-) -> Option<MenuItem> {
- let type_: String = properties.remove("type")
- .and_then(|v| {
- let s: Result<String, _> = v.try_into();
- s.ok()
- })
- .unwrap_or_default();
- let is_separator = type_ == "separator";
-
- let label: String = properties.remove("label")
- .and_then(|v| {
- let s: Result<String, _> = v.try_into();
- s.ok()
- })
- .unwrap_or_default();
-
- let enabled: bool = properties.remove("enabled")
- .and_then(|v| {
- let b: Result<bool, _> = v.try_into();
- b.ok()
- })
- .unwrap_or(true);
-
- let toggle_state: i32 = properties.remove("toggle-state")
- .and_then(|v| {
- let i: Result<i32, _> = v.try_into();
- i.ok()
- })
- .unwrap_or(-1);
-
- let mut children = Vec::new();
- for child_val in children_vals {
- let child_val_inner = zbus::zvariant::Value::from(child_val);
- if let Ok(child) = <(i32, std::collections::HashMap<String, zbus::zvariant::OwnedValue>, Vec<zbus::zvariant::OwnedValue>)>::try_from(child_val_inner) {
- if let Some(parsed) = parse_menu_item(child.0, child.1, child.2) {
- children.push(parsed);
- }
- }
- }
-
- Some(MenuItem {
- id,
- label,
- enabled,
- is_separator,
- toggle_state,
- children,
- })
-}
-
-fn get_cce_cloud_cmd() -> String {
- if let Ok(home) = std::env::var("HOME") {
- let path = format!("{}/.local/bin/cce-cloud", home);
- if std::path::Path::new(&path).exists() {
- return path;
- }
- }
- "cce-cloud".to_string()
-}
-
-fn get_currently_focused_window() -> Option<String> {
- let output = std::process::Command::new(get_ccectl_cmd())
- .arg("windows")
- .output();
- if let Ok(out) = output {
- let stdout_str = String::from_utf8_lossy(&out.stdout);
- for line in stdout_str.lines() {
- let focused = if let Some(idx) = line.find("focused=") {
- let rest = &line[idx + 8..];
- let end = rest.find(' ').unwrap_or(rest.len());
- rest[..end].trim() == "true"
- } else {
- false
- };
-
- if focused {
- let app_id = if let Some(idx) = line.find("app_id=") {
- let rest = &line[idx + 7..];
- let end = rest.find(' ').unwrap_or(rest.len());
- rest[..end].to_string()
- } else {
- continue;
- };
- if app_id == "cce-status" || app_id == "cce-cloud" {
- continue;
- }
-
- // Return the unique window ID if present, otherwise fall back to app_id
- let id = if let Some(idx) = line.find("window id=") {
- let rest = &line[idx + 10..];
- let end = rest.find(' ').unwrap_or(rest.len());
- rest[..end].to_string()
- } else {
- app_id
- };
- return Some(id);
- }
- }
- }
- None
-}
-
-async fn show_cce_cloud_menu(
- conn: &zbus::Connection,
- destination: &str,
- menu_path: &str,
- x_pos: i32,
- y_pos: i32,
- align_right: bool,
- thread_sender: calloop::channel::Sender<CustomEvent>,
- source: String,
- parent_app_id: String,
-) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
- let mut last_spawned_pid = 0;
-
- let res = async {
- let menu_proxy = DBusMenuProxy::builder(conn)
- .destination(destination)?
- .path(menu_path)?
- .build()
- .await?;
-
- let _ = menu_proxy.about_to_show(0).await;
- let (_, layout) = menu_proxy.get_layout(0, 5, vec![]).await?;
-
- let root_item = match parse_menu_item(layout.0, layout.1, layout.2) {
- Some(item) => item,
- None => return Ok(()),
- };
-
- // Assign page indices to submenus.
- let mut page_indices = std::collections::HashMap::new();
- page_indices.insert(root_item.id, 0);
- let mut parent_pages = std::collections::HashMap::new();
- let mut next_page = 1;
-
- fn assign_pages(
- item: &MenuItem,
- current_page: usize,
- page_indices: &mut std::collections::HashMap<i32, usize>,
- parent_pages: &mut std::collections::HashMap<usize, usize>,
- next_page: &mut usize,
- ) {
- for child in &item.children {
- if child.is_separator || !child.enabled {
- continue;
- }
- if !child.children.is_empty() && *next_page < 16 {
- let child_page = *next_page;
- page_indices.insert(child.id, child_page);
- parent_pages.insert(child_page, current_page);
- *next_page += 1;
- assign_pages(child, child_page, page_indices, parent_pages, next_page);
- }
- }
- }
-
- assign_pages(&root_item, 0, &mut page_indices, &mut parent_pages, &mut next_page);
-
- #[derive(Debug, Clone)]
- struct LocalWidget {
- widget_type: String,
- text: String,
- id: Option<String>,
- target_page: Option<usize>,
- }
-
- #[derive(Debug, Clone)]
- struct LocalPage {
- title: String,
- widgets: Vec<LocalWidget>,
- }
-
- let mut pages = vec![LocalPage {
- title: "".to_string(),
- widgets: Vec::new(),
- }; next_page];
-
- fn build_pages(
- item: &MenuItem,
- current_page: usize,
- page_indices: &std::collections::HashMap<i32, usize>,
- parent_pages: &std::collections::HashMap<usize, usize>,
- pages: &mut [LocalPage],
- ) {
- let mut widgets = Vec::new();
-
- if current_page > 0 {
- if let Some(&parent_page) = parent_pages.get(¤t_page) {
- widgets.push(LocalWidget {
- widget_type: "button".to_string(),
- text: "< Back".to_string(),
- id: Some(format!("back_to_{}", parent_page)),
- target_page: Some(parent_page),
- });
- }
- }
-
- for child in &item.children {
- if child.is_separator || !child.enabled {
- continue;
- }
-
- let mut display_label = if child.toggle_state == 1 {
- format!("[x] {}", child.label)
- } else if child.toggle_state == 0 {
- format!("[ ] {}", child.label)
- } else {
- child.label.clone()
- };
-
- if !child.children.is_empty() {
- if let Some(&target_page) = page_indices.get(&child.id) {
- display_label = format!("{} >", display_label);
-
- widgets.push(LocalWidget {
- widget_type: "button".to_string(),
- text: display_label,
- id: Some(format!("submenu_{}", child.id)),
- target_page: Some(target_page),
- });
-
- build_pages(child, target_page, page_indices, parent_pages, pages);
- } else {
- widgets.push(LocalWidget {
- widget_type: "button".to_string(),
- text: display_label,
- id: Some(format!("item_{}", child.id)),
- target_page: None,
- });
- }
- } else {
- widgets.push(LocalWidget {
- widget_type: "button".to_string(),
- text: display_label,
- id: Some(format!("item_{}", child.id)),
- target_page: None,
- });
- }
- }
-
- let title = if item.label.is_empty() {
- if current_page == 0 {
- "Tray Menu".to_string()
- } else {
- "".to_string()
- }
- } else {
- item.label.clone()
- };
-
- pages[current_page] = LocalPage {
- title,
- widgets,
- };
- }
-
- build_pages(&root_item, 0, &page_indices, &parent_pages, &mut pages);
-
- // Serialize to JSON value
- let mut pages_json = Vec::new();
- for page in pages {
- let mut widgets_json = Vec::new();
- for w in page.widgets {
- let mut w_val = serde_json::json!({
- "type": w.widget_type,
- "text": w.text,
- });
- if let Some(id) = w.id {
- w_val["id"] = serde_json::Value::String(id);
- }
- if let Some(tp) = w.target_page {
- w_val["target_page"] = serde_json::Value::Number(tp.into());
- }
- widgets_json.push(w_val);
- }
- pages_json.push(serde_json::json!({
- "title": page.title,
- "widgets": widgets_json,
- }));
- }
-
- let layout_json = serde_json::json!({
- "width": 260,
- "pages": pages_json,
- });
- let layout_str = layout_json.to_string();
-
- let mut cmd_args = vec![
- "--json".to_string(),
- "-x".to_string(),
- x_pos.to_string(),
- "-y".to_string(),
- y_pos.to_string(),
- "--parent-app-id".to_string(),
- parent_app_id,
- ];
- if align_right {
- cmd_args.push("--align-right".to_string());
- }
-
- let mut child = std::process::Command::new(get_cce_cloud_cmd())
- .args(&cmd_args)
- .stdin(std::process::Stdio::piped())
- .stdout(std::process::Stdio::piped())
- .stderr(std::process::Stdio::inherit())
- .spawn()?;
-
- let pid = child.id();
- last_spawned_pid = pid;
- let _ = thread_sender.send(CustomEvent::CloudSpawned { pid, source: source.clone() });
-
- if let Some(mut stdin) = child.stdin.take() {
- use std::io::Write;
- stdin.write_all(layout_str.as_bytes())?;
- }
-
- let output = child.wait_with_output()?;
- if output.status.success() {
- let stdout_str = String::from_utf8_lossy(&output.stdout);
- if let Ok(parsed_json) = serde_json::from_str::<serde_json::Value>(stdout_str.trim()) {
- if let Some(btn_id) = parsed_json.get("button").and_then(|v| v.as_str()) {
- if btn_id.starts_with("item_") {
- if let Ok(item_id) = btn_id["item_".len()..].parse::<i32>() {
- let timestamp = std::time::SystemTime::now()
- .duration_since(std::time::UNIX_EPOCH)
- .unwrap_or_default()
- .as_secs() as u32;
- let val = zbus::zvariant::Value::from("");
- let _ = menu_proxy.event(item_id, "clicked", &val, timestamp).await;
- }
- }
- }
- }
- }
- Ok::<(), Box<dyn std::error::Error + Send + Sync>>(())
- }.await;
-
- let _ = thread_sender.send(CustomEvent::CloudClosed { pid: last_spawned_pid, source });
- res
-}
-
-fn find_icon_file(dir: &std::path::Path, icon_name: &str) -> Option<std::path::PathBuf> {
- if let Ok(entries) = std::fs::read_dir(dir) {
- for entry in entries.filter_map(Result::ok) {
- if let Ok(file_type) = entry.file_type() {
- let path = entry.path();
- if file_type.is_dir() {
- if !file_type.is_symlink() {
- if let Some(found) = find_icon_file(&path, icon_name) {
- return Some(found);
- }
- }
- } else if file_type.is_file() {
- if let Some(file_name) = path.file_name().and_then(|f| f.to_str()) {
- if file_name == format!("{}.png", icon_name) || file_name == format!("{}.svg", icon_name) {
- return Some(path);
- }
- }
- }
- }
- }
- }
- None
-}
-fn load_png_as_pixmap(path: &std::path::Path) -> Option<TrayPixmap> {
- let file = std::fs::File::open(path).ok()?;
- let mut decoder = png::Decoder::new(file);
- decoder.set_transformations(png::Transformations::EXPAND);
- let mut reader = decoder.read_info().ok()?;
- let mut buf = vec![0; reader.output_buffer_size()];
- let info = reader.next_frame(&mut buf).ok()?;
-
- let width = info.width as i32;
- let height = info.height as i32;
- let mut argb_pixels = Vec::with_capacity((width * height * 4) as usize);
-
- let actual_bytes = &buf[..info.buffer_size()];
- match info.color_type {
- png::ColorType::Rgba => {
- for chunk in actual_bytes.chunks_exact(4) {
- argb_pixels.push(chunk[3]); // A
- argb_pixels.push(chunk[0]); // R
- argb_pixels.push(chunk[1]); // G
- argb_pixels.push(chunk[2]); // B
- }
- }
- png::ColorType::Rgb => {
- for chunk in actual_bytes.chunks_exact(3) {
- argb_pixels.push(255); // A
- argb_pixels.push(chunk[0]); // R
- argb_pixels.push(chunk[1]); // G
- argb_pixels.push(chunk[2]); // B
- }
- }
- png::ColorType::Grayscale => {
- for &g in actual_bytes {
- argb_pixels.push(255); // A
- argb_pixels.push(g); // R
- argb_pixels.push(g); // G
- argb_pixels.push(g); // B
- }
- }
- png::ColorType::GrayscaleAlpha => {
- for chunk in actual_bytes.chunks_exact(2) {
- argb_pixels.push(chunk[1]); // A
- argb_pixels.push(chunk[0]); // R
- argb_pixels.push(chunk[0]); // G
- argb_pixels.push(chunk[0]); // B
- }
- }
- _ => return None,
- }
-
- Some(TrayPixmap {
- width,
- height,
- pixels: argb_pixels,
- })
-}
-fn load_svg_as_pixmap(path: &std::path::Path) -> Option<TrayPixmap> {
- let svg_data = std::fs::read(path).ok()?;
- let opt = resvg::usvg::Options::default();
- let fontdb = resvg::usvg::fontdb::Database::new();
- let tree = resvg::usvg::Tree::from_data(&svg_data, &opt, &fontdb).ok()?;
-
- let target_w = 48;
- let target_h = 48;
- let mut pixmap = resvg::tiny_skia::Pixmap::new(target_w, target_h)?;
-
- let orig_w = tree.size().width();
- let orig_h = tree.size().height();
- let sx = target_w as f32 / orig_w;
- let sy = target_h as f32 / orig_h;
- let transform = resvg::tiny_skia::Transform::from_scale(sx, sy);
-
- resvg::render(&tree, transform, &mut pixmap.as_mut());
-
- let raw_pixels = pixmap.data();
- let mut argb_pixels = Vec::with_capacity((target_w * target_h * 4) as usize);
- for chunk in raw_pixels.chunks_exact(4) {
- argb_pixels.push(chunk[3]); // A
- argb_pixels.push(chunk[0]); // R
- argb_pixels.push(chunk[1]); // G
- argb_pixels.push(chunk[2]); // B
- }
-
- Some(TrayPixmap {
- width: target_w as i32,
- height: target_h as i32,
- pixels: argb_pixels,
- })
-}
-
-fn resolve_icon_path(theme_path: Option<&str>, icon_name: &str) -> Option<std::path::PathBuf> {
- if icon_name.is_empty() {
- return None;
- }
- let icon_name = if icon_name == "dropbox" { "dropboxstatus-idle" } else { icon_name };
- if let Some(path_str) = theme_path {
- if !path_str.is_empty() {
- let path = std::path::Path::new(path_str);
- if path.exists() {
- if let Some(found) = find_icon_file(path, icon_name) {
- return Some(found);
- }
- }
- }
- }
- let mut search_dirs = Vec::new();
- if let Ok(home) = std::env::var("HOME") {
- search_dirs.push(format!("{}/.local/share/icons", home));
- search_dirs.push(format!("{}/.icons", home));
- }
- search_dirs.push("/usr/share/icons".to_string());
- search_dirs.push("/usr/share/pixmaps".to_string());
-
- let sub_paths = [
- "hicolor/16x16/status",
- "hicolor/22x22/status",
- "hicolor/24x24/status",
- "hicolor/32x32/status",
- "hicolor/48x48/status",
- "hicolor/scalable/status",
- "hicolor/16x16/apps",
- "hicolor/22x22/apps",
- "hicolor/24x24/apps",
- "hicolor/32x32/apps",
- "hicolor/48x48/apps",
- "hicolor/scalable/apps",
- "gnome/16x16/status",
- "gnome/22x22/status",
- "gnome/24x24/status",
- "gnome/32x32/status",
- "gnome/48x48/status",
- "gnome/scalable/status",
- "gnome/16x16/apps",
- "gnome/22x22/apps",
- "gnome/24x24/apps",
- "gnome/32x32/apps",
- "gnome/48x48/apps",
- "gnome/scalable/apps",
- ];
-
- for base in &search_dirs {
- for sub in &sub_paths {
- let path_png = std::path::Path::new(base).join(sub).join(format!("{}.png", icon_name));
- if path_png.exists() && path_png.is_file() {
- return Some(path_png);
- }
- let path_svg = std::path::Path::new(base).join(sub).join(format!("{}.svg", icon_name));
- if path_svg.exists() && path_svg.is_file() {
- return Some(path_svg);
- }
- }
- let base_path = std::path::Path::new(base);
- if base_path.exists() {
- if let Some(found) = find_icon_file(base_path, icon_name) {
- return Some(found);
- }
- }
- }
-
- None
-}
-
-async fn fetch_tray_item(conn: &zbus::Connection, addr: &NotifierAddress) -> Result<TrayItem, zbus::Error> {
- let proxy = StatusNotifierItemProxy::builder(conn)
- .destination(addr.destination.clone())?
- .path(addr.path.clone())?
- .build()
- .await?;
-
- let id = format!("{}/{}", addr.destination, addr.path.trim_start_matches('/'));
- let icon_name = proxy.icon_name().await.ok();
- let icon_theme_path = proxy.icon_theme_path().await.ok();
- let title = proxy.title().await.ok();
- let dbus_id = proxy.id().await.ok();
-
- let mut pixmaps = proxy.icon_pixmap().await.ok().and_then(|v| {
- if v.is_empty() || (v.len() == 1 && v[0].0 == 0 && v[0].1 == 0) {
- None
- } else {
- Some(v.into_iter()
- .map(|(w, h, pixels)| TrayPixmap {
- width: w,
- height: h,
- pixels,
- })
- .collect::<Vec<_>>())
- }
- });
-
- if pixmaps.is_none() {
- if let Some(ref name) = icon_name {
- if let Some(icon_path) = resolve_icon_path(icon_theme_path.as_deref(), name) {
- let ext = icon_path.extension().and_then(|e| e.to_str()).unwrap_or("");
- let pixmap = if ext.eq_ignore_ascii_case("svg") {
- load_svg_as_pixmap(&icon_path)
- } else {
- load_png_as_pixmap(&icon_path)
- };
- if let Some(pixmap) = pixmap {
- pixmaps = Some(vec![pixmap]);
- }
- }
- }
- }
-
- Ok(TrayItem {
- id,
- icon_name,
- icon_theme_path,
- pixmaps,
- title,
- dbus_id,
- })
-}
-
-struct Watcher {
- registered_items: Arc<tokio::sync::Mutex<HashMap<String, NotifierAddress>>>,
- sender: calloop::channel::Sender<CustomEvent>,
- tokio_handle: tokio::runtime::Handle,
-}
-
-#[zbus::interface(name = "org.kde.StatusNotifierWatcher")]
-impl Watcher {
- async fn register_status_notifier_item(
- &self,
- service: &str,
- #[zbus(header)] header: zbus::MessageHeader<'_>,
- #[zbus(connection)] conn: &zbus::Connection,
- ) {
- let sender = header
- .sender()
- .map(|s| s.to_string())
- .unwrap_or_else(|| service.to_string());
-
- if let Ok(addr) = NotifierAddress::from_notifier_service(service, &sender) {
- let mut items = self.registered_items.lock().await;
- let full_address = format!("{}/{}", addr.destination, addr.path.trim_start_matches('/'));
- if !items.contains_key(&full_address) {
- items.insert(full_address.clone(), addr.clone());
-
- let conn = conn.clone();
- let addr_clone = addr.clone();
- let sender_clone = self.sender.clone();
-
- self.tokio_handle.spawn(async move {
- if let Ok(item) = fetch_tray_item(&conn, &addr_clone).await {
- let _ = sender_clone.send(CustomEvent::TrayUpdated(item));
- }
-
- // Listen for updates
- if let Ok(proxy) = StatusNotifierItemProxy::builder(&conn)
- .destination(addr_clone.destination.clone())
- .unwrap()
- .path(addr_clone.path.clone())
- .unwrap()
- .build()
- .await
- {
- let mut new_icon_stream = proxy.receive_new_icon().await.ok();
- let mut new_title_stream = proxy.receive_new_title().await.ok();
- let mut new_status_stream = proxy.receive_new_status().await.ok();
-
- use tokio_stream::StreamExt;
- loop {
- tokio::select! {
- Some(_) = async {
- if let Some(ref mut s) = new_icon_stream {
- s.next().await
- } else {
- std::future::pending().await
- }
- } => {
- if let Ok(item) = fetch_tray_item(&conn, &addr_clone).await {
- let _ = sender_clone.send(CustomEvent::TrayUpdated(item));
- }
- }
- Some(_) = async {
- if let Some(ref mut s) = new_title_stream {
- s.next().await
- } else {
- std::future::pending().await
- }
- } => {
- if let Ok(item) = fetch_tray_item(&conn, &addr_clone).await {
- let _ = sender_clone.send(CustomEvent::TrayUpdated(item));
- }
- }
- Some(_) = async {
- if let Some(ref mut s) = new_status_stream {
- s.next().await
- } else {
- std::future::pending().await
- }
- } => {
- if let Ok(item) = fetch_tray_item(&conn, &addr_clone).await {
- let _ = sender_clone.send(CustomEvent::TrayUpdated(item));
- }
- }
- }
- }
- }
- });
- }
- }
- }
-
- async fn register_status_notifier_host(&self, _service: &str) {}
-
- #[zbus(property)]
- async fn protocol_version(&self) -> i32 {
- 0
- }
-
- #[zbus(property)]
- async fn is_status_notifier_host_registered(&self) -> bool {
- true
- }
-
- #[zbus(property)]
- async fn registered_status_notifier_items(&self) -> Vec<String> {
- let items = self.registered_items.lock().await;
- items.keys().cloned().collect()
- }
-}
-
-struct StatusInterface;
-
-#[zbus::interface(name = "org.clear.StatusInterface")]
-impl StatusInterface {
- async fn notify_attention(&self, app_id: String, title: String) {
- eprintln!("[status-interface] Received NotifyAttention: app_id={}, title={}", app_id, title);
- let title_escaped = title.replace('\'', "'\\''");
- let app_id_escaped = app_id.replace('\'', "'\\''");
- let cmd = format!(
- "notify-send -a '{}' '{} needs attention' 'This window has requested activation.'",
- app_id_escaped, title_escaped
- );
- std::process::Command::new("sh")
- .args(["-c", &cmd])
- .spawn()
- .ok();
- }
-}
-
-async fn spawn_status_tray(sender: calloop::channel::Sender<CustomEvent>) {
- let registered_items = Arc::new(tokio::sync::Mutex::new(HashMap::new()));
- let tokio_handle = tokio::runtime::Handle::current();
-
- let watcher = Watcher {
- registered_items: registered_items.clone(),
- sender: sender.clone(),
- tokio_handle,
- };
-
- let conn = match zbus::ConnectionBuilder::session() {
- Ok(builder) => {
- match builder
- .name("org.kde.StatusNotifierWatcher")
- .unwrap()
- .serve_at("/StatusNotifierWatcher", watcher)
- .unwrap()
- .serve_at("/StatusInterface", StatusInterface)
- .unwrap()
- .build()
- .await
- {
- Ok(c) => c,
- Err(e) => {
- eprintln!("Failed to build D-Bus connection: {:?}", e);
- return;
- }
- }
- }
- Err(e) => {
- eprintln!("Failed to initialize D-Bus session: {:?}", e);
- return;
- }
- };
-
- println!("StatusNotifierWatcher running successfully on D-Bus!");
-
- // Start NameOwnerChanged listener to detect when tray apps disconnect
- let dbus_proxy = match zbus::fdo::DBusProxy::new(&conn).await {
- Ok(p) => p,
- Err(e) => {
- eprintln!("Failed to create DBusProxy: {:?}", e);
- return;
- }
- };
- let mut owner_changes = match dbus_proxy.receive_name_owner_changed().await {
- Ok(oc) => oc,
- Err(e) => {
- eprintln!("Failed to receive name owner changed: {:?}", e);
- return;
- }
- };
-
- let registered_items_clone = registered_items.clone();
- let sender_clone = sender.clone();
-
- tokio::spawn(async move {
- use tokio_stream::StreamExt;
- while let Some(signal) = owner_changes.next().await {
- if let Ok(args) = signal.args() {
- let old = args.old_owner;
- let new = args.new_owner;
- let old_opt: &Option<_> = &*old;
- if let Some(ref old_owner) = old_opt {
- if new.is_none() {
- let mut items = registered_items_clone.lock().await;
- let mut to_remove = Vec::new();
- for (key, addr) in items.iter() {
- if addr.destination == old_owner.as_str() {
- to_remove.push(key.clone());
- }
- }
- for key in to_remove {
- items.remove(&key);
- let _ = sender_clone.send(CustomEvent::TrayRemoved(key));
- }
- }
- }
- }
- }
- });
-
- // Keep the task alive
- loop {
- tokio::time::sleep(std::time::Duration::from_secs(3600)).await;
- }
-}
fn main() {
env_logger::Builder::from_default_env()
@@ -3155,215 +1883,6 @@ fn main() {
cce_ui::engine::run::<StatusApp>();
}
-fn parse_json(content: &str) -> serde_json::Value {
- cce_ui::config::parse_kdl_to_json(content)
-}
-
-pub(crate) fn json_find_key<'a>(val: &'a serde_json::Value, key: &str) -> Option<&'a serde_json::Value> {
- fn find_recursive<'a>(val: &'a serde_json::Value, key: &str) -> Option<&'a serde_json::Value> {
- if let Some(obj) = val.as_object() {
- if let Some(v) = obj.get(key) {
- return Some(v);
- }
- let parts: Vec<&str> = key.split('_').collect();
- for i in 1..parts.len() {
- let (prefix_parts, suffix_parts) = parts.split_at(i);
- let prefix = prefix_parts.join("_");
- let suffix = suffix_parts.join("_");
- if let Some(sub_val) = obj.get(&prefix) {
- if let Some(found) = find_recursive(sub_val, &suffix) {
- return Some(found);
- }
- }
- }
- for (_, sub_val) in obj.iter() {
- if let Some(found) = find_recursive(sub_val, key) {
- return Some(found);
- }
- }
- }
- None
- }
- find_recursive(val, key)
-}
-
-pub(crate) fn get_cached_config() -> serde_json::Value {
- cce_ui::config::cached_config()
-}
-
-pub(crate) fn get_cached_config_content() -> String {
- cce_ui::config::cached_config_content()
-}
-
-fn read_normal_color_from_config() -> Option<[f32; 4]> {
- let content = get_cached_config_content();
- parse_srgb_color_from_key(&content, "status_normal_color")
-}
-
-pub(crate) fn read_disabled_color_from_config() -> Option<[f32; 4]> {
- let content = get_cached_config_content();
- parse_srgb_color_from_key(&content, "disabled_color")
-}
-
-fn parse_srgb_color_from_key(content: &str, key: &str) -> Option<[f32; 4]> {
- let val = parse_json(content);
- if let Some(s) = json_find_key(&val, key).and_then(|v| v.as_str()) {
- if let Some(rgb) = parse_hex(s) {
- let r = rgb[0] as f32 / 255.0;
- let g = rgb[1] as f32 / 255.0;
- let b = rgb[2] as f32 / 255.0;
- return Some([r, g, b, 1.0]);
- }
- }
- None
-}
-
-fn read_status_font_from_config() -> String {
- let val = get_cached_config();
- if let Some(font_str) = json_find_key(&val, "status_font").and_then(|v| v.as_str()) {
- return font_str.to_string();
- }
-
- let font_conf_path = cce_ui::config::config_home().join("fontconfig").join("fonts.conf");
- if let Ok(content) = std::fs::read_to_string(&font_conf_path) {
- if let Some(font) = parse_font_for_alias(&content, "status-interface") {
- return font;
- }
- }
- "sans-serif".to_string()
-}
-
-fn read_status_height_from_config() -> f32 {
- let val = get_cached_config();
- json_find_key(&val, "bar_height").and_then(|v| v.as_f64()).map(|n| n as f32).unwrap_or(28.0)
-}
-
-fn read_status_font_size_from_config() -> f32 {
- let val = get_cached_config();
-
- if let Some(font_str) = json_find_key(&val, "status_font").and_then(|v| v.as_str()) {
- let (_, parsed_size) = cce_ui::layout::parse_font_string(font_str);
- if let Some(size) = parsed_size {
- return size;
- }
- }
-
- json_find_key(&val, "status_font_size").and_then(|v| v.as_f64()).map(|n| n as f32).unwrap_or(11.0)
-}
-
-fn read_status_padding_from_config() -> f32 {
- let val = get_cached_config();
- json_find_key(&val, "status_padding").and_then(|v| v.as_f64()).map(|n| n as f32).unwrap_or(8.0)
-}
-
-fn read_status_module_spacing_from_config() -> f32 {
- let val = get_cached_config();
- json_find_key(&val, "status_module_spacing").and_then(|v| v.as_f64()).map(|n| n as f32).unwrap_or(8.0)
-}
-
-fn read_separator_color_from_config() -> Option<[f32; 4]> {
- let content = get_cached_config_content();
- parse_color_from_key(&content, "status_separator_color")
-}
-
-
-
-fn parse_font_for_alias(content: &str, alias: &str) -> Option<String> {
- let lines: Vec<&str> = content.lines().collect();
- for i in 0..lines.len() {
- let line = lines[i].trim();
- if line.contains("<test") && line.contains("name=\"family\"") && line.contains(&format!("<string>{}</string>", alias)) {
- for j in (i + 1)..(i + 6).min(lines.len()) {
- let next_line = lines[j].trim();
- if next_line.contains("<edit") {
- for k in (j + 1)..(j + 6).min(lines.len()) {
- let str_line = lines[k].trim();
- if str_line.contains("<string>") && str_line.contains("</string>") {
- if let Some(start) = str_line.find("<string>") {
- if let Some(end) = str_line.find("</string>") {
- return Some(str_line[start + 8..end].to_string());
- }
- }
- }
- }
- }
- }
- }
- }
- None
-}
-
-fn read_bg_color_from_config() -> Option<[f32; 4]> {
- let content = get_cached_config_content();
- parse_color_from_key(&content, "background_color")
- .or_else(|| parse_color_from_key(&content, "low_color"))
- .or_else(|| parse_color_from_key(&content, "desktop_gap_color"))
-}
-
-fn parse_color_from_key(content: &str, key: &str) -> Option<[f32; 4]> {
- let val = parse_json(content);
- if let Some(s) = json_find_key(&val, key).and_then(|v| v.as_str()) {
- if let Some(rgba) = parse_hex_rgba(s) {
- let r = (rgba[0] as f32 / 255.0).powf(2.2);
- let g = (rgba[1] as f32 / 255.0).powf(2.2);
- let b = (rgba[2] as f32 / 255.0).powf(2.2);
- let a = rgba[3] as f32 / 255.0;
- return Some([r, g, b, a]);
- }
- }
- None
-}
-
-fn parse_hex(s: &str) -> Option<[u8; 3]> {
- cce_ui::color::parse_hex_bytes(s).map(|[r, g, b, _]| [r, g, b])
-}
-fn parse_hex_rgba(s: &str) -> Option<[u8; 4]> {
- cce_ui::color::parse_hex_bytes(s)
-}
-
-fn parse_rgba_color_from_key(content: &str, key: &str) -> Option<[f32; 4]> {
- let val = parse_json(content);
- if let Some(s) = json_find_key(&val, key).and_then(|v| v.as_str()) {
- if let Some(rgba) = parse_hex_rgba(s) {
- let r = (rgba[0] as f32 / 255.0).powf(2.2);
- let g = (rgba[1] as f32 / 255.0).powf(2.2);
- let b = (rgba[2] as f32 / 255.0).powf(2.2);
- let a = rgba[3] as f32 / 255.0;
- return Some([r, g, b, a]);
- }
- }
- None
-}
-
-fn read_status_background_blur_from_config() -> f32 {
- let val = get_cached_config();
- json_find_key(&val, "status_background_blur").and_then(|v| v.as_f64()).map(|n| n as f32).unwrap_or(0.0)
-}
-
-fn read_status_box_background_color_from_config() -> Option<[f32; 4]> {
- let content = get_cached_config_content();
- let mut color = parse_rgba_color_from_key(&content, "status_background_color")
- .unwrap_or_else(|| {
- let r = (0x15 as f32 / 255.0).powf(2.2);
- let g = (0x15 as f32 / 255.0).powf(2.2);
- let b = (0x20 as f32 / 255.0).powf(2.2);
- [r, g, b, 0.9]
- });
-
- let blur = read_status_background_blur_from_config();
-
- // Scale RGB by (1.0 - blur) to apply tint factor while keeping alpha as full opacity for the blur shader
- color[0] *= 1.0 - blur;
- color[1] *= 1.0 - blur;
- color[2] *= 1.0 - blur;
-
- Some(color)
-}
-
-fn read_status_box_corner_radius_from_config() -> f32 {
- let val = get_cached_config();
- json_find_key(&val, "status_box_corner_radius").and_then(|v| v.as_f64()).map(|n| n as f32).unwrap_or(4.0)
-}
#[cfg(test)]
mod tests {
@@ -3651,22 +2170,3 @@ style {
}
}
-fn get_cce_cmd() -> String {
- if let Ok(home) = std::env::var("HOME") {
- let path = format!("{}/.local/bin/cce", home);
- if std::path::Path::new(&path).exists() {
- return path;
- }
- }
- "cce".to_string()
-}
-
-fn get_ccectl_cmd() -> String {
- if let Ok(home) = std::env::var("HOME") {
- let path = format!("{}/.local/bin/ccectl", home);
- if std::path::Path::new(&path).exists() {
- return path;
- }
- }
- "ccectl".to_string()
-}
diff --git a/src/stats.rs b/src/stats.rs
new file mode 100644
index 0000000..9531be3
--- /dev/null
+++ b/src/stats.rs
@@ -0,0 +1,209 @@
+//! System statistics: /proc, /sys, and pactl readers plus the polling task
+//! that feeds `SystemStats` updates to the bar.
+
+use crate::{CustomEvent, SystemStats};
+
+pub(crate) fn read_cpu_ticks() -> Option<(u64, u64)> {
+ let stat = std::fs::read_to_string("/proc/stat").ok()?;
+ let first_line = stat.lines().next()?;
+ if first_line.starts_with("cpu ") {
+ let parts: Vec<u64> = first_line
+ .split_whitespace()
+ .skip(1)
+ .filter_map(|s| s.parse::<u64>().ok())
+ .collect();
+ if parts.len() >= 4 {
+ let idle = parts[3];
+ let total: u64 = parts.iter().sum();
+ return Some((total, idle));
+ }
+ }
+ None
+}
+
+pub(crate) fn read_memory_usage() -> Option<String> {
+ let meminfo = std::fs::read_to_string("/proc/meminfo").ok()?;
+ let mut total = 0.0;
+ let mut free = 0.0;
+ let mut buffers = 0.0;
+ let mut cached = 0.0;
+ for line in meminfo.lines() {
+ if line.starts_with("MemTotal:") {
+ total = line.split_whitespace().nth(1)?.parse::<f32>().ok()? / 1024.0 / 1024.0;
+ } else if line.starts_with("MemFree:") {
+ free = line.split_whitespace().nth(1)?.parse::<f32>().ok()? / 1024.0 / 1024.0;
+ } else if line.starts_with("Buffers:") {
+ buffers = line.split_whitespace().nth(1)?.parse::<f32>().ok()? / 1024.0 / 1024.0;
+ } else if line.starts_with("Cached:") {
+ cached = line.split_whitespace().nth(1)?.parse::<f32>().ok()? / 1024.0 / 1024.0;
+ }
+ }
+ if total > 0.0 {
+ let used = total - free - buffers - cached;
+ Some(format!("Mem {:.1}/{:.1}G", used, total))
+ } else {
+ None
+ }
+}
+
+pub(crate) fn read_battery_details() -> Option<(String, i32, bool)> {
+ for bat in &["BAT0", "BAT1"] {
+ let cap_path = format!("/sys/class/power_supply/{}/capacity", bat);
+ let status_path = format!("/sys/class/power_supply/{}/status", bat);
+ if let Ok(cap_str) = std::fs::read_to_string(&cap_path) {
+ let cap_trimmed = cap_str.trim();
+ let cap = cap_trimmed.parse::<i32>().unwrap_or(0);
+ let status = std::fs::read_to_string(&status_path).unwrap_or_default();
+ let is_charging = status.trim() == "Charging";
+ let charge_symbol = if is_charging { "⚡" } else { "Bat" };
+ return Some((format!("{} {}%", charge_symbol, cap_trimmed), cap, is_charging));
+ }
+ }
+ None
+}
+
+pub(crate) fn read_brightness() -> Option<String> {
+ let dir = std::fs::read_dir("/sys/class/backlight").ok()?;
+ for entry in dir {
+ if let Ok(entry) = entry {
+ let path = entry.path();
+ let cur_path = path.join("brightness");
+ let max_path = path.join("max_brightness");
+ if cur_path.exists() && max_path.exists() {
+ let cur_str = std::fs::read_to_string(cur_path).ok()?;
+ let max_str = std::fs::read_to_string(max_path).ok()?;
+ let cur = cur_str.trim().parse::<f32>().ok()?;
+ let max = max_str.trim().parse::<f32>().ok()?;
+ if max > 0.0 {
+ let pct = (cur / max * 100.0).round() as i32;
+ return Some(format!("Bri {}%", pct));
+ }
+ }
+ }
+ }
+ None
+}
+
+pub(crate) async fn read_volume() -> Option<(String, bool)> {
+ let vol_output = match tokio::process::Command::new("pactl")
+ .args(["get-sink-volume", "@DEFAULT_SINK@"])
+ .output()
+ .await
+ {
+ Ok(o) => o,
+ Err(e) => {
+ eprintln!("[read_volume] failed to spawn pactl: {:?}", e);
+ return None;
+ }
+ };
+ if !vol_output.status.success() {
+ eprintln!("[read_volume] pactl get-sink-volume exited with error: {:?}", String::from_utf8_lossy(&vol_output.stderr));
+ return None;
+ }
+ let vol_str = String::from_utf8_lossy(&vol_output.stdout);
+
+ let mute_output = match tokio::process::Command::new("pactl")
+ .args(["get-sink-mute", "@DEFAULT_SINK@"])
+ .output()
+ .await
+ {
+ Ok(o) => o,
+ Err(e) => {
+ eprintln!("[read_volume] failed to spawn pactl mute: {:?}", e);
+ return None;
+ }
+ };
+ if !mute_output.status.success() {
+ eprintln!("[read_volume] pactl get-sink-mute exited with error: {:?}", String::from_utf8_lossy(&mute_output.stderr));
+ return None;
+ }
+ let mute_str = String::from_utf8_lossy(&mute_output.stdout);
+ let muted = mute_str.contains("yes");
+
+ let mut pct = None;
+ if let Some(pos) = vol_str.find('%') {
+ let start = vol_str[..pos].rfind(|c: char| !c.is_ascii_digit()).map(|i| i + 1).unwrap_or(0);
+ if let Ok(num) = vol_str[start..pos].parse::<u32>() {
+ pct = Some(num);
+ }
+ }
+
+ match (muted, pct) {
+ (true, Some(p)) => Some((format!("Vol {}%", p), true)),
+ (true, None) => Some(("Vol Muted".to_string(), true)),
+ (false, Some(p)) => Some((format!("Vol {}%", p), false)),
+ (false, None) => Some(("Vol N/A".to_string(), false)),
+ }
+}
+
+pub(crate) fn get_initial_stats() -> SystemStats {
+ let clock = chrono::Local::now().format("%A, %B %d, %Y %I:%M %p").to_string();
+ let memory = read_memory_usage().unwrap_or_else(|| "Mem N/A".to_string());
+
+ let (battery_str, battery_capacity, battery_charging) = if let Some((s, cap, chg)) = read_battery_details() {
+ (s, cap, chg)
+ } else {
+ ("".to_string(), 0, false)
+ };
+ let (volume, volume_muted) = pollster::block_on(read_volume()).unwrap_or_else(|| ("".to_string(), false));
+ let brightness = read_brightness().unwrap_or_default();
+
+ SystemStats {
+ clock,
+ memory,
+ cpu: "Cpu 0.0%".to_string(),
+ battery: battery_str,
+ battery_capacity,
+ battery_charging,
+ volume,
+ volume_muted,
+ brightness,
+ }
+}
+
+pub(crate) async fn spawn_system_stats(sender: calloop::channel::Sender<CustomEvent>) {
+ eprintln!("[spawn_system_stats] Starting system stats loop!");
+ let mut last_cpu = read_cpu_ticks().unwrap_or((0, 0));
+ loop {
+ eprintln!("[spawn_system_stats] loop iteration start");
+ let clock = chrono::Local::now().format("%A, %B %d, %Y %I:%M %p").to_string();
+ let memory = read_memory_usage().unwrap_or_else(|| "Mem N/A".to_string());
+
+ let cpu_str = if let Some(current_cpu) = read_cpu_ticks() {
+ let total_diff = current_cpu.0 - last_cpu.0;
+ let idle_diff = current_cpu.1 - last_cpu.1;
+ last_cpu = current_cpu;
+ if total_diff > 0 {
+ let usage = 100.0 - (idle_diff as f32 * 100.0 / total_diff as f32);
+ format!("Cpu {:.1}%", usage)
+ } else {
+ "Cpu 0.0%".to_string()
+ }
+ } else {
+ "Cpu N/A".to_string()
+ };
+
+ let (battery_str, battery_capacity, battery_charging) = if let Some((s, cap, chg)) = read_battery_details() {
+ (s, cap, chg)
+ } else {
+ ("".to_string(), 0, false)
+ };
+ let (volume, volume_muted) = read_volume().await.unwrap_or_else(|| ("".to_string(), false));
+ let brightness = read_brightness().unwrap_or_default();
+
+ let stats = SystemStats {
+ clock,
+ memory,
+ cpu: cpu_str,
+ battery: battery_str,
+ battery_capacity,
+ battery_charging,
+ volume,
+ volume_muted,
+ brightness,
+ };
+ eprintln!("[spawn_system_stats] stats: {:?}", stats);
+ let _ = sender.send(CustomEvent::SystemStatsUpdated(stats));
+ tokio::time::sleep(std::time::Duration::from_secs(1)).await;
+ }
+}
diff --git a/src/tray.rs b/src/tray.rs
new file mode 100644
index 0000000..2e05133
--- /dev/null
+++ b/src/tray.rs
@@ -0,0 +1,543 @@
+//! StatusNotifierItem tray host: the SNI watcher/host D-Bus interfaces,
+//! item fetching, and icon loading.
+
+use std::collections::HashMap;
+use std::sync::Arc;
+
+use crate::{CustomEvent, TrayItem, TrayPixmap};
+
+#[derive(Debug, Clone)]
+pub struct NotifierAddress {
+ pub destination: String,
+ pub path: String,
+}
+
+impl NotifierAddress {
+ pub fn from_notifier_service(service: &str, sender: &str) -> Result<Self, String> {
+ if service.starts_with('/') {
+ Ok(NotifierAddress {
+ destination: sender.to_string(),
+ path: service.to_string(),
+ })
+ } else if let Some((destination, path)) = service.split_once('/') {
+ Ok(NotifierAddress {
+ destination: destination.to_string(),
+ path: format!("/{}", path),
+ })
+ } else if service.contains(':') {
+ let split = service.split(':').collect::<Vec<&str>>();
+ Ok(NotifierAddress {
+ destination: format!(":{}", split[1]),
+ path: "/StatusNotifierItem".to_string(),
+ })
+ } else {
+ Ok(NotifierAddress {
+ destination: service.to_string(),
+ path: "/StatusNotifierItem".to_string(),
+ })
+ }
+ }
+}
+
+#[zbus::proxy(
+ interface = "org.kde.StatusNotifierItem",
+ default_path = "/StatusNotifierItem"
+)]
+pub(crate) trait StatusNotifierItem {
+ #[zbus(property)]
+ fn id(&self) -> zbus::Result<String>;
+
+ #[zbus(property)]
+ fn category(&self) -> zbus::Result<String>;
+
+ #[zbus(property)]
+ fn status(&self) -> zbus::Result<String>;
+
+ #[zbus(property)]
+ fn title(&self) -> zbus::Result<String>;
+
+ #[zbus(property)]
+ fn icon_name(&self) -> zbus::Result<String>;
+
+ #[zbus(property)]
+ fn icon_theme_path(&self) -> zbus::Result<String>;
+
+ #[zbus(property)]
+ fn icon_pixmap(&self) -> zbus::Result<Vec<(i32, i32, Vec<u8>)>>;
+
+ #[zbus(signal)]
+ fn new_icon(&self) -> zbus::Result<()>;
+ #[zbus(signal)]
+ fn new_title(&self) -> zbus::Result<()>;
+
+ #[zbus(signal)]
+ fn new_status(&self) -> zbus::Result<()>;
+
+ fn activate(&self, x: i32, y: i32) -> zbus::Result<()>;
+ fn context_menu(&self, x: i32, y: i32) -> zbus::Result<()>;
+
+ #[zbus(property)]
+ fn item_is_menu(&self) -> zbus::Result<bool>;
+
+ #[zbus(property)]
+ fn menu(&self) -> zbus::Result<zbus::zvariant::OwnedObjectPath>;
+}
+
+pub(crate) fn find_icon_file(dir: &std::path::Path, icon_name: &str) -> Option<std::path::PathBuf> {
+ if let Ok(entries) = std::fs::read_dir(dir) {
+ for entry in entries.filter_map(Result::ok) {
+ if let Ok(file_type) = entry.file_type() {
+ let path = entry.path();
+ if file_type.is_dir() {
+ if !file_type.is_symlink() {
+ if let Some(found) = find_icon_file(&path, icon_name) {
+ return Some(found);
+ }
+ }
+ } else if file_type.is_file() {
+ if let Some(file_name) = path.file_name().and_then(|f| f.to_str()) {
+ if file_name == format!("{}.png", icon_name) || file_name == format!("{}.svg", icon_name) {
+ return Some(path);
+ }
+ }
+ }
+ }
+ }
+ }
+ None
+}
+
+pub(crate) fn load_png_as_pixmap(path: &std::path::Path) -> Option<TrayPixmap> {
+ let file = std::fs::File::open(path).ok()?;
+ let mut decoder = png::Decoder::new(file);
+ decoder.set_transformations(png::Transformations::EXPAND);
+ let mut reader = decoder.read_info().ok()?;
+ let mut buf = vec![0; reader.output_buffer_size()];
+ let info = reader.next_frame(&mut buf).ok()?;
+
+ let width = info.width as i32;
+ let height = info.height as i32;
+ let mut argb_pixels = Vec::with_capacity((width * height * 4) as usize);
+
+ let actual_bytes = &buf[..info.buffer_size()];
+ match info.color_type {
+ png::ColorType::Rgba => {
+ for chunk in actual_bytes.chunks_exact(4) {
+ argb_pixels.push(chunk[3]); // A
+ argb_pixels.push(chunk[0]); // R
+ argb_pixels.push(chunk[1]); // G
+ argb_pixels.push(chunk[2]); // B
+ }
+ }
+ png::ColorType::Rgb => {
+ for chunk in actual_bytes.chunks_exact(3) {
+ argb_pixels.push(255); // A
+ argb_pixels.push(chunk[0]); // R
+ argb_pixels.push(chunk[1]); // G
+ argb_pixels.push(chunk[2]); // B
+ }
+ }
+ png::ColorType::Grayscale => {
+ for &g in actual_bytes {
+ argb_pixels.push(255); // A
+ argb_pixels.push(g); // R
+ argb_pixels.push(g); // G
+ argb_pixels.push(g); // B
+ }
+ }
+ png::ColorType::GrayscaleAlpha => {
+ for chunk in actual_bytes.chunks_exact(2) {
+ argb_pixels.push(chunk[1]); // A
+ argb_pixels.push(chunk[0]); // R
+ argb_pixels.push(chunk[0]); // G
+ argb_pixels.push(chunk[0]); // B
+ }
+ }
+ _ => return None,
+ }
+
+ Some(TrayPixmap {
+ width,
+ height,
+ pixels: argb_pixels,
+ })
+}
+
+pub(crate) fn load_svg_as_pixmap(path: &std::path::Path) -> Option<TrayPixmap> {
+ let svg_data = std::fs::read(path).ok()?;
+ let opt = resvg::usvg::Options::default();
+ let fontdb = resvg::usvg::fontdb::Database::new();
+ let tree = resvg::usvg::Tree::from_data(&svg_data, &opt, &fontdb).ok()?;
+
+ let target_w = 48;
+ let target_h = 48;
+ let mut pixmap = resvg::tiny_skia::Pixmap::new(target_w, target_h)?;
+
+ let orig_w = tree.size().width();
+ let orig_h = tree.size().height();
+ let sx = target_w as f32 / orig_w;
+ let sy = target_h as f32 / orig_h;
+ let transform = resvg::tiny_skia::Transform::from_scale(sx, sy);
+
+ resvg::render(&tree, transform, &mut pixmap.as_mut());
+
+ let raw_pixels = pixmap.data();
+ let mut argb_pixels = Vec::with_capacity((target_w * target_h * 4) as usize);
+ for chunk in raw_pixels.chunks_exact(4) {
+ argb_pixels.push(chunk[3]); // A
+ argb_pixels.push(chunk[0]); // R
+ argb_pixels.push(chunk[1]); // G
+ argb_pixels.push(chunk[2]); // B
+ }
+
+ Some(TrayPixmap {
+ width: target_w as i32,
+ height: target_h as i32,
+ pixels: argb_pixels,
+ })
+}
+
+pub(crate) fn resolve_icon_path(theme_path: Option<&str>, icon_name: &str) -> Option<std::path::PathBuf> {
+ if icon_name.is_empty() {
+ return None;
+ }
+
+ let icon_name = if icon_name == "dropbox" { "dropboxstatus-idle" } else { icon_name };
+
+ if let Some(path_str) = theme_path {
+ if !path_str.is_empty() {
+ let path = std::path::Path::new(path_str);
+ if path.exists() {
+ if let Some(found) = find_icon_file(path, icon_name) {
+ return Some(found);
+ }
+ }
+ }
+ }
+
+ let mut search_dirs = Vec::new();
+ if let Ok(home) = std::env::var("HOME") {
+ search_dirs.push(format!("{}/.local/share/icons", home));
+ search_dirs.push(format!("{}/.icons", home));
+ }
+ search_dirs.push("/usr/share/icons".to_string());
+ search_dirs.push("/usr/share/pixmaps".to_string());
+
+ let sub_paths = [
+ "hicolor/16x16/status",
+ "hicolor/22x22/status",
+ "hicolor/24x24/status",
+ "hicolor/32x32/status",
+ "hicolor/48x48/status",
+ "hicolor/scalable/status",
+ "hicolor/16x16/apps",
+ "hicolor/22x22/apps",
+ "hicolor/24x24/apps",
+ "hicolor/32x32/apps",
+ "hicolor/48x48/apps",
+ "hicolor/scalable/apps",
+ "gnome/16x16/status",
+ "gnome/22x22/status",
+ "gnome/24x24/status",
+ "gnome/32x32/status",
+ "gnome/48x48/status",
+ "gnome/scalable/status",
+ "gnome/16x16/apps",
+ "gnome/22x22/apps",
+ "gnome/24x24/apps",
+ "gnome/32x32/apps",
+ "gnome/48x48/apps",
+ "gnome/scalable/apps",
+ ];
+
+ for base in &search_dirs {
+ for sub in &sub_paths {
+ let path_png = std::path::Path::new(base).join(sub).join(format!("{}.png", icon_name));
+ if path_png.exists() && path_png.is_file() {
+ return Some(path_png);
+ }
+ let path_svg = std::path::Path::new(base).join(sub).join(format!("{}.svg", icon_name));
+ if path_svg.exists() && path_svg.is_file() {
+ return Some(path_svg);
+ }
+ }
+ let base_path = std::path::Path::new(base);
+ if base_path.exists() {
+ if let Some(found) = find_icon_file(base_path, icon_name) {
+ return Some(found);
+ }
+ }
+ }
+
+ None
+}
+
+pub(crate) async fn fetch_tray_item(conn: &zbus::Connection, addr: &NotifierAddress) -> Result<TrayItem, zbus::Error> {
+ let proxy = StatusNotifierItemProxy::builder(conn)
+ .destination(addr.destination.clone())?
+ .path(addr.path.clone())?
+ .build()
+ .await?;
+
+ let id = format!("{}/{}", addr.destination, addr.path.trim_start_matches('/'));
+ let icon_name = proxy.icon_name().await.ok();
+ let icon_theme_path = proxy.icon_theme_path().await.ok();
+ let title = proxy.title().await.ok();
+ let dbus_id = proxy.id().await.ok();
+
+ let mut pixmaps = proxy.icon_pixmap().await.ok().and_then(|v| {
+ if v.is_empty() || (v.len() == 1 && v[0].0 == 0 && v[0].1 == 0) {
+ None
+ } else {
+ Some(v.into_iter()
+ .map(|(w, h, pixels)| TrayPixmap {
+ width: w,
+ height: h,
+ pixels,
+ })
+ .collect::<Vec<_>>())
+ }
+ });
+
+ if pixmaps.is_none() {
+ if let Some(ref name) = icon_name {
+ if let Some(icon_path) = resolve_icon_path(icon_theme_path.as_deref(), name) {
+ let ext = icon_path.extension().and_then(|e| e.to_str()).unwrap_or("");
+ let pixmap = if ext.eq_ignore_ascii_case("svg") {
+ load_svg_as_pixmap(&icon_path)
+ } else {
+ load_png_as_pixmap(&icon_path)
+ };
+ if let Some(pixmap) = pixmap {
+ pixmaps = Some(vec![pixmap]);
+ }
+ }
+ }
+ }
+
+ Ok(TrayItem {
+ id,
+ icon_name,
+ icon_theme_path,
+ pixmaps,
+ title,
+ dbus_id,
+ })
+}
+
+struct Watcher {
+ registered_items: Arc<tokio::sync::Mutex<HashMap<String, NotifierAddress>>>,
+ sender: calloop::channel::Sender<CustomEvent>,
+ tokio_handle: tokio::runtime::Handle,
+}
+
+#[zbus::interface(name = "org.kde.StatusNotifierWatcher")]
+impl Watcher {
+ async fn register_status_notifier_item(
+ &self,
+ service: &str,
+ #[zbus(header)] header: zbus::MessageHeader<'_>,
+ #[zbus(connection)] conn: &zbus::Connection,
+ ) {
+ let sender = header
+ .sender()
+ .map(|s| s.to_string())
+ .unwrap_or_else(|| service.to_string());
+
+ if let Ok(addr) = NotifierAddress::from_notifier_service(service, &sender) {
+ let mut items = self.registered_items.lock().await;
+ let full_address = format!("{}/{}", addr.destination, addr.path.trim_start_matches('/'));
+ if !items.contains_key(&full_address) {
+ items.insert(full_address.clone(), addr.clone());
+
+ let conn = conn.clone();
+ let addr_clone = addr.clone();
+ let sender_clone = self.sender.clone();
+
+ self.tokio_handle.spawn(async move {
+ if let Ok(item) = fetch_tray_item(&conn, &addr_clone).await {
+ let _ = sender_clone.send(CustomEvent::TrayUpdated(item));
+ }
+
+ // Listen for updates
+ if let Ok(proxy) = StatusNotifierItemProxy::builder(&conn)
+ .destination(addr_clone.destination.clone())
+ .unwrap()
+ .path(addr_clone.path.clone())
+ .unwrap()
+ .build()
+ .await
+ {
+ let mut new_icon_stream = proxy.receive_new_icon().await.ok();
+ let mut new_title_stream = proxy.receive_new_title().await.ok();
+ let mut new_status_stream = proxy.receive_new_status().await.ok();
+
+ use tokio_stream::StreamExt;
+ loop {
+ tokio::select! {
+ Some(_) = async {
+ if let Some(ref mut s) = new_icon_stream {
+ s.next().await
+ } else {
+ std::future::pending().await
+ }
+ } => {
+ if let Ok(item) = fetch_tray_item(&conn, &addr_clone).await {
+ let _ = sender_clone.send(CustomEvent::TrayUpdated(item));
+ }
+ }
+ Some(_) = async {
+ if let Some(ref mut s) = new_title_stream {
+ s.next().await
+ } else {
+ std::future::pending().await
+ }
+ } => {
+ if let Ok(item) = fetch_tray_item(&conn, &addr_clone).await {
+ let _ = sender_clone.send(CustomEvent::TrayUpdated(item));
+ }
+ }
+ Some(_) = async {
+ if let Some(ref mut s) = new_status_stream {
+ s.next().await
+ } else {
+ std::future::pending().await
+ }
+ } => {
+ if let Ok(item) = fetch_tray_item(&conn, &addr_clone).await {
+ let _ = sender_clone.send(CustomEvent::TrayUpdated(item));
+ }
+ }
+ }
+ }
+ }
+ });
+ }
+ }
+ }
+
+ async fn register_status_notifier_host(&self, _service: &str) {}
+
+ #[zbus(property)]
+ async fn protocol_version(&self) -> i32 {
+ 0
+ }
+
+ #[zbus(property)]
+ async fn is_status_notifier_host_registered(&self) -> bool {
+ true
+ }
+
+ #[zbus(property)]
+ async fn registered_status_notifier_items(&self) -> Vec<String> {
+ let items = self.registered_items.lock().await;
+ items.keys().cloned().collect()
+ }
+}
+
+struct StatusInterface;
+
+#[zbus::interface(name = "org.clear.StatusInterface")]
+impl StatusInterface {
+ async fn notify_attention(&self, app_id: String, title: String) {
+ eprintln!("[status-interface] Received NotifyAttention: app_id={}, title={}", app_id, title);
+ let title_escaped = title.replace('\'', "'\\''");
+ let app_id_escaped = app_id.replace('\'', "'\\''");
+ let cmd = format!(
+ "notify-send -a '{}' '{} needs attention' 'This window has requested activation.'",
+ app_id_escaped, title_escaped
+ );
+ std::process::Command::new("sh")
+ .args(["-c", &cmd])
+ .spawn()
+ .ok();
+ }
+}
+
+pub(crate) async fn spawn_status_tray(sender: calloop::channel::Sender<CustomEvent>) {
+ let registered_items = Arc::new(tokio::sync::Mutex::new(HashMap::new()));
+ let tokio_handle = tokio::runtime::Handle::current();
+
+ let watcher = Watcher {
+ registered_items: registered_items.clone(),
+ sender: sender.clone(),
+ tokio_handle,
+ };
+
+ let conn = match zbus::ConnectionBuilder::session() {
+ Ok(builder) => {
+ match builder
+ .name("org.kde.StatusNotifierWatcher")
+ .unwrap()
+ .serve_at("/StatusNotifierWatcher", watcher)
+ .unwrap()
+ .serve_at("/StatusInterface", StatusInterface)
+ .unwrap()
+ .build()
+ .await
+ {
+ Ok(c) => c,
+ Err(e) => {
+ eprintln!("Failed to build D-Bus connection: {:?}", e);
+ return;
+ }
+ }
+ }
+ Err(e) => {
+ eprintln!("Failed to initialize D-Bus session: {:?}", e);
+ return;
+ }
+ };
+
+ println!("StatusNotifierWatcher running successfully on D-Bus!");
+
+ // Start NameOwnerChanged listener to detect when tray apps disconnect
+ let dbus_proxy = match zbus::fdo::DBusProxy::new(&conn).await {
+ Ok(p) => p,
+ Err(e) => {
+ eprintln!("Failed to create DBusProxy: {:?}", e);
+ return;
+ }
+ };
+ let mut owner_changes = match dbus_proxy.receive_name_owner_changed().await {
+ Ok(oc) => oc,
+ Err(e) => {
+ eprintln!("Failed to receive name owner changed: {:?}", e);
+ return;
+ }
+ };
+
+ let registered_items_clone = registered_items.clone();
+ let sender_clone = sender.clone();
+
+ tokio::spawn(async move {
+ use tokio_stream::StreamExt;
+ while let Some(signal) = owner_changes.next().await {
+ if let Ok(args) = signal.args() {
+ let old = args.old_owner;
+ let new = args.new_owner;
+ let old_opt: &Option<_> = &*old;
+ if let Some(ref old_owner) = old_opt {
+ if new.is_none() {
+ let mut items = registered_items_clone.lock().await;
+ let mut to_remove = Vec::new();
+ for (key, addr) in items.iter() {
+ if addr.destination == old_owner.as_str() {
+ to_remove.push(key.clone());
+ }
+ }
+ for key in to_remove {
+ items.remove(&key);
+ let _ = sender_clone.send(CustomEvent::TrayRemoved(key));
+ }
+ }
+ }
+ }
+ }
+ });
+
+ // Keep the task alive
+ loop {
+ tokio::time::sleep(std::time::Duration::from_secs(3600)).await;
+ }
+}