From 6019c181a156897e9a5d01fd2f707823dc20dde6 Mon Sep 17 00:00:00 2001 From: Milosz Blizniak Date: Fri, 4 Jul 2025 13:56:16 +0200 Subject: [PATCH 1/5] refactor(rs): use serde struct deserialization --- rust/Cargo.toml.in | 3 +- rust/src/backend/i3/backend.rs | 7 +- rust/src/backend/i3/compass.rs | 119 +++++++++------------------------ rust/src/backend/i3/json.rs | 57 ++++++++++++++++ rust/src/backend/i3/mod.rs | 1 + rust/src/types/rect.rs | 9 +++ 6 files changed, 104 insertions(+), 92 deletions(-) create mode 100644 rust/src/backend/i3/json.rs diff --git a/rust/Cargo.toml.in b/rust/Cargo.toml.in index 18dff41..7057a77 100644 --- a/rust/Cargo.toml.in +++ b/rust/Cargo.toml.in @@ -5,12 +5,13 @@ edition = "2021" [dependencies] libwmctl = { version = "0.0.51", optional = true } +serde = { version = "1.0", optional = true, features = ["derive"] } serde_json = { version = "1.0", optional = true } x11rb = { version = "0.13", optional = true } xcb = { version = "1", optional = true } [features] default = ["i3", "xcb"] -i3 = ["dep:serde_json"] +i3 = ["dep:serde_json", "dep:serde"] xcb = ["dep:xcb"] wmctl = ["dep:libwmctl", "dep:x11rb"] diff --git a/rust/src/backend/i3/backend.rs b/rust/src/backend/i3/backend.rs index 48d3a11..c220e58 100644 --- a/rust/src/backend/i3/backend.rs +++ b/rust/src/backend/i3/backend.rs @@ -4,13 +4,14 @@ use crate::logging; use crate::types::Windows; use super::client::{Client, Request}; use super::compass; +use super::json::Node; use serde_json as json; use std::process; pub struct Backend { client: Client, - root: json::Value, + root: Node, } impl Backend { @@ -26,8 +27,10 @@ impl Backend { .expect_log("Failed to get i3 tree JSON"); // Parse the i3 tree to get the current workspace and window information - let root = json::from_str::(&root_string) + let root_value = json::from_str::(&root_string) .expect_log("Failed to parse i3 tree JSON"); + let root: Node = json::from_value(root_value) + .expect_log("Failed to convert i3 tree JSON to Node"); Self { client, root, diff --git a/rust/src/backend/i3/compass.rs b/rust/src/backend/i3/compass.rs index 522441b..5e3831c 100644 --- a/rust/src/backend/i3/compass.rs +++ b/rust/src/backend/i3/compass.rs @@ -1,7 +1,6 @@ -use serde_json::Value; use crate::logging; -use crate::types::{Rect, Window, Windows}; - +use crate::types::{Window, Windows}; +use crate::backend::i3::json::Node; /// This enum represents the layout type of a node in a window manager's tree structure. #[derive(Debug, Clone, PartialEq, Eq)] @@ -15,14 +14,10 @@ enum Layout { /// Returns a collection of visible nodes in the provided JSON node. /// This function traverses the node structure and collects nodes that are not invisible /// or end nodes. -pub fn visible_nodes<'a>(node: &'a Value) -> Vec<&'a Value> { - logging::debug!("V Node iterated id:{} type:{} layout:{}, name:{}", - node.get("id").and_then(|v| v.as_u64()).unwrap_or(0), - node.get("type").and_then(|v| v.as_str()).unwrap_or("null"), - node.get("layout").and_then(|v| v.as_str()).unwrap_or("null"), - node.get("name").and_then(|v| v.as_str()).unwrap_or("null")); - if is_end_node(node) { - if is_invisible_node(node) { +pub fn visible_nodes<'a>(node: &'a Node) -> Vec<&'a Node> { + logging::debug!("V Node iterated {}", node.to_string()); + if node.is_leaf() { + if node.is_invisible() { return vec![]; } return vec![node]; @@ -31,17 +26,15 @@ pub fn visible_nodes<'a>(node: &'a Value) -> Vec<&'a Value> { let layout = get_layout(node); match layout { Layout::AllVisible => { - let mut nodes: Vec<&'a Value> = vec![]; - if let Some(floating_nodes) = node.get("floating_nodes").unwrap_or(&Value::Null).as_array() { - nodes.extend(floating_nodes.iter()); - } - node.get("nodes").unwrap().as_array().unwrap().iter().for_each(|subnode| { + let mut nodes: Vec<&'a Node> = vec![]; + nodes.extend(node.floating_nodes.iter()); + node.nodes.iter().for_each(|subnode| { nodes.extend(visible_nodes(subnode)); }); return nodes; } Layout::OneVisible => { - let mut nodes: Vec<&'a Value> = vec![]; + let mut nodes: Vec<&'a Node> = vec![]; if let Some(focused_node) = focused_subnode(node) { nodes.extend(visible_nodes(focused_node)); } @@ -58,91 +51,45 @@ pub fn visible_nodes<'a>(node: &'a Value) -> Vec<&'a Value> { /// Finds deepest focused tabbed container in the provided node, and then for each tab, /// returns the deepest focused node within that tab, this is to preserve the /// focused state within tabs of a tabbed layout. -pub fn available_tabs(node: &Value) -> Vec<&Value> { +pub fn available_tabs(node: &Node) -> Vec<&Node> { if let Some(subnode) = find_deepest_focused_tabbed(node) { - let tabs_node = subnode.get("nodes").unwrap_or(&Value::Null); - if let Some(tabs_node) = tabs_node.as_array() { - return tabs_node.iter().map(|tab| find_deepest_focused(tab).unwrap_or(tab)).collect(); - } + return subnode.nodes.iter().map(|tab| find_deepest_focused(tab).unwrap_or(tab)).collect(); } logging::info!("No available tabs found in the provided node."); vec![] } -impl From<&Value> for Window { - fn from(node: &Value) -> Self { - let id = node["id"].as_u64().unwrap_or(0); - let rect = Rect { - x: node["rect"]["x"].as_i64().unwrap_or(0) as i32, - y: node["rect"]["y"].as_i64().unwrap_or(0) as i32, - w: node["rect"]["width"].as_i64().unwrap_or(0) as i32, - h: node["rect"]["height"].as_i64().unwrap_or(0) as i32, - }; - let floating = node["type"].as_str().map_or(false, |t| t == "floating_con"); - let focused: bool; - if floating { - focused = node["nodes"].get(0).and_then(|n| n["focused"].as_bool()).unwrap_or(false); - } else { - focused = node["focused"].as_bool().unwrap_or(false); - }; - - Window { id, rect, focused, floating, } - } -} - /// Converts a JSON node to a `Windows`. -pub fn to_windows(nodes: Vec<&Value>) -> Windows { +pub fn to_windows(nodes: Vec<&Node>) -> Windows { nodes.into_iter() - .filter(|node| !is_invisible_node(node)) + .filter(|node| !node.is_invisible()) .map(Window::from) .collect() } -/// Checks if a node is an invisible node, which is defined as having a rectangle with zero width -/// and height. -fn is_invisible_node(node: &Value) -> bool { - node["rect"]["width"].as_i64().unwrap_or(0) == 0 - && node["rect"]["height"].as_i64().unwrap_or(0) == 0 -} - -/// Checks if a node is an end node, which is defined as having no subnodes and being of type -/// "con". -fn is_end_node(node: &Value) -> bool { - let is_empty = node.get("nodes").and_then(|n| n.as_array()).unwrap_or(&vec![]).is_empty(); - let type_str = node.get("type").and_then(|t| t.as_str()).unwrap_or(""); - is_empty && ( type_str == "con" || type_str == "floating_con" ) -} - -/// Checks if a node is a workspace, which is defined as having a type of "workspace". -fn is_content_node(node: &Value) -> bool { - node["name"].as_str().unwrap_or("") == "content" && !is_end_node(node) -} - /// Returns the subnode that is currently focused, if any. If the node is an end node or has no /// focus, it returns `None`. /// Requires an array "focus" field and a "nodes" field containing subnodes. -fn focused_subnode(node: &Value) -> Option<&Value> { - let focus: &Value = node.get("focus").unwrap_or(&Value::Null); - if is_end_node(node) || focus.is_null() || focus.as_array().unwrap().is_empty() { +fn focused_subnode(node: &Node) -> Option<&Node> { + if node.is_leaf() || node.focus.is_empty() { return None; } - let focus_id = focus[0].as_u64().unwrap_or(0); - node.get("nodes")? - .as_array()? + let focus_id = node.focus.first().unwrap_or(&0); + node.nodes .iter() - .find(|n| n["id"].as_u64() == Some(focus_id)) + .find(|n| n.id == *focus_id) } /// Determines the layout type of a node based on its properties. /// Requires a "layout" field and a "type" field in the node. -fn get_layout(node: &Value) -> Layout { - let layout = node.get("layout").and_then(|l| l.as_str()).unwrap_or(""); - let node_type = node.get("type").and_then(|t| t.as_str()).unwrap_or(""); - if is_content_node(node) || layout == "stacked" || layout == "tabbed" { +fn get_layout(node: &Node) -> Layout { + if node.is_content() || + ["stacked", "tabbed"].contains(&node.layout.as_str()) { Layout::OneVisible - } else if layout == "splith" || layout == "splitv" || layout == "output" || node_type == "workspace" || node_type == "root" { + } else if ["splith", "splitv", "output"].contains(&node.layout.as_str()) || + ["workspace", "root", "con"].contains(&node.type_.as_str()) { Layout::AllVisible - } else if layout == "dockarea" { + } else if node.layout == "dockarea" { Layout::Skipped } else { Layout::Invalid @@ -150,11 +97,8 @@ fn get_layout(node: &Value) -> Layout { } /// Finds the deepest focused node in a tree structure, starting from the given node. -fn find_deepest_focused(node: &Value) -> Option<&Value> { - logging::debug!("F Node iterated id:{} type:{} layout:{}", - node.get("id").and_then(|v| v.as_u64()).unwrap_or(0), - node.get("type").and_then(|v| v.as_str()).unwrap_or("null"), - node.get("layout").and_then(|v| v.as_str()).unwrap_or("null")); +fn find_deepest_focused(node: &Node) -> Option<&Node> { + logging::debug!("F Node iterated {}", node.to_string()); let subnode = focused_subnode(node); if subnode.is_some() { let deepest = find_deepest_focused(subnode?); @@ -167,11 +111,8 @@ fn find_deepest_focused(node: &Value) -> Option<&Value> { /// Finds the deepest focused node that is tabbed, meaning it has a layout of `tabbed` or /// `stacked`. -fn find_deepest_focused_tabbed(node: &Value) -> Option<&Value> { - logging::debug!("T Node iterated id:{} type:{} layout:{}", - node.get("id").and_then(|v| v.as_u64()).unwrap_or(0), - node.get("type").and_then(|v| v.as_str()).unwrap_or("null"), - node.get("layout").and_then(|v| v.as_str()).unwrap_or("null")); +fn find_deepest_focused_tabbed(node: &Node) -> Option<&Node> { + logging::debug!("T Node iterated {}", node.to_string()); if let Some(subnode) = focused_subnode(node) { let endnode = find_deepest_focused_tabbed(subnode); if endnode.is_some() { @@ -428,7 +369,7 @@ mod tests { json!({"id": 1, "rect": {"x": 0, "y": 0, "width": 100, "height": 100}, "focused": true, "type": "con", "nodes": []}), json!({"id": 2, "rect": {"x": 300, "y": 450, "width": 15, "height": 200}, "focused": false, "type": "con", "nodes": []}), ]; - let node_refs: Vec<&Value> = nodes.iter().collect(); + let node_refs: Vec<&Node> = nodes.iter().collect(); let windows = to_windows(node_refs); assert_eq!(windows.len(), 2); assert_eq!(windows[0].id, 1); diff --git a/rust/src/backend/i3/json.rs b/rust/src/backend/i3/json.rs new file mode 100644 index 0000000..75cfb29 --- /dev/null +++ b/rust/src/backend/i3/json.rs @@ -0,0 +1,57 @@ +use crate::types::Rect; +use crate::types::Window; + +use serde; +use serde::Deserialize; + +#[derive(Deserialize, Debug, Clone)] +pub struct Node { + pub id: i64, + pub name: Option, + #[serde(rename = "type")] + pub type_: String, + pub layout: String, + pub nodes: Vec, + pub floating_nodes: Vec, + pub rect: Rect, + pub focus: Vec, + pub focused: bool, +} + +impl Node { + pub fn is_leaf(&self) -> bool { + self.nodes.is_empty() && + (self.type_ == "con" || self.type_ == "floating_con") + } + + pub fn is_content(&self) -> bool { + self.type_ == "con" && + self.name.is_some() && + self.name.as_ref().unwrap() == "content" && + !self.is_leaf() + } + + pub fn is_invisible(&self) -> bool { + self.rect.w == 0 && + self.rect.h == 0 + } +} + +impl From<&Node> for Window { + fn from(node: &Node) -> Self { + let id = node.id as u64; + let rect = node.rect.clone(); + let floating = node.type_ == "floating_con"; + let focused = node.focused; + + Window { id, rect, focused, floating, } + } +} + +impl ToString for Node { + fn to_string(&self) -> String { + let result = format!("Node id={} type={} layout={}", + self.id, self.type_, self.layout); + result + } +} diff --git a/rust/src/backend/i3/mod.rs b/rust/src/backend/i3/mod.rs index e7ec11d..0fe99cd 100644 --- a/rust/src/backend/i3/mod.rs +++ b/rust/src/backend/i3/mod.rs @@ -1,5 +1,6 @@ mod client; mod compass; +mod json; pub mod backend; pub use crate::backend::i3::backend::Backend; diff --git a/rust/src/types/rect.rs b/rust/src/types/rect.rs index 2b3b6ab..44ee1c9 100644 --- a/rust/src/types/rect.rs +++ b/rust/src/types/rect.rs @@ -1,13 +1,22 @@ /// A simple rectangle structure with methods to calculate its extents and middle points in a 2D /// space. +#[cfg(feature = "i3")] +use serde::Deserialize; + /// This structure is used to represent a rectangle defined by its top-left corner (x, y) and its /// width (w) and height (h). +#[cfg(feature = "i3")] +#[derive(Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Rect { pub x: i32, pub y: i32, + #[cfg(feature = "i3")] + #[serde(rename = "width")] pub w: i32, + #[cfg(feature = "i3")] + #[serde(rename = "height")] pub h: i32, } From a4ca4b2f5a1561bc97ae7cc096a9a5ac35ec4b5d Mon Sep 17 00:00:00 2001 From: Milosz Blizniak Date: Fri, 4 Jul 2025 20:07:12 +0200 Subject: [PATCH 2/5] refactor(rs): move compass logic into node --- rust/jsons/2node_splith.json | 31 ++ rust/jsons/ambigous_tabs.json | 52 +++ rust/jsons/empty_node.json | 10 + rust/jsons/empty_tabs.json | 21 + rust/jsons/empty_workspace.json | 10 + rust/jsons/node.jsonschema | 36 ++ rust/jsons/root_with_several_nodes.json | 173 ++++++++ rust/jsons/tabs_with_deep_focus.json | 63 +++ rust/src/backend/i3/backend.rs | 10 +- rust/src/backend/i3/compass.rs | 565 ------------------------ rust/src/backend/i3/json.rs | 312 ++++++++++++- rust/src/backend/i3/mod.rs | 1 - 12 files changed, 706 insertions(+), 578 deletions(-) create mode 100644 rust/jsons/2node_splith.json create mode 100644 rust/jsons/ambigous_tabs.json create mode 100644 rust/jsons/empty_node.json create mode 100644 rust/jsons/empty_tabs.json create mode 100644 rust/jsons/empty_workspace.json create mode 100644 rust/jsons/node.jsonschema create mode 100644 rust/jsons/root_with_several_nodes.json create mode 100644 rust/jsons/tabs_with_deep_focus.json delete mode 100644 rust/src/backend/i3/compass.rs diff --git a/rust/jsons/2node_splith.json b/rust/jsons/2node_splith.json new file mode 100644 index 0000000..bbf34fa --- /dev/null +++ b/rust/jsons/2node_splith.json @@ -0,0 +1,31 @@ +{ + "id": 1, + "type": "con", + "layout": "splith", + "nodes": [ + { + "id": 2, + "type": "con", + "layout": "splith", + "nodes": [], + "floating_nodes": [], + "rect": {"x": 0, "y": 0, "width": 100, "height": 100}, + "focus": [], + "focused": true + }, + { + "id": 3, + "type": "con", + "layout": "splith", + "nodes": [], + "floating_nodes": [], + "rect": {"x": 300, "y": 450, "width": 15, "height": 200}, + "focus": [], + "focused": false + } + ], + "floating_nodes": [], + "rect": {"x": 0, "y": 0, "width": 100, "height": 100}, + "focus": [2], + "focused": false +} diff --git a/rust/jsons/ambigous_tabs.json b/rust/jsons/ambigous_tabs.json new file mode 100644 index 0000000..4ce41c9 --- /dev/null +++ b/rust/jsons/ambigous_tabs.json @@ -0,0 +1,52 @@ +{ + "id": 1, + "type": "con", + "layout": "splith", + "nodes": [ + { + "id": 1, + "type": "con", + "layout": "tabbed", + "nodes": [ + { + "id": 3, + "type": "con", + "layout": "tabbed", + "nodes": [], + "floating_nodes": [], + "rect": {"x": 0, "y": 0, "width": 100, "height": 100}, + "focus": [], + "focused": true + }, + { + "id": 4, + "type": "con", + "layout": "tabbed", + "nodes": [], + "floating_nodes": [], + "rect": {"x": 0, "y": 0, "width": 100, "height": 100}, + "focus": [], + "focused": false + } + ], + "floating_nodes": [], + "rect": {"x": 0, "y": 0, "width": 100, "height": 100}, + "focus": [3], + "focused": false + }, + { + "id": 2, + "type": "con", + "layout": "tabbed", + "nodes": [], + "floating_nodes": [], + "rect": {"x": 0, "y": 0, "width": 100, "height": 100}, + "focus": [], + "focused": false + } + ], + "floating_nodes": [], + "rect": {"x": 0, "y": 0, "width": 100, "height": 100}, + "focus": [1, 3], + "focused": false +} diff --git a/rust/jsons/empty_node.json b/rust/jsons/empty_node.json new file mode 100644 index 0000000..ec7a3fb --- /dev/null +++ b/rust/jsons/empty_node.json @@ -0,0 +1,10 @@ +{ + "id": 0, + "type": "con", + "layout": "splith", + "nodes": [], + "floating_nodes": [], + "rect": {"x": 0, "y": 0, "width": 100, "height": 100}, + "focus": [], + "focused": false +} diff --git a/rust/jsons/empty_tabs.json b/rust/jsons/empty_tabs.json new file mode 100644 index 0000000..ff94308 --- /dev/null +++ b/rust/jsons/empty_tabs.json @@ -0,0 +1,21 @@ +{ + "id": 2, + "type": "con", + "layout": "tabbed", + "nodes": [ + { + "id": 1, + "type": "con", + "layout": "tabbed", + "nodes": [], + "floating_nodes": [], + "rect": {"x": 0, "y": 0, "width": 100, "height": 100}, + "focus": [], + "focused": true + } + ], + "floating_nodes": [], + "rect": {"x": 0, "y": 0, "width": 100, "height": 100}, + "focus": [1], + "focused": false +} diff --git a/rust/jsons/empty_workspace.json b/rust/jsons/empty_workspace.json new file mode 100644 index 0000000..722438b --- /dev/null +++ b/rust/jsons/empty_workspace.json @@ -0,0 +1,10 @@ +{ + "id": 1, + "type": "workspace", + "layout": "splith", + "nodes": [], + "floating_nodes": [], + "rect": {"x": 0, "y": 0, "width": 100, "height": 100}, + "focus": [], + "focused": false +} diff --git a/rust/jsons/node.jsonschema b/rust/jsons/node.jsonschema new file mode 100644 index 0000000..32a989b --- /dev/null +++ b/rust/jsons/node.jsonschema @@ -0,0 +1,36 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "id": { "type": "number" }, + "name": { "type": "string" }, + "type": { "type": "string" }, + "layout": { "type": "string" }, + "nodes": { + "type": "array", + "items": { "$ref": "#" } + }, + "floating_nodes": { + "type": "array", + "items": { "$ref": "#" } + }, + "rect": { + "type": "object", + "properties": { + "x": { "type": "number" }, + "y": { "type": "number" }, + "width": { "type": "number" }, + "height": { "type": "number" } + }, + "required": ["x", "y", "width", "height"] + }, + "focus": { + "type": "array", + "items": { "type": "number" } + }, + "focused": { + "type": "boolean" + } + }, + "required": ["id", "type", "layout", "nodes", "floating_nodes", "rect", "focus", "focused"] +} diff --git a/rust/jsons/root_with_several_nodes.json b/rust/jsons/root_with_several_nodes.json new file mode 100644 index 0000000..c26122b --- /dev/null +++ b/rust/jsons/root_with_several_nodes.json @@ -0,0 +1,173 @@ +{ + "id": 0, + "type": "root", + "layout": "splith", + "nodes": [ + { + "id": 1, + "type": "output", + "layout": "output", + "nodes": [ + { + "id": 2, + "name": "content", + "type": "con", + "layout": "splith", + "nodes": [ + { + "id": 3, + "type": "workspace", + "layout": "splith", + "nodes": [ + { + "id": 4, + "type": "con", + "layout": "splith", + "nodes": [ + { + "id": 5, + "type": "con", + "layout": "splith", + "nodes": [], + "floating_nodes": [], + "rect": {"x": 0, "y": 0, "width": 100, "height": 100}, + "focus": [], + "focused": false + }, + { + "id": 6, + "type": "con", + "layout": "splith", + "nodes": [], + "floating_nodes": [], + "rect": {"x": 0, "y": 0, "width": 100, "height": 100}, + "focus": [], + "focused": true + } + ], + "floating_nodes": [], + "rect": {"x": 0, "y": 0, "width": 100, "height": 100}, + "focus": [6, 5], + "focused": false + } + ], + "floating_nodes": [], + "rect": {"x": 0, "y": 0, "width": 100, "height": 100}, + "focus": [4], + "focused": false + } + ], + "floating_nodes": [], + "rect": {"x": 0, "y": 0, "width": 100, "height": 100}, + "focus": [3], + "focused": false + } + ], + "floating_nodes": [], + "rect": {"x": 0, "y": 0, "width": 100, "height": 100}, + "focus": [2], + "focused": false + }, + { + "id": 7, + "type": "output", + "layout": "output", + "nodes": [ + { + "id": 8, + "type": "con", + "layout": "splith", + "name": "content", + "nodes": [ + { + "id": 12, + "type": "workspace", + "layout": "splith", + "nodes": [ + { + "id": 13, + "type": "con", + "layout": "splith", + "nodes": [], + "floating_nodes": [], + "rect": {"x": 0, "y": 0, "width": 100, "height": 100}, + "focus": [], + "focused": false + } + ], + "floating_nodes": [], + "rect": {"x": 0, "y": 0, "width": 100, "height": 100}, + "focus": [13], + "focused": false + }, + { + "id": 9, + "type": "workspace", + "layout": "splith", + "nodes": [ + { + "id": 10, + "type": "con", + "layout": "tabbed", + "nodes": [ + { + "id": 14, + "type": "con", + "layout": "splith", + "nodes": [], + "floating_nodes": [], + "rect": {"x": 0, "y": 0, "width": 100, "height": 100}, + "focus": [], + "focused": false + }, + { + "id": 11, + "type": "con", + "layout": "splith", + "nodes": [], + "floating_nodes": [], + "rect": {"x": 0, "y": 0, "width": 100, "height": 100}, + "focus": [], + "focused": false + } + ], + "floating_nodes": [], + "rect": {"x": 0, "y": 0, "width": 0, "height": 0}, + "focus": [11, 14], + "focused": false + } + ], + "floating_nodes": [ + { + "id": 20, + "type": "con", + "layout": "splith", + "nodes": [], + "floating_nodes": [], + "rect": {"x": 0, "y": 0, "width": 100, "height": 100}, + "focus": [], + "focused": false + } + ], + "rect": {"x": 0, "y": 0, "width": 100, "height": 100}, + "focus": [10, 20], + "focused": false + } + ], + "floating_nodes": [], + "rect": {"x": 0, "y": 0, "width": 100, "height": 100}, + "focus": [9, 12], + "focused": false + } + ], + "floating_nodes": [], + "rect": {"x": 0, "y": 0, "width": 100, "height": 100}, + "focus": [8], + "focused": false + } + ], + "floating_nodes": [], + "rect": {"x": 0, "y": 0, "width": 100, "height": 100}, + "focus": [1, 7], + "focused": false +} diff --git a/rust/jsons/tabs_with_deep_focus.json b/rust/jsons/tabs_with_deep_focus.json new file mode 100644 index 0000000..d5ab784 --- /dev/null +++ b/rust/jsons/tabs_with_deep_focus.json @@ -0,0 +1,63 @@ +{ + "id": 1, + "type": "con", + "layout": "splith", + "nodes": [ + { + "id": 2, + "type": "con", + "layout": "tabbed", + "nodes": [ + { + "id": 3, + "type": "con", + "layout": "splith", + "nodes": [], + "floating_nodes": [], + "rect": {"x": 0, "y": 0, "width": 100, "height": 100}, + "focus": [], + "focused": true + }, + { + "id": 4, + "type": "con", + "layout": "splith", + "nodes": [ + { + "id": 5, + "type": "con", + "layout": "splith", + "nodes": [], + "floating_nodes": [], + "rect": {"x": 0, "y": 0, "width": 100, "height": 100}, + "focus": [], + "focused": true + }, + { + "id": 6, + "type": "con", + "layout": "splith", + "nodes": [], + "floating_nodes": [], + "rect": {"x": 0, "y": 0, "width": 100, "height": 100}, + "focus": [], + "focused": false + } + ], + "floating_nodes": [], + "rect": {"x": 0, "y": 0, "width": 100, "height": 100}, + "focus": [5], + "focused": true + } + ], + "floating_nodes": [], + "rect": {"x": 0, "y": 0, "width": 100, "height": 100}, + "focus": [3], + "focused": true + } + ], + "floating_nodes": [], + "rect": {"x": 0, "y": 0, "width": 100, "height": 100}, + "focus": [2], + "focused": true +} diff --git a/rust/src/backend/i3/backend.rs b/rust/src/backend/i3/backend.rs index c220e58..91edd9b 100644 --- a/rust/src/backend/i3/backend.rs +++ b/rust/src/backend/i3/backend.rs @@ -3,8 +3,8 @@ use crate::logging::ResultExt; use crate::logging; use crate::types::Windows; use super::client::{Client, Request}; -use super::compass; use super::json::Node; +use crate::types::Window; use serde_json as json; use std::process; @@ -40,15 +40,15 @@ impl Backend { impl GetTabs for Backend { fn get_tabs(&self) -> Result { - let nodes = compass::available_tabs(&self.root); - Ok(compass::to_windows(nodes)) + let nodes = self.root.available_tabs(); + Ok(nodes.iter().map(|node| Window::from(*node)).collect()) } } impl GetVisible for Backend { fn get_visible(&self) -> Result { - let nodes = compass::visible_nodes(&self.root); - Ok(compass::to_windows(nodes)) + let nodes = self.root.visible_nodes(); + Ok(nodes.iter().map(|node| Window::from(*node)).collect()) } } diff --git a/rust/src/backend/i3/compass.rs b/rust/src/backend/i3/compass.rs deleted file mode 100644 index 5e3831c..0000000 --- a/rust/src/backend/i3/compass.rs +++ /dev/null @@ -1,565 +0,0 @@ -use crate::logging; -use crate::types::{Window, Windows}; -use crate::backend::i3::json::Node; - -/// This enum represents the layout type of a node in a window manager's tree structure. -#[derive(Debug, Clone, PartialEq, Eq)] -enum Layout { - AllVisible, - OneVisible, - Skipped, - Invalid, -} - -/// Returns a collection of visible nodes in the provided JSON node. -/// This function traverses the node structure and collects nodes that are not invisible -/// or end nodes. -pub fn visible_nodes<'a>(node: &'a Node) -> Vec<&'a Node> { - logging::debug!("V Node iterated {}", node.to_string()); - if node.is_leaf() { - if node.is_invisible() { - return vec![]; - } - return vec![node]; - } - - let layout = get_layout(node); - match layout { - Layout::AllVisible => { - let mut nodes: Vec<&'a Node> = vec![]; - nodes.extend(node.floating_nodes.iter()); - node.nodes.iter().for_each(|subnode| { - nodes.extend(visible_nodes(subnode)); - }); - return nodes; - } - Layout::OneVisible => { - let mut nodes: Vec<&'a Node> = vec![]; - if let Some(focused_node) = focused_subnode(node) { - nodes.extend(visible_nodes(focused_node)); - } - return nodes; - } - Layout::Skipped => vec![], - Layout::Invalid => { - logging::error!("Invalid layout encountered: {:?}", layout); - return vec![] - } - } -} - -/// Finds deepest focused tabbed container in the provided node, and then for each tab, -/// returns the deepest focused node within that tab, this is to preserve the -/// focused state within tabs of a tabbed layout. -pub fn available_tabs(node: &Node) -> Vec<&Node> { - if let Some(subnode) = find_deepest_focused_tabbed(node) { - return subnode.nodes.iter().map(|tab| find_deepest_focused(tab).unwrap_or(tab)).collect(); - } - logging::info!("No available tabs found in the provided node."); - vec![] -} - -/// Converts a JSON node to a `Windows`. -pub fn to_windows(nodes: Vec<&Node>) -> Windows { - nodes.into_iter() - .filter(|node| !node.is_invisible()) - .map(Window::from) - .collect() -} - -/// Returns the subnode that is currently focused, if any. If the node is an end node or has no -/// focus, it returns `None`. -/// Requires an array "focus" field and a "nodes" field containing subnodes. -fn focused_subnode(node: &Node) -> Option<&Node> { - if node.is_leaf() || node.focus.is_empty() { - return None; - } - let focus_id = node.focus.first().unwrap_or(&0); - node.nodes - .iter() - .find(|n| n.id == *focus_id) -} - -/// Determines the layout type of a node based on its properties. -/// Requires a "layout" field and a "type" field in the node. -fn get_layout(node: &Node) -> Layout { - if node.is_content() || - ["stacked", "tabbed"].contains(&node.layout.as_str()) { - Layout::OneVisible - } else if ["splith", "splitv", "output"].contains(&node.layout.as_str()) || - ["workspace", "root", "con"].contains(&node.type_.as_str()) { - Layout::AllVisible - } else if node.layout == "dockarea" { - Layout::Skipped - } else { - Layout::Invalid - } -} - -/// Finds the deepest focused node in a tree structure, starting from the given node. -fn find_deepest_focused(node: &Node) -> Option<&Node> { - logging::debug!("F Node iterated {}", node.to_string()); - let subnode = focused_subnode(node); - if subnode.is_some() { - let deepest = find_deepest_focused(subnode?); - if deepest.is_some() { - return deepest; - } - } - subnode -} - -/// Finds the deepest focused node that is tabbed, meaning it has a layout of `tabbed` or -/// `stacked`. -fn find_deepest_focused_tabbed(node: &Node) -> Option<&Node> { - logging::debug!("T Node iterated {}", node.to_string()); - if let Some(subnode) = focused_subnode(node) { - let endnode = find_deepest_focused_tabbed(subnode); - if endnode.is_some() { - return endnode; - } else if get_layout(node) == Layout::OneVisible { - return Some(node); - } - } - None -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - /// Tests for visible nodes extraction. - /// We expect the function to return all nodes that are not invisible. - #[test] - fn test_visible_nodes() { - let node = json!({ - "id": 1, - "type": "con", - "layout": "splith", - "nodes": [ - { - "id": 2, - "type": "con", - "rect": {"width": 100, "height": 100}, - "focused": true, - "nodes": [] - }, - { - "id": 3, - "type": "con", - "rect": {"width": 0, "height": 0}, - "focused": false, - "nodes": [] - } - ], - "rect": {"width": 100, "height": 100} - }); - let nodes = visible_nodes(&node); - assert_eq!(nodes.len(), 1); - assert_eq!(nodes[0]["id"], 2); - - let node = json!({ - "id": 0, - "type": "root", - "layout": "splith", - "nodes": [ - { - "id": 1, - "type": "output", - "layout": "output", - "nodes": [ - { - "id": 2, - "type": "con", - "layout": "splith", - "name": "content", - "nodes": [ - { - "id": 3, - "type": "workspace", - "layout": "splith", - "nodes": [ - { - "id": 4, - "type": "con", - "layout": "splith", - "nodes": [ - { - "id": 5, - "focused": false, - "nodes": [], - "type": "con", - "focus": [], - "rect": {"width": 100, "height": 100} - }, - { - "id": 6, - "focused": true, - "nodes": [], - "type": "con", - "focus": [], - "rect": {"width": 100, "height": 100} - } - ], - "focus": [6, 5], - "focused": false, - "rect": {"width": 100, "height": 100} - } - ], - "focus": [4], - "focused": false, - "rect": {"width": 100, "height": 100} - } - ], - "focus": [3], - "focused": false, - "rect": {"width": 100, "height": 100} - } - ], - "focus": [2], - "focused": false, - "rect": {"width": 100, "height": 100} - }, - { - "id": 7, - "type": "output", - "layout": "output", - "nodes": [ - { - "id": 8, - "type": "con", - "layout": "splith", - "name": "content", - "nodes": [ - { - "id": 12, - "type": "workspace", - "layout": "splith", - "nodes": [ - { - "id": 13, - "focused": false, - "nodes": [], - "type": "con", - "focus": [], - "rect": {"width": 100, "height": 100} - } - ], - "focus": [13], - "focused": false, - "rect": {"width": 100, "height": 100} - }, - { - "id": 9, - "type": "workspace", - "layout": "splith", - "nodes": [ - { - "id": 10, - "type": "con", - "layout": "tabbed", - "nodes": [ - { - "id": 14, - "focused": false, - "nodes": [], - "type": "con", - "focus": [], - "rect": {"width": 100, "height": 100} - }, - { - "id": 11, - "focused": false, - "nodes": [], - "type": "con", - "focus": [], - "rect": {"width": 100, "height": 100} - } - ], - "focus": [11, 14], - "focused": false, - } - ], - "floating_nodes": [ - { - "id": 20, - "focused": false, - "nodes": [], - "type": "con", - "focus": [], - "rect": {"width": 100, "height": 100} - } - ], - "focus": [10, 20], - "focused": false, - "rect": {"width": 100, "height": 100} - } - ], - "focus": [9, 12], - "focused": false, - "rect": {"width": 100, "height": 100} - } - ], - "focus": [8], - "focused": false, - "rect": {"width": 100, "height": 100} - } - ], - "focus": [1, 7], - "focused": false, - "rect": {"width": 100, "height": 100} - }); - let nodes = visible_nodes(&node); - assert!(nodes.iter().any(|n| n["id"] == 5)); - assert!(nodes.iter().any(|n| n["id"] == 6)); - assert!(nodes.iter().any(|n| n["id"] == 11)); - assert!(nodes.iter().any(|n| n["id"] == 20)); - assert_eq!(nodes.len(), 4); - } - - /// Tests for extracting available tabs from a node. - /// We expect the function to return a vector of leaf nodes that are focused of a tabbed - /// layout, or none if there are no tabs. - #[test] - fn test_available_tabs() { - let node = json!({ - "id": 1, - "type": "con", - "layout": "splith", - "nodes": [ - { - "id": 2, - "type": "con", - "layout": "tabbed", - "nodes": [ - {"id": 3, "focused": true, "nodes": [], "type": "con", "focus": []}, - { - "id": 4, - "type": "con", - "layout": "splith", - "nodes": [ - {"id": 5, "focused": true, "nodes": [], "type": "con", "focus": []}, - {"id": 6, "focused": false, "nodes": [], "type": "con", "focus": []} - ], - "focus": [5], - "focused": true, - "rect": {"width": 100, "height": 100} - } - ], - "focus": [3], - "focused": true, - "rect": {"width": 100, "height": 100} - } - ], - "focus": [2], - "focused": true, - "rect": {"width": 100, "height": 100} - }); - let tabs = available_tabs(&node); - assert_eq!(tabs.len(), 2); - assert!(tabs.iter().any(|tab| tab["id"] == 3)); - assert!(tabs.iter().any(|tab| tab["id"] == 5)); - } - - /// Tests for converting JSON nodes to windows. - #[test] - fn test_to_windows() { - let nodes = vec![ - json!({"id": 1, "rect": {"x": 0, "y": 0, "width": 100, "height": 100}, "focused": true, "type": "con", "nodes": []}), - json!({"id": 2, "rect": {"x": 300, "y": 450, "width": 15, "height": 200}, "focused": false, "type": "con", "nodes": []}), - ]; - let node_refs: Vec<&Node> = nodes.iter().collect(); - let windows = to_windows(node_refs); - assert_eq!(windows.len(), 2); - assert_eq!(windows[0].id, 1); - assert!(windows[0].focused); - assert!(!windows[0].floating); - assert_eq!(windows[0].rect, Rect { x: 0, y: 0, w: 100, h: 100 }); - assert_eq!(windows[1].id, 2); - assert!(!windows[1].focused); - assert!(!windows[1].floating); - assert_eq!(windows[1].rect, Rect { x: 300, y: 450, w: 15, h: 200 }); - } - - /// Tests for layout extraction. - /// We expect the function to return the correct layout type based on the "layout" and "type" - /// fields. - /// Workspaces are switchable, - /// Split layouts are directional, - /// Tabbed layouts are switchable, - /// Stacked layouts are switchable, - /// Dock areas are opaque to the layout system, - /// Invalid layouts are marked as invalid. - #[test] - fn test_get_layout() { - let node = json!({ - "layout": "splith", - "name": "content", - "type": "con", - "nodes": [ - {"id": 1, "type": "workspace", "layout": "splith", "nodes": []}, - ], - }); - assert_eq!(get_layout(&node), Layout::OneVisible); - - let node = json!({ - "layout": "splith", - "type": "workspace" - }); - assert_eq!(get_layout(&node), Layout::AllVisible); - - let node = json!({ - "layout": "splith", - "type": "con" - }); - assert_eq!(get_layout(&node), Layout::AllVisible); - - let node = json!({ - "layout": "splitv", - "type": "con" - }); - assert_eq!(get_layout(&node), Layout::AllVisible); - - let node = json!({ - "layout": "tabbed", - "type": "con" - }); - assert_eq!(get_layout(&node), Layout::OneVisible); - - let node = json!({ - "layout": "stacked", - "type": "con" - }); - assert_eq!(get_layout(&node), Layout::OneVisible); - - let node = json!({ - "layout": "dockarea", - "type": "con" - }); - assert_eq!(get_layout(&node), Layout::Skipped); - - let node = json!({ - "layout": "invalid", - "type": "con" - }); - assert_eq!(get_layout(&node), Layout::Invalid); - } - - /// Tests for invisible node detection. - /// We expect the function to return true for nodes that have a rectangle with zero width and - /// height. - #[test] - fn test_is_invisible_node() { - let node = json!({ - "rect": {"width": 0, "height": 0} - }); - assert!(is_invisible_node(&node)); - - let node = json!({ - "rect": {"width": 100, "height": 100} - }); - assert!(!is_invisible_node(&node)); - } - - /// Tests for end node detection. - /// We expect the function to return true for nodes that have no subnodes and are a container. - #[test] - fn test_is_end_node() { - let node = json!({ - "nodes": [], - "type": "con" - }); - assert!(is_end_node(&node)); - - let node = json!({ - "nodes": [{"id": 1}], - "type": "con" - }); - assert!(!is_end_node(&node)); - - let node = json!({ - "nodes": [], - "type": "workspace" - }); - assert!(!is_end_node(&node)); - } - - /// Tests for focused subnode extraction. - /// We expect the function to return the node that is indicated based on the "focus" field. - #[test] - fn test_focused_subnode() { - let node = json!({ - "nodes": [ - {"id": 1, "focused": true, "nodes": []}, - {"id": 2, "focused": false, "nodes": []} - ], - "focus": [1] - }); - assert_eq!(focused_subnode(&node).unwrap()["id"].as_u64().unwrap(), 1); - - let node = json!({ - "nodes": [], - "focus": [] - }); - assert!(focused_subnode(&node).is_none()); - } - - /// Tests for finding the deepest focused node. - /// We expect the function to traverse the tree and find the deepest node that is focused. - #[test] - fn test_find_deepest_focused() { - let node = json!({ - "nodes": [ - {"id": 1, "focused": true, "nodes": []}, - {"id": 2, "focused": false, "nodes": []} - ], - "focus": [1] - }); - assert_eq!(find_deepest_focused(&node).unwrap()["id"], 1); - - let node = json!({ - "nodes": [], - "focus": [] - }); - assert!(find_deepest_focused(&node).is_none()); - } - - /// Tests for finding the deepest focused tabbed node. - /// We expect the function to find the deepest node that is focused and has focusable tabs. - #[test] - fn test_find_deepest_focused_tabbed() { - // Test with a focused tabbed node. - // Tabs require elements and focus to be recognized as switchable. - let node = json!({ - "nodes": [ - {"id": 1, "layout": "tabbed", "focused": true, "nodes": [ - {"id": 3, "focused": true, "nodes": [], "focus": []}, - {"id": 4, "focused": false, "nodes": [], "focus": []} - ], "focus": [3]}, - {"id": 2, "layout": "tabbed", "focused": false, "nodes": [], "focus": []} - ], - "focus": [1, 3] - }); - assert_eq!(find_deepest_focused_tabbed(&node).unwrap()["id"], 1); - - // Test with a focused tabbed node that has no subnodes - // This should return None since it has no tabs. - let node = json!({ - "nodes": [ - {"id": 1, "layout": "tabbed", "focused": true, "nodes": [], "focus": []} - ], - "focus": [1] - }); - assert_eq!(find_deepest_focused_tabbed(&node), None); - - // Test with no focused tabbed node - // This should return None since there are no tabs. - let node = json!({ - "nodes": [], - "focus": [] - }); - assert!(find_deepest_focused_tabbed(&node).is_none()); - } - -} diff --git a/rust/src/backend/i3/json.rs b/rust/src/backend/i3/json.rs index 75cfb29..52431b3 100644 --- a/rust/src/backend/i3/json.rs +++ b/rust/src/backend/i3/json.rs @@ -1,12 +1,22 @@ use crate::types::Rect; use crate::types::Window; +use crate::logging; use serde; use serde::Deserialize; -#[derive(Deserialize, Debug, Clone)] +/// This enum represents the layout type of a node in a window manager's tree structure. +#[derive(Debug, Clone, PartialEq, Eq)] +enum Layout { + AllVisible, + OneVisible, + Skipped, + Invalid, +} + +#[derive(Deserialize, Debug, Clone, PartialEq, Eq)] pub struct Node { - pub id: i64, + pub id: u64, pub name: Option, #[serde(rename = "type")] pub type_: String, @@ -14,34 +24,138 @@ pub struct Node { pub nodes: Vec, pub floating_nodes: Vec, pub rect: Rect, - pub focus: Vec, + pub focus: Vec, pub focused: bool, } impl Node { - pub fn is_leaf(&self) -> bool { + + // -------------- + // Public methods + // -------------- + + pub fn available_tabs(&self) -> Vec<&Node> { + if let Some(subnode) = self.find_deepest_focused_tabbed() { + return subnode.nodes.iter().map(|tab| tab.find_deepest_focused().unwrap_or(tab)).collect(); + } + logging::info!("No available tabs found in the provided node."); + vec![] + } + + pub fn visible_nodes<'a>(&'a self) -> Vec<&'a Node> { + logging::debug!("V Iterated {}", self.to_string()); + if self.is_leaf() { + if self.is_invisible() { + return vec![]; + } + return vec![self]; + } + + let layout = self.get_layout(); + match layout { + Layout::AllVisible => { + let mut nodes: Vec<&'a Node> = vec![]; + nodes.extend(self.floating_nodes.iter()); + self.nodes.iter().for_each(|subnode| { + println!("Subnode: {}", subnode.to_string()); + nodes.extend(subnode.visible_nodes()); + }); + return nodes; + } + Layout::OneVisible => { + let mut nodes: Vec<&'a Node> = vec![]; + if let Some(focused_node) = self.focused_subnode() { + nodes.extend(focused_node.visible_nodes()); + } + return nodes; + } + Layout::Skipped => vec![], + Layout::Invalid => { + logging::error!("Invalid layout encountered: {:?}", layout); + return vec![] + } + } + } + + // --------------- + // Private methods + // --------------- + + fn is_leaf(&self) -> bool { self.nodes.is_empty() && (self.type_ == "con" || self.type_ == "floating_con") } - pub fn is_content(&self) -> bool { + fn is_content(&self) -> bool { self.type_ == "con" && self.name.is_some() && self.name.as_ref().unwrap() == "content" && !self.is_leaf() } - pub fn is_invisible(&self) -> bool { + fn is_invisible(&self) -> bool { self.rect.w == 0 && self.rect.h == 0 } + + fn is_floating(&self) -> bool { + self.type_ == "floating_con" || + self.floating_nodes.iter().any(|n| n.is_floating()) + } + + fn focused_subnode(&self) -> Option<&Node> { + if self.is_leaf() || self.focus.is_empty() { + return None; + } + let focus_id = self.focus.first().unwrap_or(&0); + self.nodes + .iter() + .find(|n| n.id == *focus_id) + } + + fn find_deepest_focused_tabbed(&self) -> Option<&Node> { + logging::debug!("T Iterated {}", self.to_string()); + if let Some(subnode) = self.focused_subnode() { + if self.get_layout() != Layout::OneVisible { + return Some(subnode); + } + return subnode.find_deepest_focused_tabbed(); + } + None + } + + fn find_deepest_focused(&self) -> Option<&Node> { + logging::debug!("F Iterated {}", self.to_string()); + let subnode = self.focused_subnode(); + if subnode.is_some() { + let deepest = subnode?.find_deepest_focused(); + if deepest.is_some() { + return deepest; + } + } + subnode + } + + fn get_layout(&self) -> Layout { + if self.is_content() || + ["stacked", "tabbed"].contains(&self.layout.as_str()) { + Layout::OneVisible + } else if ["splith", "splitv", "output"].contains(&self.layout.as_str()) && + ["workspace", "root", "output", "con"].contains(&self.type_.as_str()) { + Layout::AllVisible + } else if self.layout == "dockarea" { + Layout::Skipped + } else { + Layout::Invalid + } + } } impl From<&Node> for Window { fn from(node: &Node) -> Self { let id = node.id as u64; let rect = node.rect.clone(); - let floating = node.type_ == "floating_con"; + let floating = node.is_floating(); let focused = node.focused; Window { id, rect, focused, floating, } @@ -55,3 +169,187 @@ impl ToString for Node { result } } + +// ----- +// Tests +// ----- + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn read_json(file: &str) -> Node { + let content = std::fs::read_to_string(file) + .expect("Failed to read JSON file"); + serde_json::from_str::(content.as_str()) + .expect("Failed to parse JSON to Node") + } + + /// Tests for visible nodes extraction. + /// We expect the function to return all nodes that are not invisible. + #[test] + fn test_visible_nodes() { + let root: Node = read_json("jsons/2node_splith.json"); + let nodes = root.visible_nodes(); + assert_eq!(nodes.len(), 2); + assert_eq!(nodes[0].id, 2); + + let node: Node = read_json("jsons/root_with_several_nodes.json"); + let nodes = node.visible_nodes(); + assert!(nodes.iter().any(|n| n.id == 5)); + assert!(nodes.iter().any(|n| n.id == 6)); + assert!(nodes.iter().any(|n| n.id == 11)); + assert!(nodes.iter().any(|n| n.id == 20)); + assert_eq!(nodes.len(), 4); + } + + /// Tests for extracting available tabs from a node. + /// We expect the function to return a vector of leaf nodes that are focused of a tabbed + /// layout, or none if there are no tabs. + #[test] + fn test_available_tabs() { + let node: Node = read_json("jsons/tabs_with_deep_focus.json"); + let tabs = node.available_tabs(); + assert_eq!(tabs.len(), 2); + assert!(tabs.iter().any(|tab| tab.id == 3)); + assert!(tabs.iter().any(|tab| tab.id == 5)); + } + + /// Tests for converting JSON nodes to windows. + #[test] + fn test_to_windows() { + let node: Node = read_json("jsons/2node_splith.json"); + let nodes: Vec<&Node> = node.nodes.iter().collect(); + let windows = nodes.iter().map(|n| Window::from(*n)).collect::>(); + assert_eq!(windows.len(), 2); + assert_eq!(windows[0].id, 2); + assert!(windows[0].focused); + assert!(!windows[0].floating); + assert_eq!(windows[0].rect, Rect { x: 0, y: 0, w: 100, h: 100 }); + assert_eq!(windows[1].id, 3); + assert!(!windows[1].focused); + assert!(!windows[1].floating); + assert_eq!(windows[1].rect, Rect { x: 300, y: 450, w: 15, h: 200 }); + } + + /// Tests for layout extraction. + /// We expect the function to return the correct layout type based on the "layout" and "type" + /// fields. + /// Workspaces are switchable, + /// Split layouts are directional, + /// Tabbed layouts are switchable, + /// Stacked layouts are switchable, + /// Dock areas are opaque to the layout system, + /// Invalid layouts are marked as invalid. + #[test] + fn test_get_layout() { + let mut node: Node = read_json("jsons/empty_node.json"); + node.name = Some("content".to_string()); + node.type_ = "con".to_string(); + node.layout = "splith".to_string(); + node.nodes.push(node.clone()); + assert_eq!(node.get_layout(), Layout::OneVisible); + + let mut node: Node = read_json("jsons/empty_node.json"); + node.type_ = "workspace".to_string(); + node.layout = "splith".to_string(); + assert_eq!(node.get_layout(), Layout::AllVisible); + + node.type_ = "con".to_string(); + node.layout = "splith".to_string(); + assert_eq!(node.get_layout(), Layout::AllVisible); + + node.type_ = "con".to_string(); + node.layout = "splitv".to_string(); + assert_eq!(node.get_layout(), Layout::AllVisible); + + node.type_ = "con".to_string(); + node.layout = "tabbed".to_string(); + assert_eq!(node.get_layout(), Layout::OneVisible); + + node.type_ = "con".to_string(); + node.layout = "stacked".to_string(); + assert_eq!(node.get_layout(), Layout::OneVisible); + + node.type_ = "dockarea".to_string(); + node.layout = "dockarea".to_string(); + assert_eq!(node.get_layout(), Layout::Skipped); + + node.type_ = "con".to_string(); + node.layout = "invalid".to_string(); + assert_eq!(node.get_layout(), Layout::Invalid); + } + + /// Tests for invisible node detection. + /// We expect the function to return true for nodes that have a rectangle with zero width and + /// height. + #[test] + fn test_is_invisible_node() { + let mut node: Node = read_json("jsons/empty_node.json"); + node.rect.w = 0; + node.rect.h = 0; + assert!(node.is_invisible()); + + node.rect.w = 100; + node.rect.h = 100; + assert!(!node.is_invisible()); + } + + /// Tests for end node detection. + /// We expect the function to return true for nodes that have no subnodes and are a container. + #[test] + fn test_is_end_node() { + let node: Node = read_json("jsons/empty_node.json"); + assert!(node.is_leaf()); + + let node: Node = read_json("jsons/2node_splith.json"); + assert!(!node.is_leaf()); + + let node: Node = read_json("jsons/empty_workspace.json"); + assert!(!node.is_leaf()); + } + + /// Tests for focused subnode extraction. + /// We expect the function to return the node that is indicated based on the "focus" field. + #[test] + fn test_focused_subnode() { + let node: Node = read_json("jsons/2node_splith.json"); + assert_eq!(node.focused_subnode().unwrap().id, 2); + + let node: Node = read_json("jsons/empty_node.json"); + assert!(node.focused_subnode().is_none()); + } + + /// Tests for finding the deepest focused node. + /// We expect the function to traverse the tree and find the deepest node that is focused. + #[test] + fn test_find_deepest_focused() { + let node: Node = read_json("jsons/2node_splith.json"); + assert_eq!(node.find_deepest_focused().unwrap().id, 2); + + let node: Node = read_json("jsons/empty_node.json"); + assert!(node.find_deepest_focused().is_none()); + } + + /// Tests for finding the deepest focused tabbed node. + /// We expect the function to find the deepest node that is focused and has focusable tabs. + #[test] + fn test_find_deepest_focused_tabbed() { + // Test with a focused tabbed node. + // Tabs require elements and focus to be recognized as switchable. + let node: Node = read_json("jsons/ambigous_tabs.json"); + assert_eq!(node.find_deepest_focused_tabbed().unwrap().id, 1); + + // Test with a focused tabbed node that has no subnodes + // This should return None since it has no tabs. + let node: Node = read_json("jsons/empty_tabs.json"); + assert_eq!(node.find_deepest_focused_tabbed(), None); + + // Test with no focused tabbed node + // This should return None since there are no tabs. + let node: Node = read_json("jsons/empty_node.json"); + assert!(node.find_deepest_focused_tabbed().is_none()); + } + +} diff --git a/rust/src/backend/i3/mod.rs b/rust/src/backend/i3/mod.rs index 0fe99cd..05f0586 100644 --- a/rust/src/backend/i3/mod.rs +++ b/rust/src/backend/i3/mod.rs @@ -1,5 +1,4 @@ mod client; -mod compass; mod json; pub mod backend; From 9bc18671d152cc3e5fb32d904a64fdaa445c15bb Mon Sep 17 00:00:00 2001 From: Milosz Blizniak Date: Fri, 4 Jul 2025 21:05:59 +0200 Subject: [PATCH 3/5] fix(rs): workspaces are not tabs --- rust/jsons/ambigous_tabs.json | 2 +- rust/src/backend/i3/json.rs | 46 ++++++++++++++++++++++++----------- 2 files changed, 33 insertions(+), 15 deletions(-) diff --git a/rust/jsons/ambigous_tabs.json b/rust/jsons/ambigous_tabs.json index 4ce41c9..e39c7e2 100644 --- a/rust/jsons/ambigous_tabs.json +++ b/rust/jsons/ambigous_tabs.json @@ -1,5 +1,5 @@ { - "id": 1, + "id": 0, "type": "con", "layout": "splith", "nodes": [ diff --git a/rust/src/backend/i3/json.rs b/rust/src/backend/i3/json.rs index 52431b3..2a5e34f 100644 --- a/rust/src/backend/i3/json.rs +++ b/rust/src/backend/i3/json.rs @@ -34,6 +34,8 @@ impl Node { // Public methods // -------------- + /// Finds all available tabs in the node tree, relevant for current focus. + /// Returns a vector of most recently focused nodes for each tab. pub fn available_tabs(&self) -> Vec<&Node> { if let Some(subnode) = self.find_deepest_focused_tabbed() { return subnode.nodes.iter().map(|tab| tab.find_deepest_focused().unwrap_or(tab)).collect(); @@ -42,6 +44,9 @@ impl Node { vec![] } + /// Finds all visible nodes in the node tree. + /// Nodes are considered visible if they are on a visible workspace, not unfocused tab, + /// and have a non-zero rectangle size. pub fn visible_nodes<'a>(&'a self) -> Vec<&'a Node> { logging::debug!("V Iterated {}", self.to_string()); if self.is_leaf() { @@ -81,11 +86,14 @@ impl Node { // Private methods // --------------- + /// Checks if the node is a leaf node, meaning it has no subnodes and is a container. fn is_leaf(&self) -> bool { self.nodes.is_empty() && (self.type_ == "con" || self.type_ == "floating_con") } + /// Checks if the node is a content node, which is a special type of node containing + /// workspaces. fn is_content(&self) -> bool { self.type_ == "con" && self.name.is_some() && @@ -93,16 +101,28 @@ impl Node { !self.is_leaf() } + /// Checks if the node is invisible, meaning it has a rectangle with zero width and height. fn is_invisible(&self) -> bool { self.rect.w == 0 && self.rect.h == 0 } + /// Checks if the node is floating, meaning it is a floating container or has any floating + /// subnodes that are floating. fn is_floating(&self) -> bool { self.type_ == "floating_con" || self.floating_nodes.iter().any(|n| n.is_floating()) } + /// Returns whether the node is a tabbed layout that has multiple subnodes. + fn is_switchable_tabbed(&self) -> bool { + self.get_layout() == Layout::OneVisible && + !self.is_content() && + self.nodes.len() > 1 + } + + /// Returns the focused subnode of the current node, if it exists. + /// Focused subnodes are determined by the most recent focus ID in the `focus` vector. fn focused_subnode(&self) -> Option<&Node> { if self.is_leaf() || self.focus.is_empty() { return None; @@ -113,29 +133,28 @@ impl Node { .find(|n| n.id == *focus_id) } + /// Finds the deepest focused tabbed node in the tree that has multiple subnodes. fn find_deepest_focused_tabbed(&self) -> Option<&Node> { logging::debug!("T Iterated {}", self.to_string()); - if let Some(subnode) = self.focused_subnode() { - if self.get_layout() != Layout::OneVisible { - return Some(subnode); - } - return subnode.find_deepest_focused_tabbed(); + let subnode = self.focused_subnode()?; + match subnode.find_deepest_focused_tabbed() { + Some(tabnode) => Some(tabnode), + None if self.is_switchable_tabbed() => Some(self), + None => None } - None } + /// Finds the deepest focused node in the tree. fn find_deepest_focused(&self) -> Option<&Node> { logging::debug!("F Iterated {}", self.to_string()); - let subnode = self.focused_subnode(); - if subnode.is_some() { - let deepest = subnode?.find_deepest_focused(); - if deepest.is_some() { - return deepest; - } + let subnode = self.focused_subnode()?; + match subnode.find_deepest_focused() { + Some(deepest) => Some(deepest), + None => Some(subnode) } - subnode } + /// Returns the layout type of the node based on its type and layout fields. fn get_layout(&self) -> Layout { if self.is_content() || ["stacked", "tabbed"].contains(&self.layout.as_str()) { @@ -177,7 +196,6 @@ impl ToString for Node { #[cfg(test)] mod tests { use super::*; - use serde_json::json; fn read_json(file: &str) -> Node { let content = std::fs::read_to_string(file) From fd04a232937cc5a6534a5c4996f57c796da66349 Mon Sep 17 00:00:00 2001 From: Milosz Blizniak Date: Fri, 4 Jul 2025 21:43:41 +0200 Subject: [PATCH 4/5] build(rs): add schema test target --- rust/Makefile | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/rust/Makefile b/rust/Makefile index 5a754b0..277e735 100644 --- a/rust/Makefile +++ b/rust/Makefile @@ -31,9 +31,13 @@ dist/i3switch: target/x86_64-unknown-linux-gnu/release/i3switch cp target/x86_64-unknown-linux-gnu/release/i3switch dist/ .PHONY: test -test: Cargo.toml $(SOURCE_FILES) +test: schema Cargo.toml $(SOURCE_FILES) cargo test --all-features +.PHONY: schema +schema: + check-jsonschema --schemafile jsons/node.jsonschema jsons/*.json + .PHONY: all all: dist/i3switch From 41783905d655b8c3ffdba1187b5debaee0e7272c Mon Sep 17 00:00:00 2001 From: Milosz Blizniak Date: Fri, 4 Jul 2025 22:11:41 +0200 Subject: [PATCH 5/5] fix(rs): parse json once --- rust/src/backend/i3/backend.rs | 4 +--- rust/src/backend/i3/json.rs | 1 - 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/rust/src/backend/i3/backend.rs b/rust/src/backend/i3/backend.rs index 91edd9b..8144240 100644 --- a/rust/src/backend/i3/backend.rs +++ b/rust/src/backend/i3/backend.rs @@ -27,9 +27,7 @@ impl Backend { .expect_log("Failed to get i3 tree JSON"); // Parse the i3 tree to get the current workspace and window information - let root_value = json::from_str::(&root_string) - .expect_log("Failed to parse i3 tree JSON"); - let root: Node = json::from_value(root_value) + let root: Node = json::from_str(root_string.as_str()) .expect_log("Failed to convert i3 tree JSON to Node"); Self { client, diff --git a/rust/src/backend/i3/json.rs b/rust/src/backend/i3/json.rs index 2a5e34f..c0b54b2 100644 --- a/rust/src/backend/i3/json.rs +++ b/rust/src/backend/i3/json.rs @@ -62,7 +62,6 @@ impl Node { let mut nodes: Vec<&'a Node> = vec![]; nodes.extend(self.floating_nodes.iter()); self.nodes.iter().for_each(|subnode| { - println!("Subnode: {}", subnode.to_string()); nodes.extend(subnode.visible_nodes()); }); return nodes;