From a3eedb414fe4db5670f8de7f0e2c43636e9c8432 Mon Sep 17 00:00:00 2001 From: Mohammad Rafiq Date: Sat, 21 Feb 2026 10:31:19 +0800 Subject: [PATCH] feat(hyprctl): add 1:2 split CLI and package in Nix --- nix/modules/aliases.nix | 1 + nix/outputs/packages.nix | 56 ++- rs/Cargo.lock | 7 + rs/Cargo.toml | 2 +- rs/hyprctl-split/Cargo.toml | 13 + rs/hyprctl-split/src/main.rs | 438 ++++++++++++++++++ ...2026-02-21-hyprctl-workspace-inspection.md | 101 ++++ 7 files changed, 597 insertions(+), 21 deletions(-) create mode 100644 rs/hyprctl-split/Cargo.toml create mode 100644 rs/hyprctl-split/src/main.rs create mode 100644 sessions/2026-02-21-hyprctl-workspace-inspection.md diff --git a/nix/modules/aliases.nix b/nix/modules/aliases.nix index 3faa901c..ccaa8e28 100644 --- a/nix/modules/aliases.nix +++ b/nix/modules/aliases.nix @@ -8,6 +8,7 @@ in { pkgs, ... }: { home.packages = [ + config.flake.packages.${pkgs.stdenv.hostPlatform.system}.hyprctl-split (pkgs.writeShellScriptBin "process" (lib.fileContents (cfg.paths.root + "/scripts/process.sh"))) ]; diff --git a/nix/outputs/packages.nix b/nix/outputs/packages.nix index f594501d..5d149413 100644 --- a/nix/outputs/packages.nix +++ b/nix/outputs/packages.nix @@ -3,28 +3,44 @@ perSystem = { pkgs, self', ... }: { - packages.site-bin = pkgs.rustPlatform.buildRustPackage { - name = "site"; - src = config.flake.paths.root + /rs; - cargoLock.lockFile = config.flake.paths.root + /rs/Cargo.lock; - }; - packages.site-image = pkgs.dockerTools.buildLayeredImage { - name = "site"; - tag = "latest"; - contents = [ - self'.packages.site-bin - pkgs.dockerTools.binSh - ]; - config = { - Env = [ - "SITE_CONTENT_DIR=${inputs.site-content}" - "STATIC_DIR=${config.flake.paths.root + /rs/site/static}" + packages = { + hyprctl-split = pkgs.rustPlatform.buildRustPackage { + pname = "hyprctl-split"; + version = "0.1.0"; + src = config.flake.paths.root + /rs; + cargoLock.lockFile = config.flake.paths.root + /rs/Cargo.lock; + cargoBuildFlags = [ + "-p" + "hyprctl-split" + ]; + cargoTestFlags = [ + "-p" + "hyprctl-split" ]; - Entrypoint = [ - "/bin/sh" - "-c" + }; + site-bin = pkgs.rustPlatform.buildRustPackage { + name = "site"; + src = config.flake.paths.root + /rs; + cargoLock.lockFile = config.flake.paths.root + /rs/Cargo.lock; + }; + site-image = pkgs.dockerTools.buildLayeredImage { + name = "site"; + tag = "latest"; + contents = [ + self'.packages.site-bin + pkgs.dockerTools.binSh ]; - Cmd = [ "/bin/site" ]; + config = { + Env = [ + "SITE_CONTENT_DIR=${inputs.site-content}" + "STATIC_DIR=${config.flake.paths.root + /rs/site/static}" + ]; + Entrypoint = [ + "/bin/sh" + "-c" + ]; + Cmd = [ "/bin/site" ]; + }; }; }; }; diff --git a/rs/Cargo.lock b/rs/Cargo.lock index 6fe5038b..ec018f2e 100644 --- a/rs/Cargo.lock +++ b/rs/Cargo.lock @@ -428,6 +428,13 @@ dependencies = [ "tower-service", ] +[[package]] +name = "hyprctl-split" +version = "0.1.0" +dependencies = [ + "serde_json", +] + [[package]] name = "iana-time-zone" version = "0.1.64" diff --git a/rs/Cargo.toml b/rs/Cargo.toml index fcf6460b..406bf666 100644 --- a/rs/Cargo.toml +++ b/rs/Cargo.toml @@ -1,3 +1,3 @@ [workspace] resolver = "3" -members = ["site"] +members = ["hyprctl-split", "site"] diff --git a/rs/hyprctl-split/Cargo.toml b/rs/hyprctl-split/Cargo.toml new file mode 100644 index 00000000..a2901c7d --- /dev/null +++ b/rs/hyprctl-split/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "hyprctl-split" +version = "0.1.0" +edition = "2024" +description = "Set 1:2 split for two Hyprland windows" +readme = "../../README.md" +repository = "https://github.com/rrvsh/tools" +license = "MIT" +keywords = ["hyprland", "hyprctl", "cli"] +categories = ["command-line-utilities"] + +[dependencies] +serde_json = "1.0" diff --git a/rs/hyprctl-split/src/main.rs b/rs/hyprctl-split/src/main.rs new file mode 100644 index 00000000..84cf2b21 --- /dev/null +++ b/rs/hyprctl-split/src/main.rs @@ -0,0 +1,438 @@ +use std::cmp::Ordering; +use std::env; +use std::process::Command; + +use serde_json::Value; + +#[derive(Debug, Clone)] +struct WindowInfo { + address: String, + title: String, + class_name: String, + x: i64, + y: i64, + width: i64, + height: i64, + floating: bool, + fullscreen: i64, +} + +#[derive(Debug, Clone)] +struct ActiveWorkspace { + id: i64, + monitor: String, + windows: i64, +} + +#[derive(Debug, Clone)] +struct MonitorInfo { + name: String, + width_px: i64, + height_px: i64, + scale: f64, + x: i64, + y: i64, +} + +#[derive(Debug, Clone)] +struct DesiredSplit { + left_width: i64, + right_width: i64, + separator: i64, +} + +fn main() { + if let Err(err) = run() { + eprintln!("error: {err}"); + std::process::exit(1); + } +} + +fn run() -> Result<(), String> { + let dry_run = env::args().any(|arg| arg == "--dry-run"); + + let active_workspace = get_active_workspace()?; + let layout = get_option_string("general:layout")?; + let gaps_in = get_option_custom("general:gaps_in")?; + let gaps_out = get_option_custom("general:gaps_out")?; + let border_size = get_option_int("general:border_size")?; + let monitor = get_monitor(&active_workspace.monitor)?; + + let mut workspace_windows = get_windows_for_workspace(active_workspace.id)?; + if workspace_windows.len() != 2 { + return Err(format!( + "expected exactly 2 mapped windows on active workspace {}, found {}", + active_workspace.id, + workspace_windows.len() + )); + } + + workspace_windows.sort_by(|a, b| { + let by_x = a.x.cmp(&b.x); + if by_x == Ordering::Equal { + a.y.cmp(&b.y) + } else { + by_x + } + }); + + let left = workspace_windows[0].clone(); + let right = workspace_windows[1].clone(); + + if left.fullscreen != 0 || right.fullscreen != 0 { + return Err("cannot resize while a target window is fullscreen".to_owned()); + } + + let current_separator = right.x - (left.x + left.width); + let combined_window_width = left.width + right.width; + if combined_window_width <= 0 { + return Err("invalid total width for workspace windows".to_owned()); + } + + let desired = compute_desired_split(combined_window_width, current_separator)?; + + println!("hyprctl-split plan"); + println!( + "- active workspace: {} (monitor {})", + active_workspace.id, active_workspace.monitor + ); + println!( + "- active workspace reports window count: {}", + active_workspace.windows + ); + println!( + "- monitor logical size (from hyprctl): {} {}x{} (physical {}x{}, scale {:.2})", + monitor.name, + logical_dimension(monitor.width_px, monitor.scale), + logical_dimension(monitor.height_px, monitor.scale), + monitor.width_px, + monitor.height_px, + monitor.scale, + ); + println!("- monitor origin: {},{}", monitor.x, monitor.y); + println!("- layout: {layout}"); + println!("- gaps_in: {gaps_in}"); + println!("- gaps_out: {gaps_out}"); + println!("- border_size: {border_size}"); + println!( + "- left window: {} [{}] addr={} pos=({}, {}) size={}x{} floating={}", + left.title, + left.class_name, + left.address, + left.x, + left.y, + left.width, + left.height, + left.floating, + ); + println!( + "- right window: {} [{}] addr={} pos=({}, {}) size={}x{} floating={}", + right.title, + right.class_name, + right.address, + right.x, + right.y, + right.width, + right.height, + right.floating, + ); + println!("- detected separator: {} px", desired.separator); + println!( + "- target widths: left={} right={} (ratio {:.3}:{:.3})", + desired.left_width, + desired.right_width, + desired.left_width as f64 / (desired.left_width + desired.right_width) as f64, + desired.right_width as f64 / (desired.left_width + desired.right_width) as f64, + ); + + if dry_run { + println!("- mode: dry-run (no changes applied)"); + return Ok(()); + } + + apply_resize(&left, desired.left_width)?; + verify_result(active_workspace.id, desired.left_width, desired.right_width)?; + + println!("- status: applied"); + Ok(()) +} + +fn verify_result(workspace_id: i64, expected_left: i64, expected_right: i64) -> Result<(), String> { + let mut windows = get_windows_for_workspace(workspace_id)?; + windows.sort_by(|a, b| { + let by_x = a.x.cmp(&b.x); + if by_x == Ordering::Equal { + a.y.cmp(&b.y) + } else { + by_x + } + }); + + if windows.len() != 2 { + return Err(format!( + "post-apply verification failed: expected 2 windows, found {}", + windows.len() + )); + } + + let left = &windows[0]; + let right = &windows[1]; + let left_ok = approx_equal(left.width, expected_left, 2); + let right_ok = approx_equal(right.width, expected_right, 2); + + println!( + "- verify: left={} right={} (expected {} / {})", + left.width, right.width, expected_left, expected_right + ); + + if left_ok && right_ok { + return Ok(()); + } + + Err( + "post-apply verification failed: window widths differ from target beyond tolerance" + .to_owned(), + ) +} + +fn approx_equal(actual: i64, expected: i64, tolerance: i64) -> bool { + (actual - expected).abs() <= tolerance +} + +fn apply_resize(left: &WindowInfo, target_width: i64) -> Result<(), String> { + let addr = format!("{},address:{}", left.height, left.address); + let width = target_width.to_string(); + run_hyprctl(&["dispatch", "resizewindowpixel", "exact", &width, &addr])?; + Ok(()) +} + +fn compute_desired_split(combined_width: i64, separator: i64) -> Result { + if combined_width < 3 { + return Err("combined width too small for 1/3:2/3 split".to_owned()); + } + + let left_width = ((combined_width as f64) / 3.0).round() as i64; + let right_width = combined_width - left_width; + if left_width <= 0 || right_width <= 0 { + return Err("computed invalid split widths".to_owned()); + } + + Ok(DesiredSplit { + left_width, + right_width, + separator, + }) +} + +fn get_active_workspace() -> Result { + let value = run_hyprctl_json(&["activeworkspace"])?; + Ok(ActiveWorkspace { + id: get_i64(&value, "id")?, + monitor: get_string(&value, "monitor")?, + windows: get_i64(&value, "windows")?, + }) +} + +fn get_monitor(name: &str) -> Result { + let value = run_hyprctl_json(&["monitors"])?; + let monitors = value + .as_array() + .ok_or_else(|| "hyprctl monitors json is not an array".to_owned())?; + + for monitor in monitors { + if monitor.get("name").and_then(Value::as_str) == Some(name) { + return Ok(MonitorInfo { + name: get_string(monitor, "name")?, + width_px: get_i64(monitor, "width")?, + height_px: get_i64(monitor, "height")?, + scale: get_f64(monitor, "scale")?, + x: get_i64(monitor, "x")?, + y: get_i64(monitor, "y")?, + }); + } + } + + Err(format!( + "active monitor {name} not found in hyprctl monitors" + )) +} + +fn get_windows_for_workspace(workspace_id: i64) -> Result, String> { + let value = run_hyprctl_json(&["clients"])?; + let clients = value + .as_array() + .ok_or_else(|| "hyprctl clients json is not an array".to_owned())?; + + let mut windows = Vec::new(); + for client in clients { + let ws = client + .get("workspace") + .and_then(Value::as_object) + .ok_or_else(|| "client has no workspace object".to_owned())?; + let ws_id = ws + .get("id") + .and_then(Value::as_i64) + .ok_or_else(|| "workspace has no numeric id".to_owned())?; + if ws_id != workspace_id { + continue; + } + + let mapped = get_bool(client, "mapped")?; + let hidden = get_bool(client, "hidden")?; + if !mapped || hidden { + continue; + } + + let at = get_pair(client, "at")?; + let size = get_pair(client, "size")?; + + windows.push(WindowInfo { + address: get_string(client, "address")?, + title: get_string(client, "title")?, + class_name: get_string(client, "class")?, + x: at.0, + y: at.1, + width: size.0, + height: size.1, + floating: get_bool(client, "floating")?, + fullscreen: get_i64(client, "fullscreen")?, + }); + } + + Ok(windows) +} + +fn get_option_string(option: &str) -> Result { + let value = run_hyprctl_json(&["getoption", option])?; + get_string(&value, "str") +} + +fn get_option_custom(option: &str) -> Result { + let value = run_hyprctl_json(&["getoption", option])?; + get_string(&value, "custom") +} + +fn get_option_int(option: &str) -> Result { + let value = run_hyprctl_json(&["getoption", option])?; + get_i64(&value, "int") +} + +fn run_hyprctl_json(args: &[&str]) -> Result { + let mut full_args = Vec::with_capacity(args.len() + 1); + full_args.push("-j"); + full_args.extend(args); + + let output = run_hyprctl(&full_args)?; + serde_json::from_str(&output).map_err(|err| format!("failed to parse hyprctl json: {err}")) +} + +fn run_hyprctl(args: &[&str]) -> Result { + let output = Command::new("hyprctl") + .args(args) + .output() + .map_err(|err| format!("failed to execute hyprctl {:?}: {err}", args))?; + + if output.status.success() { + return String::from_utf8(output.stdout) + .map_err(|err| format!("hyprctl output was not utf8: {err}")); + } + + let stderr = + String::from_utf8(output.stderr).unwrap_or_else(|_| "".to_owned()); + let stdout = + String::from_utf8(output.stdout).unwrap_or_else(|_| "".to_owned()); + Err(format!( + "hyprctl {:?} failed with status {}. stdout: {} stderr: {}", + args, + output.status, + stdout.trim(), + stderr.trim() + )) +} + +fn logical_dimension(px: i64, scale: f64) -> i64 { + if scale <= 0.0 { + return px; + } + ((px as f64) / scale).round() as i64 +} + +fn get_pair(value: &Value, key: &str) -> Result<(i64, i64), String> { + let arr = value + .get(key) + .and_then(Value::as_array) + .ok_or_else(|| format!("missing or invalid array field `{key}`"))?; + if arr.len() != 2 { + return Err(format!("array field `{key}` does not have 2 elements")); + } + + let first = arr[0] + .as_i64() + .ok_or_else(|| format!("array field `{key}` first element is not integer"))?; + let second = arr[1] + .as_i64() + .ok_or_else(|| format!("array field `{key}` second element is not integer"))?; + Ok((first, second)) +} + +fn get_string(value: &Value, key: &str) -> Result { + value + .get(key) + .and_then(Value::as_str) + .map(ToOwned::to_owned) + .ok_or_else(|| format!("missing or invalid string field `{key}`")) +} + +fn get_i64(value: &Value, key: &str) -> Result { + value + .get(key) + .and_then(Value::as_i64) + .ok_or_else(|| format!("missing or invalid integer field `{key}`")) +} + +fn get_f64(value: &Value, key: &str) -> Result { + value + .get(key) + .and_then(Value::as_f64) + .ok_or_else(|| format!("missing or invalid float field `{key}`")) +} + +fn get_bool(value: &Value, key: &str) -> Result { + value + .get(key) + .and_then(Value::as_bool) + .ok_or_else(|| format!("missing or invalid bool field `{key}`")) +} + +#[cfg(test)] +mod tests { + use super::{approx_equal, compute_desired_split, logical_dimension}; + + #[test] + fn split_rounds_to_nearest_pixel() { + let desired = compute_desired_split(1916, 2).expect("split should be computable"); + assert_eq!(desired.left_width, 639); + assert_eq!(desired.right_width, 1277); + assert_eq!(desired.separator, 2); + } + + #[test] + fn split_rejects_tiny_width() { + let err = compute_desired_split(2, 0).expect_err("width 2 must fail"); + assert!(err.contains("too small")); + } + + #[test] + fn logical_size_obeys_scale() { + assert_eq!(logical_dimension(3840, 2.0), 1920); + assert_eq!(logical_dimension(2160, 2.0), 1080); + assert_eq!(logical_dimension(1920, 1.0), 1920); + } + + #[test] + fn tolerance_helper() { + assert!(approx_equal(639, 638, 2)); + assert!(approx_equal(1277, 1279, 2)); + assert!(!approx_equal(1277, 1281, 2)); + } +} diff --git a/sessions/2026-02-21-hyprctl-workspace-inspection.md b/sessions/2026-02-21-hyprctl-workspace-inspection.md new file mode 100644 index 00000000..0d98d25f --- /dev/null +++ b/sessions/2026-02-21-hyprctl-workspace-inspection.md @@ -0,0 +1,101 @@ +## Task + +Experiment with the `hyprctl` CLI and discover as much as possible about the current Hyprland workspace state. + +## Plan + +1. Enumerate available `hyprctl` commands. +2. Run broad read-only inspection commands for compositor, monitor, workspace, client, input, and config state. +3. Summarize findings focused on the active/current workspace. + +## Notes + +- Enumerated command surface with `hyprctl --help`. +- Confirmed single running Hyprland instance on socket `wayland-1`. +- Captured version/build metadata: Hyprland `0.53.0` (Nix build, dirty tree), commit `0de216e...`. +- Captured monitor topology: + - One active monitor: `HDMI-A-1` (`3840x2160@160Hz`, scale `2.00`), focused, DPMS on. + - No inactive/extra outputs from `monitors all`. +- Captured workspace state: + - Existing workspaces: `1`, `2`, `3`. + - Active workspace: `3` on monitor `HDMI-A-1`, `2` windows, no fullscreen. + - Workspace window counts: `1 -> 2`, `2 -> 3`, `3 -> 2`. +- Captured client inventory: + - 7 mapped windows total (Ghostty + Firefox), all Wayland (`xwayland: false`). + - Active window: Ghostty (`address 0x55a549a62600`) on workspace `3`, tiled at `961,1`, size `958x1078`. + - Workspace 3 appears split into two equal tiled columns (two Ghostty windows). +- Captured input devices: + - 3 pointer-class devices listed under mice. + - 8 keyboards listed; `zmk-project-urchin-keyboard` is marked `main: true`. +- Captured config/runtime surfaces: + - Layout engines available: `dwindle`, `master`; configured layout: `dwindle`. + - Gaps in/out are `0 0 0 0`; border size `1`; rounding `0`; active/inactive opacity `1.0`. + - `input:follow_mouse = 1`. + - Workspace rules: none. + - Global shortcuts registry: empty. + - Plugins loaded: none. + - Config errors: none. + - Layers: no registered background/bottom/top/overlay surfaces. +- Ran `hyprctl rollinglog` snapshot and observed active libinput mouse wheel + DRM cursor buffer debug activity. +- Queried auxiliary command help: + - `plugin`: `load|unload|list` + - `hyprpaper`: dynamic `wallpaper` requests + - `hyprsunset`: `temperature|identity|gamma` + +## Follow-up: current workspace windows + +- Re-queried `activeworkspace`, `activewindow`, and full `clients` list. +- Active workspace remains `3` with exactly `2` windows. +- Window A (focused): address `0x55a549a62600`, title `OC | Hyprctl CLI exploration of current wo...`, class `com.mitchellh.ghostty`, geometry `961,1 958x1078`, `focusHistoryID: 0`. +- Window B (sibling): address `0x55a549a6cb40`, title `v ~/1/tools`, class `com.mitchellh.ghostty`, geometry `1,1 958x1078`, `focusHistoryID: 1`. +- Both windows share `pid 2112`, indicating they are two Wayland toplevels from the same Ghostty process. +- Shared state for both: mapped/visible, tiled (`floating: false`), not fullscreen, not pinned, ungrouped, no tags, no idle inhibit, no xdg metadata. +- Attempted `hyprctl decorations ` for these windows returned `none`, consistent with no extra decoration objects currently reported by Hyprland. + +## Follow-up: data needed for 1/3 vs 2/3 split + +- Goal interpretation: keep two tiled windows on workspace `3`, left window at one-third width, right window at two-thirds width, full height. +- Required state signals obtainable via `hyprctl`: + - Active workspace identity and window count (`activeworkspace`) to ensure exactly two windows are targeted. + - Window identities and left/right ordering (`clients` by workspace and `at.x`) to know which address should become 1/3 side. + - Usable geometry baseline (`clients` current `at`/`size`, and optionally `monitors`) to validate post-change dimensions. + - Layout engine in use (`getoption general:layout`) to choose ratio control path. + - Layout-specific knobs (`getoption master:mfact`, `getoption master:orientation`, and dwindle options) to know whether ratio should be set via master factor or split ratio. + - Gap/border context (`getoption general:gaps_in`, `general:gaps_out`, `general:border_size`) for exact pixel expectation vs approximate ratio. +- Current relevant values gathered: + - Layout: `dwindle`. + - Workspace `3`: 2 windows. + - Left window (x=1): `0x55a549a6cb40` (`v ~/1/tools`) width `958`. + - Right window (x=961): `0x55a549a62600` (`OC | ...`) width `958`. + - Gaps in/out: all zero; border size: `1`. + - Master defaults (if switching strategy): `mfact=0.55`, `orientation=left`, `new_status=slave`. + +## Implementation: automated 1/3 vs 2/3 splitter + +- Added new Rust crate: `rs/hyprctl-split` and included it in `rs/Cargo.toml` workspace members. +- Crate behavior: + - Uses only `hyprctl` CLI for runtime data gathering and applying changes. + - Reads active workspace, active monitor, layout, gaps, border size, and mapped/visible client windows on the active workspace. + - Sorts workspace windows by `x` position and treats leftmost/rightmost as split targets. + - Computes 1/3 : 2/3 widths from current combined window widths. + - Applies via `hyprctl dispatch resizewindowpixel exact ,address:`. + - Verifies resulting left/right widths against expected values with small tolerance. + - Supports `--dry-run` to print derived state and intended target widths without applying. +- Added unit tests for width calculation, tolerance checks, and logical monitor size conversion. + +## Validation run + +- `cargo fmt --manifest-path rs/Cargo.toml --all` ✅ +- `cargo test --manifest-path rs/Cargo.toml --all` ✅ +- `cargo clippy --manifest-path rs/Cargo.toml --all` ✅ +- `just check` ✅ +- Dry-run on workspace `3` showed target `left=639`, `right=1277`. +- Applied run succeeded and verification matched expected widths. +- Final observed workspace `3` geometry: + - left window `0x55a549a6cb40`: `639x1078` at `1,1` + - right window `0x55a549a62600`: `1277x1078` at `642,1` + +## Packaging follow-up + +- Added Nix package output `packages.hyprctl-split` in `nix/outputs/packages.nix` using `buildRustPackage` against the `rs/` workspace and selecting package `hyprctl-split` via cargo flags. +- Added `pkgs.hyprctl-split` to `home.packages` in `nix/modules/aliases.nix`.