use std::fmt; use std::io; use std::process; use std::string; use clap::{Args, Parser, ValueEnum}; mod i3; #[derive(Debug)] enum Error { Command(String), Classify(String), Workstation(String), Plan(String), Apply(String), I3(i3::Error), } impl fmt::Display for Error { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( f, "{}", match self { Error::Command(msg) => format!("command failed: {msg}"), Error::Classify(msg) => format!("classification failed: {msg}"), Error::Workstation(msg) => format!("workstation failed: {msg}"), Error::Plan(msg) => format!("plan failed: {msg}"), Error::Apply(msg) => format!("apply failed: {msg}"), Error::I3(e) => format!("i3: {e}"), }, ) } } impl From for Error { fn from(value: i3::Error) -> Self { Self::I3(value) } } impl From for Error { fn from(value: io::Error) -> Self { Self::Command(value.to_string()) } } impl From for Error { fn from(value: string::FromUtf8Error) -> Self { Self::Command(value.to_string()) } } impl std::error::Error for Error {} #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] enum MonitorClass { Laptop, External, } impl TryFrom<&str> for MonitorClass { type Error = Error; fn try_from(value: &str) -> Result { if value.starts_with("eDP-") { Ok(Self::Laptop) } else if value.starts_with("DP-") || value.starts_with("HDMI-") { Ok(Self::External) } else { Err(Error::Classify(format!( "could not classify monitor: {value}" ))) } } } #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] struct Monitor { name: String, class: MonitorClass, } impl Monitor { fn on(self) -> MonitorSetting { MonitorSetting { monitor: self, state: MonitorState::On, } } fn off(self) -> MonitorSetting { MonitorSetting { monitor: self, state: MonitorState::Off, } } } #[derive(Debug, PartialEq, Eq)] struct Workstation { laptop: Option, externals: Option<(Monitor, Vec)>, } impl TryFrom> for Workstation { type Error = Error; fn try_from(mut value: Vec) -> Result { value.sort(); let (mut laptops, non_laptops): (Vec<_>, Vec<_>) = value .into_iter() .partition(|monitor| monitor.class == MonitorClass::Laptop); let laptop = match laptops.len() { 0 => None, 1 => Some(laptops.remove(0)), _ => { return Err(Error::Workstation( "found more than one laptop screen".into(), )) } }; let (mut externals, rest): (Vec<_>, Vec<_>) = non_laptops .into_iter() .partition(|monitor| monitor.class == MonitorClass::External); if laptop.is_none() && externals.is_empty() { return Err(Error::Workstation("no screens found".to_string())); } let externals = match externals.len() { 0 => None, _ => Some((externals.remove(0), externals)), }; assert_eq!(rest.len(), 0); Ok(Self { laptop, externals }) } } #[derive(Debug, PartialEq, Eq)] enum MonitorState { On, Off, } #[derive(Debug, PartialEq, Eq)] struct MonitorSetting { monitor: Monitor, state: MonitorState, } #[derive(Debug, PartialEq, Eq)] struct Plan { monitors: Vec, } impl Plan { fn command(&self) -> (String, Vec) { let mut args = vec![]; let mut left: Option<&MonitorSetting> = None; for monitor in &self.monitors { args.push("--output".into()); args.push(monitor.monitor.name.clone()); match monitor.state { MonitorState::On => { args.push("--auto".into()); if let Some(left) = left { args.push("--right-of".into()); args.push(left.monitor.name.clone()); } left = Some(monitor); } MonitorState::Off => args.push("--off".into()), }; } ("xrandr".into(), args) } fn apply(self) -> Result<(String, Vec), Error> { let (program, args) = self.command(); let output = process::Command::new(&program).args(&args).output()?; let _ = output .status .success() .then_some(()) .ok_or(Error::Apply(String::from_utf8(output.stderr)?)); Ok((program, args)) } } impl fmt::Display for Plan { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let active_monitors = self .monitors .iter() .filter(|monitor| monitor.state == MonitorState::On) .collect::>(); let padding_top = active_monitors .iter() .map(|monitor| "─".repeat(monitor.monitor.name.len())) .collect::>() .join("─┬─"); let padding_bottom = active_monitors .iter() .map(|monitor| "─".repeat(monitor.monitor.name.len())) .collect::>() .join("─┴─"); writeln!( f, "┌{}┐\n┆ ╭─{padding_top}─╮ ┆", "┄".repeat(padding_top.chars().count() + 2 * 2 + 2) )?; writeln!( f, "┆ │ {} │ ┆", active_monitors .into_iter() .map(|monitor| monitor.monitor.name.clone()) .collect::>() .join(" │ ") )?; write!( f, "┆ ╰─{padding_bottom}─╯ ┆\n└{}┘", "┄".repeat(padding_bottom.chars().count() + 2 * 2 + 2) )?; Ok(()) } } impl Workstation { fn plan(&self, setup: Setup) -> Result { match setup { Setup::LaptopLeft => match self.laptop { None => Err(Error::Plan("no laptop screen found".to_string())), Some(ref laptop) => Ok(Plan { monitors: { let mut v = vec![laptop.clone().on()]; v.append({ &mut match &self.externals { None => { return Err(Error::Plan( "no external screens found".to_string(), )) } Some((ext, rest)) => { let mut v = vec![ext.clone().on()]; v.append( &mut rest.iter().map(|ext| ext.clone().on()).collect(), ); v } } }); v }, }), }, Setup::LaptopRight => match self.laptop { None => Err(Error::Plan("no laptop screen found".to_string())), Some(ref laptop) => Ok(Plan { monitors: { let mut v = match &self.externals { None => { return Err(Error::Plan("no external screens found".to_string())) } Some((ext, rest)) => { let mut v = vec![ext.clone().on()]; v.append(&mut rest.iter().map(|ext| ext.clone().on()).collect()); v } }; v.push(laptop.clone().on()); v }, }), }, Setup::LaptopOnly => match self.laptop { None => Err(Error::Plan("no laptop screen found".to_string())), Some(ref laptop) => Ok(Plan { monitors: { let mut v = vec![laptop.clone().on()]; v.append({ &mut match &self.externals { None => vec![], Some((ext, rest)) => { let mut v = vec![ext.clone().off()]; v.append( &mut rest.iter().map(|ext| ext.clone().off()).collect(), ); v } } }); v }, }), }, Setup::ExternalOnly => match &self.externals { None => Err(Error::Plan("no external screens found".to_string())), Some((ext, rest)) => Ok(Plan { monitors: { let mut v = { let mut v = vec![ext.clone().on()]; v.append( &mut rest.iter().map(|monitor| monitor.clone().on()).collect(), ); v }; if let Some(laptop) = &self.laptop { v.push(laptop.clone().off()); } v }, }), }, } } } #[derive(Debug, PartialEq, Eq)] enum OutputState { Connected, Disconnected, } impl TryFrom<&str> for OutputState { type Error = Error; fn try_from(value: &str) -> Result { match value { "connected" => Ok(Self::Connected), "disconnected" => Ok(Self::Disconnected), _ => Err(Error::Command(format!( "unknown xrandr output state: {value}" ))), } } } struct Output { name: String, state: OutputState, } impl Monitor { fn new(name: &str) -> Result { Ok(Self { name: name.into(), class: name.try_into()?, }) } fn findall() -> Result, Error> { String::from_utf8( process::Command::new("xrandr") .arg("--query") .output()? .stdout, )? .lines() .skip(1) // skip header .filter(|line| { if let Some(c) = line.chars().next() { c.is_alphabetic() } else { false } }) .map(|line| { let mut parts = line.split_whitespace(); match (parts.next(), parts.next()) { (Some(part_1), Some(part_2)) => Ok(Output { name: part_1.to_string(), state: part_2.try_into()?, }), _ => Err(Error::Command(format!( "not enough output information in line: {line}" ))), } }) .filter(|result| { result .as_ref() .map(|output| output.state == OutputState::Connected) .unwrap_or(true) }) .flat_map(|result| result.map(|output| Self::new(&output.name))) .collect::, Error>>() .map(|mut monitors| { monitors.sort(); monitors }) } } #[derive(Clone, Copy, Debug, ValueEnum)] enum Setup { LaptopLeft, LaptopRight, LaptopOnly, ExternalOnly, } #[derive(Clone, Debug, Args)] #[group(multiple = false, required = true)] struct Approach { #[arg(long)] setup: Option, #[arg(long)] best: bool, } #[derive(Debug, Parser)] #[command(version, about)] struct Cli { #[command(flatten)] approach: Approach, #[arg(long)] dry_run: bool, #[arg(long)] diagram: bool, } #[allow(unreachable_code)] fn run() -> Result<(), Error> { let mut i3_connection = i3::connect()?; let version = i3_connection.version()?; println!("i3 version: {version}"); let workspaces = i3_connection.workspaces()?; println!("i3 workspaces:"); for workspace in workspaces { println!("- {workspace}"); } i3_connection.command(i3::Command::Nop)?; let outputs = i3_connection.outputs()?; println!("i3 outputs:"); for output in outputs.iter() { println!("- {output}") } // i3_connection.command(i3::Command::MoveWorkspace { // id: 3, // output: outputs[0], // })?; return Ok(()); let args = Cli::parse(); let monitors = Monitor::findall()?; let workstation: Workstation = monitors.try_into()?; let plan = if let Some(setup) = args.approach.setup { workstation.plan(setup)? } else { workstation .plan(Setup::LaptopLeft) .or_else(|_| workstation.plan(Setup::LaptopOnly)) .or_else(|_| workstation.plan(Setup::ExternalOnly)) .map_err(|_| Error::Plan("no plan fit with \"best\" strategy".to_string()))? }; if args.diagram { println!("{plan}"); } let command = if args.dry_run { plan.command() } else { plan.apply()? }; println!("{} {}", command.0, command.1.join(" ")); Ok(()) } fn main() -> process::ExitCode { process::ExitCode::from(match run() { Ok(()) => 0, Err(e) => { println!("{e}"); 1 } }) } #[cfg(test)] mod tests { use super::*; enum PlanExpect<'a> { Error, Valid(Plan, &'a str), } #[test] fn single_laptop() -> Result<(), Error> { let laptop = Monitor { name: "eDP-1".to_string(), class: MonitorClass::Laptop, }; let monitors = vec![laptop.clone()]; let workstation: Workstation = monitors.try_into()?; assert_eq!( &workstation, &Workstation { laptop: Some(laptop.clone()), externals: None } ); for (setup, expect) in [ (Setup::LaptopLeft, PlanExpect::Error), (Setup::LaptopRight, PlanExpect::Error), ( Setup::LaptopOnly, PlanExpect::Valid( Plan { monitors: vec![laptop.on()], }, "--output eDP-1 --auto", ), ), (Setup::ExternalOnly, PlanExpect::Error), ] { let result = workstation.plan(setup); match expect { PlanExpect::Error => assert!(result.is_err()), PlanExpect::Valid(plan, cmd) => { assert_eq!(result?, plan); assert_eq!(plan.command().1.join(" "), cmd); } } } Ok(()) } #[test] fn multiple_laptops() -> Result<(), Error> { let laptop1 = Monitor { name: "eDP-1".to_string(), class: MonitorClass::Laptop, }; let laptop2 = Monitor { name: "eDP-2".to_string(), class: MonitorClass::Laptop, }; let monitors = vec![laptop1, laptop2]; let workstation: Result = monitors.try_into(); assert!(workstation.is_err()); Ok(()) } #[test] fn no_screens() -> Result<(), Error> { let monitors = vec![]; let workstation: Result = monitors.try_into(); assert!(workstation.is_err()); Ok(()) } #[test] fn single_external() -> Result<(), Error> { let external = Monitor { name: "DP-1".to_string(), class: MonitorClass::External, }; let monitors = vec![external.clone()]; let workstation: Workstation = monitors.try_into()?; assert_eq!( workstation, Workstation { laptop: None, externals: Some((external.clone(), vec![])) } ); for (setup, expect) in [ (Setup::LaptopLeft, PlanExpect::Error), (Setup::LaptopRight, PlanExpect::Error), (Setup::LaptopOnly, PlanExpect::Error), ( Setup::ExternalOnly, PlanExpect::Valid( Plan { monitors: vec![external.on()], }, "--output DP-1 --auto", ), ), ] { let result = workstation.plan(setup); match expect { PlanExpect::Error => assert!(result.is_err()), PlanExpect::Valid(plan, cmd) => { assert_eq!(result?, plan); assert_eq!(plan.command().1.join(" "), cmd); } } } Ok(()) } #[test] fn multiple_external() -> Result<(), Error> { let external1 = Monitor { name: "DP-1".to_string(), class: MonitorClass::External, }; let external2 = Monitor { name: "DP-2".to_string(), class: MonitorClass::External, }; let external3 = Monitor { name: "DP-3".to_string(), class: MonitorClass::External, }; let monitors = vec![external2.clone(), external3.clone(), external1.clone()]; let workstation: Workstation = monitors.try_into()?; assert_eq!( workstation, Workstation { laptop: None, externals: Some(( external1.clone(), vec![external2.clone(), external3.clone()] )) } ); for (setup, expect) in [ (Setup::LaptopLeft, PlanExpect::Error), (Setup::LaptopRight, PlanExpect::Error), (Setup::LaptopOnly, PlanExpect::Error), ( Setup::ExternalOnly, PlanExpect::Valid( Plan { monitors: vec![ external1.clone().on(), external2.clone().on(), external3.on(), ], }, "--output DP-1 --auto --output DP-2 --auto --right-of DP-1 --output DP-3 --auto --right-of DP-2", ), ), ] { let result = workstation.plan(setup); match expect { PlanExpect::Error => assert!(result.is_err()), PlanExpect::Valid(plan, cmd) => { assert_eq!(result?, plan); assert_eq!(plan.command().1.join(" "), cmd); } } } Ok(()) } #[test] fn mixture() -> Result<(), Error> { let external1 = Monitor { name: "DP-1".to_string(), class: MonitorClass::External, }; let external2 = Monitor { name: "DP-2".to_string(), class: MonitorClass::External, }; let laptop = Monitor { name: "eDP-1".to_string(), class: MonitorClass::Laptop, }; let monitors = vec![external2.clone(), laptop.clone(), external1.clone()]; let workstation: Workstation = monitors.try_into()?; assert_eq!( workstation, Workstation { laptop: Some(laptop.clone()), externals: Some((external1.clone(), vec![external2.clone()])) } ); for (setup, expect) in [ ( Setup::LaptopLeft, PlanExpect::Valid( Plan { monitors: vec![ laptop.clone().on(), external1.clone().on(), external2.clone().on(), ], }, "--output eDP-1 --auto --output DP-1 --auto --right-of eDP-1 --output DP-2 --auto --right-of DP-1", ), ), ( Setup::LaptopRight, PlanExpect::Valid( Plan { monitors: vec![ external1.clone().on(), external2.clone().on(), laptop.clone().on(), ], }, "--output DP-1 --auto --output DP-2 --auto --right-of DP-1 --output eDP-1 --auto --right-of DP-2", ), ), ( Setup::LaptopOnly, PlanExpect::Valid( Plan { monitors: vec![ laptop.clone().on(), external1.clone().off(), external2.clone().off(), ], }, "--output eDP-1 --auto --output DP-1 --off --output DP-2 --off", ), ), ( Setup::ExternalOnly, PlanExpect::Valid( Plan { monitors: vec![ external1.clone().on(), external2.clone().on(), laptop.clone().off(), ], }, "--output DP-1 --auto --output DP-2 --auto --right-of DP-1 --output eDP-1 --off", ), ), ] { let result = workstation.plan(setup); match expect { PlanExpect::Error => assert!(result.is_err()), PlanExpect::Valid(plan, cmd) => { assert_eq!(result?, plan); assert_eq!(plan.command().1.join(" "), cmd); } } } Ok(()) } }