├── .gitignore ├── .rustfmt.toml ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── demo.gif ├── src ├── algorithm.rs ├── main.rs └── tree.rs └── usage.md /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /.rustfmt.toml: -------------------------------------------------------------------------------- 1 | single_line_if_else_max_width = 100 2 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "aho-corasick" 7 | version = "1.1.3" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" 10 | dependencies = [ 11 | "memchr", 12 | ] 13 | 14 | [[package]] 15 | name = "anstream" 16 | version = "0.6.15" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "64e15c1ab1f89faffbf04a634d5e1962e9074f2741eef6d97f3c4e322426d526" 19 | dependencies = [ 20 | "anstyle", 21 | "anstyle-parse", 22 | "anstyle-query", 23 | "anstyle-wincon", 24 | "colorchoice", 25 | "is_terminal_polyfill", 26 | "utf8parse", 27 | ] 28 | 29 | [[package]] 30 | name = "anstyle" 31 | version = "1.0.8" 32 | source = "registry+https://github.com/rust-lang/crates.io-index" 33 | checksum = "1bec1de6f59aedf83baf9ff929c98f2ad654b97c9510f4e70cf6f661d49fd5b1" 34 | 35 | [[package]] 36 | name = "anstyle-parse" 37 | version = "0.2.5" 38 | source = "registry+https://github.com/rust-lang/crates.io-index" 39 | checksum = "eb47de1e80c2b463c735db5b217a0ddc39d612e7ac9e2e96a5aed1f57616c1cb" 40 | dependencies = [ 41 | "utf8parse", 42 | ] 43 | 44 | [[package]] 45 | name = "anstyle-query" 46 | version = "1.1.1" 47 | source = "registry+https://github.com/rust-lang/crates.io-index" 48 | checksum = "6d36fc52c7f6c869915e99412912f22093507da8d9e942ceaf66fe4b7c14422a" 49 | dependencies = [ 50 | "windows-sys", 51 | ] 52 | 53 | [[package]] 54 | name = "anstyle-wincon" 55 | version = "3.0.4" 56 | source = "registry+https://github.com/rust-lang/crates.io-index" 57 | checksum = "5bf74e1b6e971609db8ca7a9ce79fd5768ab6ae46441c572e46cf596f59e57f8" 58 | dependencies = [ 59 | "anstyle", 60 | "windows-sys", 61 | ] 62 | 63 | [[package]] 64 | name = "colorchoice" 65 | version = "1.0.2" 66 | source = "registry+https://github.com/rust-lang/crates.io-index" 67 | checksum = "d3fd119d74b830634cea2a0f58bbd0d54540518a14397557951e79340abc28c0" 68 | 69 | [[package]] 70 | name = "env_filter" 71 | version = "0.1.2" 72 | source = "registry+https://github.com/rust-lang/crates.io-index" 73 | checksum = "4f2c92ceda6ceec50f43169f9ee8424fe2db276791afde7b2cd8bc084cb376ab" 74 | dependencies = [ 75 | "log", 76 | "regex", 77 | ] 78 | 79 | [[package]] 80 | name = "env_logger" 81 | version = "0.11.5" 82 | source = "registry+https://github.com/rust-lang/crates.io-index" 83 | checksum = "e13fa619b91fb2381732789fc5de83b45675e882f66623b7d8cb4f643017018d" 84 | dependencies = [ 85 | "anstream", 86 | "anstyle", 87 | "env_filter", 88 | "humantime", 89 | "log", 90 | ] 91 | 92 | [[package]] 93 | name = "humantime" 94 | version = "2.1.0" 95 | source = "registry+https://github.com/rust-lang/crates.io-index" 96 | checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" 97 | 98 | [[package]] 99 | name = "is_terminal_polyfill" 100 | version = "1.70.1" 101 | source = "registry+https://github.com/rust-lang/crates.io-index" 102 | checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" 103 | 104 | [[package]] 105 | name = "itoa" 106 | version = "1.0.11" 107 | source = "registry+https://github.com/rust-lang/crates.io-index" 108 | checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" 109 | 110 | [[package]] 111 | name = "log" 112 | version = "0.4.22" 113 | source = "registry+https://github.com/rust-lang/crates.io-index" 114 | checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" 115 | 116 | [[package]] 117 | name = "memchr" 118 | version = "2.7.4" 119 | source = "registry+https://github.com/rust-lang/crates.io-index" 120 | checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" 121 | 122 | [[package]] 123 | name = "proc-macro2" 124 | version = "1.0.86" 125 | source = "registry+https://github.com/rust-lang/crates.io-index" 126 | checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" 127 | dependencies = [ 128 | "unicode-ident", 129 | ] 130 | 131 | [[package]] 132 | name = "quote" 133 | version = "1.0.37" 134 | source = "registry+https://github.com/rust-lang/crates.io-index" 135 | checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" 136 | dependencies = [ 137 | "proc-macro2", 138 | ] 139 | 140 | [[package]] 141 | name = "regex" 142 | version = "1.11.0" 143 | source = "registry+https://github.com/rust-lang/crates.io-index" 144 | checksum = "38200e5ee88914975b69f657f0801b6f6dccafd44fd9326302a4aaeecfacb1d8" 145 | dependencies = [ 146 | "aho-corasick", 147 | "memchr", 148 | "regex-automata", 149 | "regex-syntax", 150 | ] 151 | 152 | [[package]] 153 | name = "regex-automata" 154 | version = "0.4.8" 155 | source = "registry+https://github.com/rust-lang/crates.io-index" 156 | checksum = "368758f23274712b504848e9d5a6f010445cc8b87a7cdb4d7cbee666c1288da3" 157 | dependencies = [ 158 | "aho-corasick", 159 | "memchr", 160 | "regex-syntax", 161 | ] 162 | 163 | [[package]] 164 | name = "regex-syntax" 165 | version = "0.8.5" 166 | source = "registry+https://github.com/rust-lang/crates.io-index" 167 | checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" 168 | 169 | [[package]] 170 | name = "ryu" 171 | version = "1.0.18" 172 | source = "registry+https://github.com/rust-lang/crates.io-index" 173 | checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" 174 | 175 | [[package]] 176 | name = "serde" 177 | version = "1.0.210" 178 | source = "registry+https://github.com/rust-lang/crates.io-index" 179 | checksum = "c8e3592472072e6e22e0a54d5904d9febf8508f65fb8552499a1abc7d1078c3a" 180 | dependencies = [ 181 | "serde_derive", 182 | ] 183 | 184 | [[package]] 185 | name = "serde_derive" 186 | version = "1.0.210" 187 | source = "registry+https://github.com/rust-lang/crates.io-index" 188 | checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f" 189 | dependencies = [ 190 | "proc-macro2", 191 | "quote", 192 | "syn", 193 | ] 194 | 195 | [[package]] 196 | name = "serde_json" 197 | version = "1.0.128" 198 | source = "registry+https://github.com/rust-lang/crates.io-index" 199 | checksum = "6ff5456707a1de34e7e37f2a6fd3d3f808c318259cbd01ab6377795054b483d8" 200 | dependencies = [ 201 | "itoa", 202 | "memchr", 203 | "ryu", 204 | "serde", 205 | ] 206 | 207 | [[package]] 208 | name = "sway-overfocus" 209 | version = "0.2.4" 210 | dependencies = [ 211 | "env_logger", 212 | "log", 213 | "swayipc", 214 | ] 215 | 216 | [[package]] 217 | name = "swayipc" 218 | version = "3.0.2" 219 | source = "registry+https://github.com/rust-lang/crates.io-index" 220 | checksum = "daa5d19f881f372e225095e297072e2e3ee1c4e9e3a46cafe5f5cf70f1313f29" 221 | dependencies = [ 222 | "serde", 223 | "serde_json", 224 | "swayipc-types", 225 | ] 226 | 227 | [[package]] 228 | name = "swayipc-types" 229 | version = "1.4.0" 230 | source = "registry+https://github.com/rust-lang/crates.io-index" 231 | checksum = "150570ddeab049ddd51930539fe05834054795d5db1a6735285e9534a5f56931" 232 | dependencies = [ 233 | "serde", 234 | "serde_json", 235 | "thiserror", 236 | ] 237 | 238 | [[package]] 239 | name = "syn" 240 | version = "2.0.79" 241 | source = "registry+https://github.com/rust-lang/crates.io-index" 242 | checksum = "89132cd0bf050864e1d38dc3bbc07a0eb8e7530af26344d3d2bbbef83499f590" 243 | dependencies = [ 244 | "proc-macro2", 245 | "quote", 246 | "unicode-ident", 247 | ] 248 | 249 | [[package]] 250 | name = "thiserror" 251 | version = "1.0.64" 252 | source = "registry+https://github.com/rust-lang/crates.io-index" 253 | checksum = "d50af8abc119fb8bb6dbabcfa89656f46f84aa0ac7688088608076ad2b459a84" 254 | dependencies = [ 255 | "thiserror-impl", 256 | ] 257 | 258 | [[package]] 259 | name = "thiserror-impl" 260 | version = "1.0.64" 261 | source = "registry+https://github.com/rust-lang/crates.io-index" 262 | checksum = "08904e7672f5eb876eaaf87e0ce17857500934f4981c4a0ab2b4aa98baac7fc3" 263 | dependencies = [ 264 | "proc-macro2", 265 | "quote", 266 | "syn", 267 | ] 268 | 269 | [[package]] 270 | name = "unicode-ident" 271 | version = "1.0.13" 272 | source = "registry+https://github.com/rust-lang/crates.io-index" 273 | checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" 274 | 275 | [[package]] 276 | name = "utf8parse" 277 | version = "0.2.2" 278 | source = "registry+https://github.com/rust-lang/crates.io-index" 279 | checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 280 | 281 | [[package]] 282 | name = "windows-sys" 283 | version = "0.52.0" 284 | source = "registry+https://github.com/rust-lang/crates.io-index" 285 | checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" 286 | dependencies = [ 287 | "windows-targets", 288 | ] 289 | 290 | [[package]] 291 | name = "windows-targets" 292 | version = "0.52.6" 293 | source = "registry+https://github.com/rust-lang/crates.io-index" 294 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 295 | dependencies = [ 296 | "windows_aarch64_gnullvm", 297 | "windows_aarch64_msvc", 298 | "windows_i686_gnu", 299 | "windows_i686_gnullvm", 300 | "windows_i686_msvc", 301 | "windows_x86_64_gnu", 302 | "windows_x86_64_gnullvm", 303 | "windows_x86_64_msvc", 304 | ] 305 | 306 | [[package]] 307 | name = "windows_aarch64_gnullvm" 308 | version = "0.52.6" 309 | source = "registry+https://github.com/rust-lang/crates.io-index" 310 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 311 | 312 | [[package]] 313 | name = "windows_aarch64_msvc" 314 | version = "0.52.6" 315 | source = "registry+https://github.com/rust-lang/crates.io-index" 316 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 317 | 318 | [[package]] 319 | name = "windows_i686_gnu" 320 | version = "0.52.6" 321 | source = "registry+https://github.com/rust-lang/crates.io-index" 322 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 323 | 324 | [[package]] 325 | name = "windows_i686_gnullvm" 326 | version = "0.52.6" 327 | source = "registry+https://github.com/rust-lang/crates.io-index" 328 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 329 | 330 | [[package]] 331 | name = "windows_i686_msvc" 332 | version = "0.52.6" 333 | source = "registry+https://github.com/rust-lang/crates.io-index" 334 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 335 | 336 | [[package]] 337 | name = "windows_x86_64_gnu" 338 | version = "0.52.6" 339 | source = "registry+https://github.com/rust-lang/crates.io-index" 340 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 341 | 342 | [[package]] 343 | name = "windows_x86_64_gnullvm" 344 | version = "0.52.6" 345 | source = "registry+https://github.com/rust-lang/crates.io-index" 346 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 347 | 348 | [[package]] 349 | name = "windows_x86_64_msvc" 350 | version = "0.52.6" 351 | source = "registry+https://github.com/rust-lang/crates.io-index" 352 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 353 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "sway-overfocus" 3 | version = "0.2.4" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [profile.release] 9 | lto = true 10 | strip = true 11 | 12 | [dependencies] 13 | log = "0.4.22" 14 | swayipc = "3.0.2" 15 | 16 | [target.'cfg(profile = "debug")'.dependencies] 17 | env_logger = "0.11.5" 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Troels Korreman Nielsen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `sway-overfocus` 2 | 3 | Alternative basic focus movement for the sway and i3 window managers. 4 | 5 | ![Demo GIF](demo.gif) 6 | 7 | The primary goal of this program is to 8 | create one set of keybinds exclusively for cycling through tabs/stacks, 9 | and another set exclusively for navigating between splits. 10 | This is accomplished by providing custom focus commands 11 | that target only specific layouts. 12 | The result is that switching focus generally can be performed in one action 13 | rather than some sequence of `focus parent` and `focus [direction]` actions. 14 | 15 | ## Installation instructions 16 | 17 | The project compiles to a standalone binary 18 | that interfaces with `sway` or `i3` over IPC. 19 | 20 | Download a [release](https://github.com/korreman/sway-overfocus/releases) 21 | or build with `cargo build --release` using `rustc` v1.59 or higher. 22 | Copy the binary (located in `./target/release` when building) 23 | to a location in your `$PATH`, 24 | fx. `~/.local/bin`. 25 | Then insert/replace keybinds to run `exec sway-overfocus ...` commands 26 | in your sway configuration. 27 | 28 | If you use the pacman package manager, an AUR package with the name [`sway-overfocus`](https://aur.archlinux.org/packages/sway-overfocus) is available as well. 29 | Note that the package is not maintained by the original author of the software. 30 | 31 | See the [usage](usage.md) page for details on constructing focus commands. 32 | The following config section is a good starting point, 33 | but commands can be configured granularly to suit your needs. 34 | 35 | bindsym $mod+h exec sway-overfocus split-lt float-lt output-ls 36 | bindsym $mod+j exec sway-overfocus split-dt float-dt output-ds 37 | bindsym $mod+k exec sway-overfocus split-ut float-ut output-us 38 | bindsym $mod+l exec sway-overfocus split-rt float-rt output-rs 39 | bindsym $mod+Tab exec sway-overfocus group-rw group-dw 40 | bindsym $mod+Shift+Tab exec sway-overfocus group-lw group-uw 41 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/korreman/sway-overfocus/5b03dca3de5d3351006a99c36e9493d1826de774/demo.gif -------------------------------------------------------------------------------- /src/algorithm.rs: -------------------------------------------------------------------------------- 1 | //! Neighbor-finding algorithm. 2 | use crate::tree::{closest_point, focus_idx, focus_local, Vec2}; 3 | use log::{debug, trace, warn}; 4 | use swayipc::{Node, NodeLayout, NodeType, Rect}; 5 | 6 | /// A target description for neighbor searching. 7 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 8 | pub struct Target { 9 | /// The kind of neighbor to find. 10 | pub kind: Kind, 11 | /// Whether to find the succeeding or preceding neighbor. 12 | pub backward: bool, 13 | /// Whether to search horizontally or vertically. 14 | pub vertical: bool, 15 | /// Moving-past-edge behavior. 16 | pub edge_mode: EdgeMode, 17 | } 18 | 19 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 20 | pub enum Kind { 21 | Split, 22 | Group, 23 | Float, 24 | Workspace, 25 | Output, 26 | } 27 | 28 | /// Describes what to do when attempting to move past the last or first child of a container. 29 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 30 | pub enum EdgeMode { 31 | /// Do nothing, don't change focus. 32 | Stop, 33 | /// Wrap around and focus the first or last child. 34 | Wrap, 35 | /// Spill over, focus the closest descendant in new parent. 36 | Traverse, 37 | /// Spill over, focus the inactive-focus child of the new parent. 38 | Inactive, 39 | } 40 | 41 | /// Find a neighbor matching one of the `targets`. 42 | pub fn neighbor<'a>(mut t: &'a Node, targets: &[Target]) -> Option<&'a Node> { 43 | // Generate the focus path as a list of ancestors 44 | debug!("Finding focus path"); 45 | let mut path = Vec::new(); 46 | while !t.focused { 47 | debug!("Node {}", t.id); 48 | path.push(t); 49 | if let Some(new_t) = focus_local(t) { 50 | t = new_t; 51 | } else { 52 | warn!("No focused child, incomplete focus path"); 53 | break; 54 | } 55 | } 56 | debug!("Searching focus path bottom-up for neighbor"); 57 | let neighbor = path.iter().rev().find_map(|parent| { 58 | debug!("Parent {}", parent.id); 59 | let target = match_targets(parent, targets)?; 60 | trace!("Matched {target:?}"); 61 | let n = neighbor_local(parent, &target); 62 | if target.edge_mode == EdgeMode::Stop { 63 | debug!("Target is stopping, forcing return"); 64 | Some(n) // `Some(None)` can stop the search without a result 65 | } else { 66 | n.map(Some) 67 | } 68 | })??; 69 | debug!("Found neighbor {}, selecting descendant", neighbor.id); 70 | Some(select_leaf(neighbor, targets)) 71 | } 72 | 73 | /// Finds a parent that contains direct children matching one of the `targets`. 74 | fn match_targets(node: &Node, targets: &[Target]) -> Option { 75 | let focus = *node.focus.first()?; 76 | let float_focused = node.floating_nodes.iter().any(|c| c.id == focus); 77 | let res = *targets.iter().find(|target| match target.kind { 78 | // Note that we match with a suitable _parent type_ for the target 79 | Kind::Output => node.node_type == NodeType::Root, 80 | Kind::Workspace => node.node_type == NodeType::Output, 81 | Kind::Split => { 82 | !float_focused 83 | && (!target.vertical && node.layout == NodeLayout::SplitH 84 | || target.vertical && node.layout == NodeLayout::SplitV) 85 | } 86 | Kind::Group => { 87 | !float_focused 88 | && (!target.vertical && node.layout == NodeLayout::Tabbed 89 | || target.vertical && node.layout == NodeLayout::Stacked) 90 | } 91 | Kind::Float => float_focused, 92 | })?; 93 | Some(res) 94 | } 95 | 96 | /// Attempt to find a neighbor of the focused child `node`, 97 | /// according to the given target. 98 | fn neighbor_local<'a>(node: &'a Node, target: &Target) -> Option<&'a Node> { 99 | let (focus_idx, children) = focus_idx(node)?; 100 | 101 | if target.kind == Kind::Float || target.kind == Kind::Output { 102 | let focus_id = *node.focus.first()?; 103 | let focused = &children[focus_idx]; 104 | trace!("Focused {:?}", focused.rect); 105 | 106 | // Selects x or y component of a rect based on whether target is horizontal or vertical 107 | let component = |r: &Rect| if target.vertical { (r.y, r.height) } else { (r.x, r.width) }; 108 | 109 | // Computes a distance to the focused node. 110 | // Handles directions and filters out irrelevant neighbors. 111 | let dist = |t: &Node, flip: bool| -> Option<(i32, i64)> { 112 | trace!("Computing distance to {}", t.id); 113 | if t.id == focus_id { return None; } 114 | let (a, b) = if flip { (&t.rect, &focused.rect) } else { (&focused.rect, &t.rect) }; 115 | let ((a_pos, a_dim), (b_pos, b_dim)) = (component(a), component(b)); 116 | let (a_mid, b_mid) = (a_pos + a_dim / 2, b_pos + b_dim / 2); 117 | let a_edge = a_pos + a_dim; 118 | trace!("A-component: ({a_pos}, {a_dim}), B-component: ({b_pos}, {b_dim})"); 119 | trace!("A-edge: {a_edge}, A-middle: {a_mid}, B middle: {b_mid}"); 120 | 121 | let dist = match target.kind { 122 | // Floats are compared by distance of centers on relevant axis 123 | Kind::Float if a_mid < b_mid || (a_mid == b_mid && flip == (t.id > focus_id)) => { 124 | Some((b_mid - a_mid).saturating_abs()) 125 | } 126 | // Outputs are compared by euclidean distance to center of focused node 127 | Kind::Output if a_edge <= b_pos => { 128 | let c = Vec2 { 129 | x: focused.rect.x + focused.rect.width / 2, 130 | y: focused.rect.y + focused.rect.height / 2, 131 | }; 132 | let p = closest_point(&t.rect, &c); 133 | Some((c.x - p.x) * (c.x - p.x) + (c.y - p.y) * (c.y - p.y)) 134 | } 135 | _ => None, 136 | }?; 137 | trace!("Distance: {dist}"); 138 | Some((dist, if flip { t.id } else { -t.id })) 139 | }; 140 | // Select the closest neighbor to focused child, 141 | // or furthest in the opposite direction if wrapping. 142 | let mut res = children 143 | .iter() 144 | .filter_map(|n| Some((dist(n, target.backward)?, n))) 145 | .min_by_key(|(d, _)| *d) 146 | .map(|(_, node)| node); 147 | if res.is_none() && target.edge_mode == EdgeMode::Wrap { 148 | trace!("No neighbor, searching for wraparound target"); 149 | let wrap_target = children 150 | .iter() 151 | .filter_map(|n| Some((dist(n, !target.backward)?, n))) 152 | .max_by_key(|(d, _)| *d) 153 | .map(|(_, node)| node); 154 | // Also include focused container as a last resort. 155 | // This allows nice interaction between [EdgeMode::Traverse] and [EdgeMode::Wrap]. 156 | res = wrap_target.or(Some(focused)); 157 | } 158 | res 159 | } else { 160 | trace!("Selecting neighbor by index"); 161 | let len = children.len(); 162 | trace!("Focused subnode index: {focus_idx} out of {}", len - 1); 163 | // Other target kinds can be chosen by index, disregarding verticality 164 | let idx = focus_idx + len; // Offset by length to avoid underflow 165 | let idx = if target.backward { idx - 1 } else { idx + 1 }; 166 | let idx = if target.edge_mode == EdgeMode::Wrap { 167 | // If wrapping, calculate modulo the number of children 168 | Some(idx % len) 169 | } else if len <= idx && idx < len * 2 { 170 | // Otherwise perform a range check and negate offset 171 | Some(idx - len) 172 | } else { 173 | None 174 | }; 175 | trace!("Resulting index: {idx:?}"); 176 | idx.map(|idx| &children[idx]) 177 | } 178 | } 179 | 180 | /// Find a leaf in a (presumed) neighboring container, respecting target edge-modes 181 | fn select_leaf<'a>(mut t: &'a Node, targets: &[Target]) -> &'a Node { 182 | loop { 183 | debug!("Node {}", t.id); 184 | // Match the current node with targets 185 | let target = match_targets(t, targets); 186 | let new_t = match target { 187 | // If the target has [EdgeMode::Traverse], 188 | // choose the closest neighbor to focused node. 189 | // Fx. if moving right, the left-most child is selected. 190 | Some(target) if target.edge_mode == EdgeMode::Traverse => { 191 | trace!("Matched traversing {:?}", target.kind); 192 | // For floats, this requires comparing geometry 193 | if target.kind == Kind::Float { 194 | trace!("Float container, selecting left/right/top/bottom-most child"); 195 | let key = |n: &&Node| { 196 | let center = if target.vertical { 197 | n.rect.y + n.rect.height / 2 198 | } else { 199 | n.rect.x + n.rect.width / 2 200 | }; 201 | (center, -n.id) 202 | }; 203 | if target.backward { 204 | t.floating_nodes.iter().max_by_key(key) 205 | } else { 206 | t.floating_nodes.iter().min_by_key(key) 207 | } 208 | // NOTE: We don't handle outputs, as we will never move from one `Root` to another. 209 | // For other container types, we can just select the first or last. 210 | } else if target.backward { 211 | t.nodes.last() 212 | } else { 213 | t.nodes.first() 214 | } 215 | } 216 | _ => focus_local(t), 217 | }; 218 | // Keep selecting children until we reach a leaf 219 | if let Some(new_t) = new_t { 220 | t = new_t; 221 | } else { 222 | break; 223 | } 224 | } 225 | debug!("Selected leaf {}", t.id); 226 | t 227 | } 228 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use log::info; 2 | use swayipc::Connection; 3 | 4 | mod algorithm; 5 | use algorithm::{EdgeMode, Kind, Target}; 6 | mod tree; 7 | 8 | #[derive(Debug)] 9 | enum FocusError { 10 | Args, 11 | Command, 12 | SwayIPC(swayipc::Error), 13 | } 14 | 15 | fn main() { 16 | #[cfg(profile = "debug")] 17 | env_logger::init(); 18 | 19 | match task() { 20 | Err(e) => { 21 | match e { 22 | FocusError::Args => eprint!("{}", include_str!("../usage.md")), 23 | FocusError::Command => eprintln!("error: no valid focus command"), 24 | FocusError::SwayIPC(e) => eprintln!("swayipc error: {e}"), 25 | }; 26 | std::process::exit(1); 27 | } 28 | Ok(()) => (), 29 | } 30 | } 31 | 32 | fn task() -> Result<(), FocusError> { 33 | info!("Parsing arguments"); 34 | let args: Box<[String]> = std::env::args().collect(); 35 | let targets = parse_args(&args).ok_or(FocusError::Args)?; 36 | 37 | info!("Starting connection"); 38 | let mut c = Connection::new().map_err(FocusError::SwayIPC)?; 39 | info!("Retrieving tree"); 40 | let tree = c.get_tree().map_err(FocusError::SwayIPC)?; 41 | info!("Pre-processing tree"); 42 | let tree = tree::preprocess(tree); 43 | 44 | info!("Searching for neighbor"); 45 | let neighbor = algorithm::neighbor(&tree, &targets); 46 | if let Some(neighbor) = neighbor { 47 | let focus_cmd = tree::focus_command(neighbor).ok_or(FocusError::Command)?; 48 | info!("Running focus command: '{focus_cmd}'"); 49 | c.run_command(focus_cmd).map_err(FocusError::SwayIPC)?; 50 | } else { 51 | info!("No neighbor found"); 52 | } 53 | Ok(()) 54 | } 55 | 56 | fn parse_args(args: &[String]) -> Option> { 57 | if args.len() < 2 { 58 | return None; 59 | } 60 | 61 | args[1..] 62 | .iter() 63 | .map(|arg| { 64 | let (target_name, mode_chars) = arg.split_once('-')?; 65 | let kind = match target_name { 66 | "split" => Some(Kind::Split), 67 | "group" => Some(Kind::Group), 68 | "float" => Some(Kind::Float), 69 | "workspace" => Some(Kind::Workspace), 70 | "output" => Some(Kind::Output), 71 | _ => None, 72 | }?; 73 | let mut mode_chars = mode_chars.chars(); 74 | let (backward, vertical) = match mode_chars.next()? { 75 | 'r' => Some((false, false)), 76 | 'l' => Some((true, false)), 77 | 'd' => Some((false, true)), 78 | 'u' => Some((true, true)), 79 | _ => None, 80 | }?; 81 | let edge_mode = match mode_chars.next()? { 82 | 's' => Some(EdgeMode::Stop), 83 | 'w' => Some(EdgeMode::Wrap), 84 | 't' => Some(EdgeMode::Traverse), 85 | 'i' => Some(EdgeMode::Inactive), 86 | _ => None, 87 | }?; 88 | Some(Target { 89 | kind, 90 | backward, 91 | vertical, 92 | edge_mode, 93 | }) 94 | }) 95 | .collect() 96 | } 97 | -------------------------------------------------------------------------------- /src/tree.rs: -------------------------------------------------------------------------------- 1 | //! Basic tree functions and pre-processing 2 | use log::{debug, trace}; 3 | use std::mem; 4 | use swayipc::{Node, NodeLayout, NodeType, Rect}; 5 | 6 | /// Closest point to `p` within `rect`. 7 | pub fn closest_point(rect: &Rect, p: &Vec2) -> Vec2 { 8 | Vec2 { 9 | x: i32::clamp(p.x, rect.x, rect.x + rect.width - 1), 10 | y: i32::clamp(p.y, rect.y, rect.y + rect.height - 1), 11 | } 12 | } 13 | 14 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 15 | pub struct Vec2 { 16 | pub x: i32, 17 | pub y: i32, 18 | } 19 | 20 | /// Generate a command that will focus `node`. 21 | pub fn focus_command(node: &Node) -> Option { 22 | let name = node.name.clone(); 23 | match node.node_type { 24 | NodeType::Root => None, 25 | NodeType::Output => Some(format!("focus output {}", name?)), 26 | NodeType::Workspace => Some(format!("workspace {}", name?)), 27 | _ => Some(format!("[con_id={}] focus", node.id)), 28 | } 29 | } 30 | 31 | /// Return the focused child, if any. 32 | pub fn focus_local(node: &Node) -> Option<&Node> { 33 | let focus = *node.focus.first()?; 34 | node.nodes 35 | .iter() 36 | .chain(node.floating_nodes.iter()) 37 | .find(|child| child.id == focus) 38 | } 39 | 40 | /// Compute the index (_not_ identifier) of the focused node in child array, if any. 41 | /// Also returns the vector of children to index into (either regular nodes or floats). 42 | pub fn focus_idx(node: &Node) -> Option<(usize, &Vec)> { 43 | let focus = *node.focus.first()?; 44 | for children in [&node.nodes, &node.floating_nodes] { 45 | for (index, child) in children.iter().enumerate() { 46 | if child.id == focus { 47 | return Some((index, children)); 48 | } 49 | } 50 | } 51 | None 52 | } 53 | 54 | /// Reform the tree to prepare for neighbor searching 55 | /// This mainly consists of collapsing i3 outputs with `content` subnodes 56 | /// and workspaces with fullscreen descendants 57 | pub fn preprocess(mut node: Node) -> Node { 58 | node.layout = NodeLayout::None; 59 | // Remove scratchpad and potential similar output nodes 60 | node.nodes 61 | .retain(|node| node.name.as_ref().map(|name| name.starts_with("__i3")) != Some(true)); 62 | 63 | for output in node.nodes.iter_mut() { 64 | debug!( 65 | "Output '{}', ID {}", 66 | output.name.as_ref().unwrap_or(&"".to_string()), 67 | output.id, 68 | ); 69 | 70 | // On i3, outputs contain a `content` subnode containing workspaces. 71 | // If this is the case, replace the children of the output with those of the `content` node. 72 | if let Some(content) = output 73 | .nodes 74 | .iter_mut() 75 | .find(|node| node.name.as_ref() == Some(&"content".to_string())) 76 | { 77 | trace!("Found 'content' subnode, collapsing"); 78 | output.focus = mem::take(&mut content.focus); 79 | output.nodes = mem::take(&mut content.nodes); 80 | } 81 | 82 | // Reform workspaces 83 | for workspace in output.nodes.iter_mut() { 84 | debug!( 85 | "Workspace '{}', ID {}", 86 | workspace.name.as_ref().unwrap_or(&"".to_string()), 87 | workspace.id, 88 | ); 89 | // Collapse nodes with fullscreen descendants 90 | if let Some(fullscreen_node) = extract_fullscreen_child(workspace) { 91 | debug!( 92 | "Node {} has fullscreen mode {}", 93 | fullscreen_node.id, 94 | fullscreen_node.fullscreen_mode.unwrap() 95 | ); 96 | // If the node is global fullscreen, it replaces the entire tree 97 | if fullscreen_node.fullscreen_mode == Some(2) { 98 | trace!("Replacing entire tree"); 99 | return fullscreen_node; 100 | } 101 | // Otherwise, it replaces the workspace 102 | if output.focus.first() == Some(&workspace.id) { 103 | // We may potentially have to change parent focus 104 | output.focus = vec![fullscreen_node.id]; 105 | } 106 | *workspace = fullscreen_node; 107 | } 108 | } 109 | } 110 | node 111 | } 112 | 113 | /// Search the tree for a fullscreen descendant. 114 | /// If found, the descendant is detached and returned. 115 | /// Neighbors of the descendant are detached and dropped as collateral. 116 | pub fn extract_fullscreen_child(node: &mut Node) -> Option { 117 | let mut children = node.nodes.iter_mut().chain(node.floating_nodes.iter_mut()); 118 | let pred = |child: &Node| child.fullscreen_mode == Some(1) || child.fullscreen_mode == Some(2); 119 | if children.any(|c| pred(c)) { 120 | let nodes = mem::take(&mut node.nodes); 121 | let floating_nodes = mem::take(&mut node.floating_nodes); 122 | let mut children = nodes.into_iter().chain(floating_nodes.into_iter()); 123 | children.find(pred) 124 | } else { 125 | node.nodes.iter_mut().find_map(extract_fullscreen_child) 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /usage.md: -------------------------------------------------------------------------------- 1 | Syntax: 2 | 3 | sway-overfocus 4 | 5 | Targets: 6 | 7 | {split|group|float|output}-{u|d|l|r}{s|w|t|i} 8 | 9 | Layout: 10 | 11 | split - horizontal and vertical splits 12 | group - tabs (horizontal) and stacks (vertical) 13 | float - floating containers 14 | workspace - workspaces, right/down is next, left/up is previous 15 | output - outputs 16 | 17 | Direction: 18 | 19 | u - up 20 | d - down 21 | l - left 22 | r - right 23 | 24 | Edge action: 25 | 26 | s - stop, do nothing 27 | w - wraparound to first or last container 28 | i - spill over and focus the inactive focus of container adjacent to parent 29 | t - spill over and traverse (focus the container closest to the current) 30 | 31 | sway-overfocus runs a focus command that only considers the specified targets 32 | while ignoring all other containers. Each target consists of a layout type, 33 | a direction, and an edge case behavior. 34 | 35 | Example: 36 | 37 | sway-overfocus split-lt float-lt output-ls 38 | 39 | This command will move left, though only between splits, floats, and outputs. 40 | Tabs will be skipped, and a visible container physically left of the current one 41 | will be focused instead. 42 | --------------------------------------------------------------------------------