From 9d847b311cb789a2db850e2885d7eebb073f57b1 Mon Sep 17 00:00:00 2001 From: stephen Date: Thu, 12 Dec 2024 02:19:03 +0000 Subject: [PATCH] show layouts and set layout --- Cargo.lock | 178 +++++++++++++++++++++++++++ Cargo.toml | 9 +- src/main.rs | 347 +++++++++++++++++++++++++++++++++++++++------------- 3 files changed, 451 insertions(+), 83 deletions(-) create mode 100644 Cargo.lock diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..dadbf1e --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,178 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + +[[package]] +name = "hashbrown" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" + +[[package]] +name = "indexmap" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62f822373a4fe84d4bb149bf54e584a7f4abec90e072ed49cda0edea5b95471f" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "proc-macro2" +version = "1.0.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ryu" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" + +[[package]] +name = "serde" +version = "1.0.216" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b9781016e935a97e8beecf0c933758c97a5520d32930e460142b4cd80c6338e" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.216" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46f859dbbf73865c6627ed570e78961cd3ac92407a2d117204c49232485da55e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.133" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7fceb2473b9166b2294ef05efcb65a3db80803f0b03ef86a5fc88a2b85ee377" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_spanned" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" +dependencies = [ + "serde", +] + +[[package]] +name = "swayout" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "toml", + "xdg", +] + +[[package]] +name = "syn" +version = "2.0.90" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "919d3b74a5dd0ccd15aeb8f93e7006bd9e14c295087c9896a110f490752bcf31" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "toml" +version = "0.8.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1ed1f98e3fdc28d6d910e6737ae6ab1a93bf1985935a1193e68f93eeb68d24e" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "winnow", +] + +[[package]] +name = "unicode-ident" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" + +[[package]] +name = "winnow" +version = "0.6.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36c1fec1a2bb5866f07c25f68c26e565c4c200aebb96d7e55710c19d3e8ac49b" +dependencies = [ + "memchr", +] + +[[package]] +name = "xdg" +version = "2.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "213b7324336b53d2414b2db8537e56544d981803139155afa84f76eeebb7a546" diff --git a/Cargo.toml b/Cargo.toml index 00b53d4..31b658b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,4 +6,11 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -json = "0.12.4" +# for config yaml +serde = { version = "1.0", features = ["derive"] } +toml = "0.8.19" + +# for json output from swaymsg +serde_json = "1.0.133" + +xdg = "2.5.2" \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 72523c3..cd46cd7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,98 +1,281 @@ +use serde::{Deserialize, Serialize}; +use serde_json::{from_str, Value}; +use std::collections::HashMap; +use std::env; use std::process::Command; -use std::str; -use json; -use json::JsonValue; +use std::{fs, str}; -enum SwayOutput { - Disabled { - name: String, - }, - Enabled { - name: String, - width: i32, - height: i32, - x: i32, - y: i32, +// parse the config to this +#[derive(Serialize, Deserialize, Debug)] +struct Rules { + monitors: HashMap, + // Map of layout name to : map of monitor name to output config + layouts: HashMap>, +} +#[derive(Serialize, Deserialize, Debug)] +struct Monitor { + make: String, + model: String, + serial: String, +} +#[derive(Serialize, Deserialize, Debug)] +struct OutputConfig { + mode: String, + scale: String, + x: u16, + y: u16, + transform: String, +} + +// parse output of swaymsg -t get_outputs to this +struct Output { + name: String, + make: String, + model: String, + serial: String, + output_config: OutputConfig, +} + +fn main() { + let args: Vec = env::args().collect(); + if args.len() == 1 { + // first arg is executable + print_layout_names(); + } else if args.len() == 2 { + apply_layout(&args[1]); + } else { + panic!("Usage: {} [layout]", args[0].as_str()); } } -fn main() { + +/// Get all outputs currently available +fn get_outputs() -> Vec { + // call `swaymsg -t get_outputs` to get outputs let output = Command::new("swaymsg") - .arg("-t") - .arg("get_outputs") - .output() - .expect("Failed to execute command"); - let s = str::from_utf8(&output.stdout).unwrap(); + .arg("-t") + .arg("get_outputs") + .output() + .expect("swaymsg -t get_outputs failed"); + let json_string = str::from_utf8(&output.stdout).unwrap(); + let json = from_str(json_string).unwrap(); - let mut outputs: Vec = Vec::new(); + if let Value::Array(outputs) = json { + outputs + .iter() + .map(|output_json| Output { + name: String::from(output_json["name"].as_str().unwrap()), + make: String::from(output_json["make"].as_str().unwrap()), + model: String::from(output_json["model"].as_str().unwrap()), + serial: String::from(output_json["serial"].as_str().unwrap()), + output_config: OutputConfig { + mode: get_mode(output_json), + scale: String::from("1.0"), + x: 0, + y: 0, + transform: String::from("normal"), + }, + }) + .collect() + } else { + panic!("outputs json was not an array"); + } +} - let json = json::parse(s).unwrap(); - - let json_outputs = - if let JsonValue::Array(vec) = json { - vec +/// Parse the mode from the output JSON into a string suitable for the mode param for sway-output. +/// This will go into OutputConfig.mode +fn get_mode(output: &Value) -> String { + if let Value::Array(modes) = &output["modes"] { + if let Some(mode) = modes.first() { + let width = mode["width"].as_i64().unwrap().to_string(); + let height = mode["height"].as_i64().unwrap().to_string(); + let mut out: String = String::new(); + out.push_str(&width); + out.push_str("x"); + out.push_str(&height); + out } else { - panic!("json is not an array"); - }; + panic!("no modes") + } + } else { + panic!("modes not an array") + } +} - let size = json_outputs.len(); - if size == 1 { - // If there is 1 output, use it. - let json_output = &json_outputs[0]; - let name = &json_output["name"]; - let mode = &json_output["modes"][0]; - outputs.push(SwayOutput::Enabled { - name: name.to_string(), - width: mode["width"].as_i32().unwrap(), - height: mode["height"].as_i32().unwrap(), - x: 0, - y: 0, +/// Get the rules from ~/.config/swayout/config.toml +fn get_rules() -> Rules { + let xdg_dirs = xdg::BaseDirectories::with_prefix("swayout").unwrap(); + if let Some(path) = xdg_dirs.find_config_file("config.toml") { + let toml = fs::read_to_string(path).expect("error reading file"); + toml::from_str(&toml).expect("toml parse error") + } else { + // no config files, use an empty rules + Rules { + monitors: HashMap::new(), + layouts: HashMap::new(), + } + } +} + +/// Determine the available layout names. +/// Return one layout for each layout defined in the configuration file +/// (for which all outputs are available), +/// one for each monitor defined in the configuration file +/// (for using just that monitor), +/// and one for each available output that is not a configured monitor (for using just that output). +fn get_layouts() -> Vec { + let available_outputs = get_outputs(); + let rules = get_rules(); + + let mut layout_names: Vec = Vec::new(); + + // Get the names of monitors that are available (that match a current output) + let mut available_monitor_names: Vec = Vec::new(); + // TODO do this with iterators + rules.monitors.iter().for_each(|(monitor_name, monitor)| { + if available_outputs.iter().any(|output| { + monitor.make == output.make + && monitor.model == output.model + && output.serial == monitor.serial + }) { + available_monitor_names.push(String::from(monitor_name)); + } + }); + + // Add each layout defined in the config file for which all outputs are available + rules.layouts.iter().for_each(|(layout_name, layout)| { + if layout + .iter() + .all(|(output_name, _output)| available_monitor_names.contains(output_name)) + { + layout_names.push(String::from(layout_name)) + } + }); + + // add each individual output (by monitor name if the monitor is defined, else by output name) + available_outputs.iter().for_each(|output| { + // if there is a monitor for the output, use the monitor name, else use the output name + let monitor_opt = rules.monitors.iter() + .find(|(_monitor_name, monitor)| { + monitor.make == output.make + && monitor.model == output.model + && monitor.serial == output.serial + }); + if let Some((monitor_name, _monitor)) = monitor_opt { + layout_names.push(String::from(monitor_name)); + } else { + layout_names.push(String::from(&output.name)); + } + }); + + layout_names +} + +/// Print the name of each layout to standard out, one per line +fn print_layout_names() { + get_layouts().iter().for_each(|layout| println!("{layout}")) +} + +/// Apply the specified layout. +/// layout_name may be the name of a layout rule, the name of a monitor, or the name of an output. +fn apply_layout(layout_name: &String) { + let rules = get_rules(); + let outputs = get_outputs(); + + // Map of output name to config for all outputs to enable. + // (All outputs not in this map will be disabled.) + let mut output_config_map: HashMap<&String, &OutputConfig> = HashMap::new(); + + // First check if the layout is defined in the rules + let opt_layout = rules.layouts.get(layout_name); + if let Some(layout) = opt_layout { + // The layout is defined in the rules. + layout.iter().for_each(|(monitor_name, output_config)| { + let monitor = &rules.monitors[monitor_name]; + + // find the output for the monitor to get the output name. + // (You can allegedly use " " instead of output name.) + let output_opt = outputs.iter().find(|output| { + output.make == monitor.make + && output.model == monitor.model + && output.serial == monitor.serial + }); + if let Some(output) = output_opt { + output_config_map.insert(&output.name, &output_config); + } else { + panic!("Missing output for monitor {monitor_name}"); + } }); } else { - let dell_option:Option<&JsonValue> = json_outputs.iter() - .find( |json_output| json_output["serial"].to_string() == "CFV9N99T0Y0U"); - if dell_option.is_some() { - // The Dell on my desk is attached. Use it, and disable others. - let dell = dell_option.unwrap(); - outputs.push(SwayOutput::Enabled { - name: dell["name"].to_string(), - width: 1920, - height: 1200, - x: 0, - y: 0 - }); - json_outputs.iter() - .filter( |json_output| { - json_output["serial"].to_string() != "CFV9N99T0Y0U" - }) - .for_each( |json_output| { - outputs.push(SwayOutput::Disabled { - name: json_output["name"].to_string() - }); - }); + // The layout is not defined in the rules. + // See if it is a monitor name... + if let Some(monitor) = rules.monitors.get(layout_name) { + // It is a monitor name. Find the matching output... + if let Some(output) = outputs.iter().find(|output| { + output.make == monitor.make + && output.model == monitor.model + && output.serial == monitor.serial + }) { + output_config_map.insert(&output.name, &output.output_config); + } else { + panic!("could not find output for monitor {layout_name}") + } } else { - println!("unknown state, do nothing"); + // See if it is an output name... + if let Some(output) = outputs.iter() + .find(|output| &output.name == layout_name) { + output_config_map.insert(&output.name, &output.output_config); + } else { + panic!("could not find layout, monitor, or output {layout_name}") + } } } - let enabled_count = outputs.iter() - .filter(|output| matches!(output,SwayOutput::Enabled{name:_,width:_,height:_,x:_,y:_})) - .count(); - if enabled_count >= 1 { - // enable then disable - outputs.iter() - .for_each(|output| { - if let SwayOutput::Enabled{name,width,height,x,y} = output { - println!("swaymsg output {} enable dpms on mode {}x{} pos {} {}", - name, width, height, x, y); - } - }); - outputs.iter() - .for_each(|output| { - if let SwayOutput::Disabled{name} = output { - println!("swaymsg output {} disabled", name); - } - }); - } else { - println!("No enabled outputs, do nothing"); - } + apply_outputs(&outputs, &output_config_map); +} + +/// Apply the specified outputs. Enable all outputs in [outputs], disable others. +fn apply_outputs(all_outputs: &Vec, outputs: &HashMap<&String, &OutputConfig>) { + let mut cmd = Command::new("swaymsg"); + + // enabled first, then disabled. + // That way if some work before an error, you at least have one output enabled. + + let mut enabled: HashMap<&String,&OutputConfig> = HashMap::new(); + let mut disabled: Vec<&String> = Vec::new(); + + all_outputs.iter().for_each(|output| { + if let Some(output_config) = outputs.get(&output.name) { + enabled.insert(&output.name, &output_config); + } else { + disabled.push(&output.name); + } + }); + + enabled.iter().for_each(|(output_name,output_config)| { + cmd.arg("output"); + cmd.arg(&output_name); + cmd.arg("enable"); + cmd.arg("mode"); + cmd.arg(&output_config.mode); + cmd.arg("scale"); + cmd.arg(&output_config.scale); + cmd.arg("pos"); + cmd.arg(&output_config.x.to_string()); + cmd.arg(&output_config.y.to_string()); + cmd.arg("transform"); + cmd.arg(&output_config.transform); + cmd.arg(","); + }); + + disabled.iter().for_each(|output_name| { + cmd.arg("output"); + cmd.arg(&output_name); + cmd.arg("disable"); + cmd.arg(","); + }); + + cmd.get_args() + .for_each(|arg| print!("{} ", arg.to_str().unwrap())); + + cmd.output().expect("swaymsg output failed"); } \ No newline at end of file