use std::{ borrow::BorrowMut, convert::Infallible, fmt::{Display, Write}, str::FromStr, }; use serde::{de::Expected, Deserialize, Serialize}; use strum::IntoEnumIterator; use crate::split; use super::{ hlwmbool, hook::Hook, parser::{FromCommandArgs, FromStrings, ParseError}, ToCommandString, }; #[derive(Debug, Clone, PartialEq)] pub struct Rule { /// Rule labels default to an incremental index. /// These default labels are unique, unless you assign a different /// rule a custom integer LABEL. Default labels can be captured /// with the printlabel flag. label: Option, flag: Option, /// If each condition of this rule matches against this client, /// then every [Consequence] is executed. /// (If there are no conditions given, then this rule is executed for each client) condition: Option, consequences: Vec, } impl Default for Rule { fn default() -> Self { Self { label: Default::default(), flag: Default::default(), condition: Some(Condition::FixedSize), consequences: vec![Consequence::Focus(true)], } } } impl ToCommandString for Rule { fn to_command_string(&self) -> String { let mut args = Vec::with_capacity(4); if let Some(label) = self.label.as_ref() { args.push(format!("--label={}", label)); } if let Some(flag) = self.flag.as_ref() { args.push(format!("{flag}")); } if let Some(cond) = self.condition.as_ref() { args.push(cond.to_command_string()); } args.push( (&self.consequences) .into_iter() .map(|c| format!("--{}", c.to_command_string())) .collect::>() .join("\t"), ); args.join("\t") } } impl Rule { pub fn new( condition: Option, consequences: Vec, label: Option, flag: Option, ) -> Self { Self { label, flag, condition, consequences, } } } impl FromStrings for Rule { fn from_strings>(s: I) -> Result { let mut condition: Option = None; let mut consequences = vec![]; let mut label: Option = None; let mut flag: Option = None; let mut args = s.map(|part| part.strip_prefix("--").unwrap_or(part.as_str()).to_string()); let mut original = Vec::new(); // For InvalidValue error while let Some(arg) = args.next() { original.push(arg.clone()); if label.is_none() { if let Some((name, value)) = arg.split_once('=') { if name.trim() == "label" { label = Some(value.trim().to_string()); continue; } } } if flag.is_none() { if let Ok(flag_res) = Flag::from_str(&arg) { flag = Some(flag_res); continue; } } if condition.is_none() { if let Ok(cond) = Condition::from_str(&arg) { condition = Some(cond); continue; } } if let Ok(cons) = Consequence::from_str(&arg) { consequences.push(cons); continue; } } if consequences.is_empty() { return Err(ParseError::InvalidValue { value: original.join(" "), expected: "condition and/or consequences", }); } Ok(Self { label, flag, condition: condition, consequences: consequences, }) } } impl Serialize for Rule { fn serialize(&self, serializer: S) -> Result where S: serde::Serializer, { serializer.serialize_str(&self.to_command_string().replace("\t", " ")) } } impl<'de> Deserialize<'de> for Rule { fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de>, { pub struct Expect; impl Expected for Expect { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { write!(f, "a valid herbstluftwm rule") } } let str_val: String = Deserialize::deserialize(deserializer)?; let strings = split::tab_or_space(&str_val); Ok(Self::from_strings(strings.into_iter()).map_err(|_| { serde::de::Error::invalid_value(serde::de::Unexpected::Str(&str_val), &Expect) })?) } } #[derive(Debug, Clone, PartialEq, strum::EnumIter)] pub enum RuleOperator { /// ~ matches if client’s property is matched by the regex value. Regex, /// = matches if client’s property string is equal to value. Equal, } impl RuleOperator { pub const fn char(&self) -> char { match self { RuleOperator::Regex => '~', RuleOperator::Equal => '=', } } pub const fn match_set() -> [char; 2] { [Self::Regex.char(), Self::Equal.char()] } } impl TryFrom for RuleOperator { type Error = ParseError; fn try_from(value: char) -> Result { Self::iter() .find(|i| i.char() == value) .ok_or(ParseError::InvalidCommand(value.to_string())) } } impl Default for RuleOperator { fn default() -> Self { Self::Equal } } impl Display for RuleOperator { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_char(self.char()) } } impl FromStr for RuleOperator { type Err = ParseError; fn from_str(s: &str) -> Result { s.chars().next().ok_or(ParseError::Empty)?.try_into() } } #[derive(Debug, Clone, PartialEq, strum::Display, strum::EnumIter)] #[strum(serialize_all = "lowercase")] pub enum Condition { /// the first entry in client’s WM_CLASS. Instance { operator: RuleOperator, value: String, }, /// the second entry in client’s WM_CLASS. Class { operator: RuleOperator, value: String, }, /// client’s window title. Title { operator: RuleOperator, value: String, }, /// the client’s process id (Warning: the pid is not available for every client. /// This only matches if the client sets _NET_WM_PID to the pid itself). Pid { operator: RuleOperator, value: String, }, /// this client’s process group id. Since the pgid of a window is derived /// from its pid the same restrictions apply as above. Pgid { operator: RuleOperator, value: String, }, /// matches if the age of the rule measured in seconds does not exceed value. /// This condition only can be used with the = operator. If maxage already is /// exceeded (and never will match again), then this rule is removed. /// (With this you can build rules that only live for a certain time.) MaxAge { operator: RuleOperator, value: String, }, /// matches the _NET_WM_WINDOW_TYPE property of a window. /// If _NET_WM_WINDOW_TYPE has multiple entries, then only the first entry is used here. WindowType { operator: RuleOperator, value: String, }, /// matches the WM_WINDOW_ROLE property of a window if it is set by the window. WindowRole { operator: RuleOperator, value: String, }, /// matches if the window does not allow being resized (i.e. if the minimum /// size matches the maximum size). This condition does not take a parameter. FixedSize, } impl ToCommandString for Condition { fn to_command_string(&self) -> String { match self { Condition::Instance { operator, value } | Condition::Class { operator, value } | Condition::Title { operator, value } | Condition::Pid { operator, value } | Condition::Pgid { operator, value } | Condition::MaxAge { operator, value } | Condition::WindowType { operator, value } | Condition::WindowRole { operator, value } => format!("--{self}{operator}{value}"), // Note: There might be a bug where if you use --fixedsize // herbstclient treats it as if fixedsize requires an argument. // // To deal with this on our end, we omit the -- prefix for fixedsize Condition::FixedSize => self.to_string(), } } } impl FromStr for Condition { type Err = ParseError; fn from_str(s: &str) -> Result { // Handle the case for fixedsize first so that we can treat the rest // of the variants as having arguments // // Also, sometimes `fixedsize=0` could come back from `herbstclient list_rules` if s.starts_with(&Self::FixedSize.to_string()) { return Ok(Self::FixedSize); } let ((name, match_val), match_char) = match split::on_first_match(s, &RuleOperator::match_set()) { Some(parts) => parts, None => return Err(ParseError::Empty), }; let mut prop = Self::iter() .find(|i| i.to_string() == name) .ok_or(ParseError::InvalidCommand(s.to_string()))?; match prop.borrow_mut() { Condition::Instance { operator, value } | Condition::Class { operator, value } | Condition::Title { operator, value } | Condition::Pid { operator, value } | Condition::Pgid { operator, value } | Condition::MaxAge { operator, value } | Condition::WindowType { operator, value } | Condition::WindowRole { operator, value } => { *operator = match_char.try_into()?; *value = match_val; } // Should be handled at the top of the function. If it's here that's not a valid // use of fixedsize. Condition::FixedSize => { return Err(ParseError::InvalidValue { value: s.to_string(), expected: "[BUG] Should be handled at the top of the function", }) } }; Ok(prop) } } #[derive(Debug, Clone, PartialEq, strum::Display, strum::EnumIter)] #[strum(serialize_all = "lowercase")] pub enum Consequence { /// moves the client to tag value. Tag(String), /// moves the client to the tag on monitor VALUE. /// If the tag consequence was also specified, /// and switchtag is set for the client, move the client to that tag, /// then display that tag on monitor VALUE. If the tag consequence was specified, /// but switchtag was not, ignore this consequence. Monitor(String), /// decides whether the client gets the input focus in its tag. The default is off. Focus(bool), /// if focus is activated and the client is put to a not focused tag, /// then switchtag tells whether the client’s tag will be shown or not. /// If the tag is shown on any monitor but is not focused, the client’s tag /// only is brought to the current monitor if swap_monitors_to_get_tag is activated. SwitchTag(bool), /// decides whether the client will be managed or not. The default is on. Manage(bool), /// moves the window to a specified index in the tree. Index(i32), /// sets the floating state of the client. Floating(bool), /// sets the pseudotile state of the client. PseudoTile(bool), /// sets the sticky attribute of the client. Sticky(bool), /// sets whether the window state (the fullscreen state and the demands attention flag) /// can be changed by the application via ewmh itself. This does not affect the /// initial fullscreen state requested by the window. EwmhRequests(bool), /// sets whether hlwm should let the client know about EMWH changes /// (currently only the fullscreen state). If this is set, applications do /// not change to their fullscreen-mode while still being fullscreen. EwmhNotify(bool), /// sets the fullscreen flag of the client. Fullscreen(bool), /// emits the custom hook rule VALUE WINID when this rule is triggered /// by a new window with the id WINID. This consequence can be used multiple times, /// which will cause a hook to be emitted for each occurrence of a hook consequence. Hook(Hook), /// sets the keymask for a client. /// A regular expression that is matched against the string representation of /// all key bindings (as they are printed by list_keybinds). /// While this client is focused, only bindings that match the expression will be active. /// Any other bindings will be disabled. The default keymask is an empty string (), /// which does not disable any keybinding KeyMask(String), /// sets a regex that determines which key bindings are inactive /// for a client. /// A regular expression that describes which keybindings are inactive while /// the client is focused. If a key combination is pressed and its string /// representation (as given by list_keybinds) matches the regex, /// then the key press is propagated to the client KeysInactive(String), /// changes the floating position of a window FloatPlacement(FloatPlacement), /// Sets the client’s floating_geometry attribute. /// The VALUE is a rectangle, interpreted relatively to the monitor. /// If floatplacement is also specified for the client (possibly by another rule), /// then only the size of the floating_geometry is used. /// In order to force the position from the geometry, it is necessary to add /// floatplacement=none. FloatingGeometry { x: u32, y: u32 }, } impl ToCommandString for Consequence { fn to_command_string(&self) -> String { match self { Consequence::Focus(value) | Consequence::SwitchTag(value) | Consequence::Manage(value) | Consequence::Floating(value) | Consequence::PseudoTile(value) | Consequence::Sticky(value) | Consequence::EwmhRequests(value) | Consequence::EwmhNotify(value) | Consequence::Fullscreen(value) => vec![ self.to_string(), match value { true => "on".into(), false => "off".into(), }, ], Consequence::Tag(value) | Consequence::Monitor(value) | Consequence::KeyMask(value) | Consequence::KeysInactive(value) => vec![self.to_string(), value.to_string()], Consequence::Index(value) => vec![self.to_string(), value.to_string()], Consequence::Hook(value) => vec![self.to_string(), value.to_string()], Consequence::FloatPlacement(value) => vec![self.to_string(), value.to_string()], Consequence::FloatingGeometry { x, y } => vec![self.to_string(), format!("{x}x{y}")], } .join("=") } } impl FromStr for Consequence { type Err = ParseError; fn from_str(s: &str) -> Result { let parts = split::tab_or_space(s); let mut parts = parts.into_iter(); let name = parts.next().unwrap(); let (name, value_str) = if name.contains('=') { let parts = name.split('=').collect::>(); if parts.len() != 2 { return Err(ParseError::InvalidCommand(s.to_string())); } let mut parts = parts.into_iter(); ( parts.next().unwrap().to_string(), parts.next().unwrap().to_string(), ) } else { match parts.next() { Some(op) => { if op != "=" { return Err(ParseError::InvalidCommand(s.to_string())); } } None => return Err(ParseError::InvalidCommand(s.to_string())), }; let value = parts.collect::>().join("\t"); (name, value) }; let mut cons = Self::iter() .find(|i| i.to_string() == name) .ok_or(ParseError::InvalidCommand(s.to_string()))?; match cons.borrow_mut() { Consequence::Focus(value) | Consequence::SwitchTag(value) | Consequence::Floating(value) | Consequence::PseudoTile(value) | Consequence::Sticky(value) | Consequence::EwmhRequests(value) | Consequence::EwmhNotify(value) | Consequence::Fullscreen(value) | Consequence::Manage(value) => { *value = hlwmbool::from_hlwm_string(&value_str)?; } Consequence::Tag(value) | Consequence::Monitor(value) | Consequence::KeyMask(value) | Consequence::KeysInactive(value) => *value = value_str, Consequence::Index(value) => *value = i32::from_str(&value_str)?, Consequence::Hook(value) => { *value = Hook::from_command_args(&name, [value_str].into_iter())?; } Consequence::FloatPlacement(value) => *value = FloatPlacement::from_str(&value_str)?, Consequence::FloatingGeometry { x, y } => { let mut values = value_str.split('='); *x = u32::from_str(values.next().ok_or(ParseError::ValueMissing)?)?; *y = u32::from_str(values.next().ok_or(ParseError::ValueMissing)?)?; } }; Ok(cons) } } #[derive(Debug, Clone, PartialEq, strum::Display, strum::EnumIter)] #[strum(serialize_all = "lowercase")] pub enum FloatPlacement { /// does not change the placement at all None, /// centers the window on the monitor Center, /// tries to place it with as little overlap to other floating windows as possible. /// If there are multiple options with the least overlap, then the position with /// the least overlap to tiling windows is chosen Smart, } impl FromStr for FloatPlacement { type Err = ParseError; fn from_str(s: &str) -> Result { Self::iter() .find(|i| i.to_string() == s) .ok_or(ParseError::InvalidCommand(s.to_string())) } } impl Default for FloatPlacement { fn default() -> Self { Self::Smart } } #[derive(Debug, Clone, PartialEq, strum::EnumIter)] pub enum Flag { /// negates the next CONDITION. Not, /// only apply this rule once (and delete it afterwards). Once, /// prints the label of the newly created rule to stdout. PrintLabel, /// prepend the rule to the list of rules instead of appending it. /// So its consequences may be overwritten by already existing rules. Prepend, } impl Display for Flag { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Flag::Not => f.write_str("not"), Flag::Once => f.write_str("once"), Flag::PrintLabel => f.write_str("printlabel"), Flag::Prepend => f.write_str("prepend"), } } } impl FromStr for Flag { type Err = ParseError; fn from_str(s: &str) -> Result { match s { "!" | "not" => Ok(Self::Not), "once" => Ok(Self::Once), "printlabel" => Ok(Self::PrintLabel), "prepend" => Ok(Self::Prepend), _ => Err(ParseError::InvalidCommand(s.to_string())), } } } #[macro_export] macro_rules! rule { ($consequence:expr) => { crate::hlwm::rule::Rule::new(None, vec![$consequence], None, None) }; ($condition:expr, $consequence:expr) => { crate::hlwm::rule::Rule::new(Some($condition), vec![$consequence], None, None) }; ($flag:tt: $condition:expr, $consequence:expr) => { crate::hlwm::rule::Rule::new( Some($condition), vec![$consequence], None, Some(rule!(Flag $flag)), ) }; ($label:literal => $consequence:expr) => { crate::hlwm::rule::Rule::new(None, vec![$consequence], Some($label.into()), None) }; ($label:literal => $condition:expr, $consequence:expr) => { crate::hlwm::rule::Rule::new(Some($condition), vec![$consequence], Some($label.into()), None) }; ($label:literal => $flag:tt: $condition:expr, $consequence:expr) => { crate::hlwm::rule::Rule::new(Some($condition), vec![$consequence], Some($label.into()), Some(rule!(Flag $flag))) }; (Flag Not) => { crate::hlwm::rule::Flag::Not }; (Flag Once) => { crate::hlwm::rule::Flag::Once }; (Flag PrintLabel) => { crate::hlwm::rule::Flag::PrintLabel }; (Flag Prepend) => { crate::hlwm::rule::Flag::Prepend }; (Flag $other:tt) => { compile_error!("flag can be one of: Not, Once, PrintLabel, Prepend") } } #[derive(Clone, Debug, PartialEq, Eq)] pub enum Unrule { All, Rule(String), } impl Display for Unrule { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Unrule::All => f.write_str("--all"), Unrule::Rule(rule) => f.write_str(&rule), } } } impl Default for Unrule { fn default() -> Self { Unrule::All } } impl FromStr for Unrule { type Err = Infallible; fn from_str(s: &str) -> Result { match s { "--all" | "-F" => Ok(Self::All), _ => Ok(Self::Rule(s.to_string())), } } } #[cfg(test)] mod test { use std::str::FromStr; use serde::{Deserialize, Serialize}; use crate::{ hlwm::{parser::FromStrings, rule::FloatPlacement}, split, }; use pretty_assertions::assert_eq; use super::{Condition, Consequence, Flag, Rule, RuleOperator}; #[derive(Debug, Deserialize, Serialize)] pub struct RuleWrapper { rule: Rule, } #[test] fn rule_serialize_deserialize() { for rule in [ Rule::new( Some(Condition::Class { operator: RuleOperator::Equal, value: "Netscape".into(), }), vec![Consequence::Tag(1.to_string())], Some("label".into()), Some(Flag::Not), ), Rule::new( Some(Condition::FixedSize), vec![Consequence::Floating(true)], None, None, ), ] { let serialized = toml::to_string_pretty(&RuleWrapper { rule: rule.clone() }) .expect("serializing rule"); let deserialized: RuleWrapper = toml::from_str(&serialized).expect("deserializing rule"); assert_eq!(rule, deserialized.rule); } } #[test] fn rules_from_list_rules_parse() { const INPUT: &str = r#"label=0 windowtype~_NET_WM_WINDOW_TYPE_(DIALOG|UTILITY|SPLASH) floating=on label=1 focus=on label=2 floatplacement=smart label=3 windowtype~_NET_WM_WINDOW_TYPE_(DIALOG) floating=on label=4 windowtype~_NET_WM_WINDOW_TYPE_(NOTIFICATION|DOCK|DESKTOP) manage=off label=5 fixedsize=0 floating=true"#; let parsed = INPUT .split('\n') .map(|l| Rule::from_strings(split::tab_or_space(l).into_iter())) .collect::, _>>() .expect("parsing error"); let expected = [ Rule::new( Some(Condition::WindowType { operator: RuleOperator::Regex, value: "_NET_WM_WINDOW_TYPE_(DIALOG|UTILITY|SPLASH)".into(), }), vec![Consequence::Floating(true)], Some("0".into()), None, ), Rule::new(None, vec![Consequence::Focus(true)], Some("1".into()), None), Rule::new( None, vec![Consequence::FloatPlacement(FloatPlacement::Smart)], Some("2".into()), None, ), Rule::new( Some(Condition::WindowType { operator: RuleOperator::Regex, value: "_NET_WM_WINDOW_TYPE_(DIALOG)".into(), }), vec![Consequence::Floating(true)], Some("3".to_string()), None, ), Rule::new( Some(Condition::WindowType { operator: RuleOperator::Regex, value: "_NET_WM_WINDOW_TYPE_(NOTIFICATION|DOCK|DESKTOP)".into(), }), vec![Consequence::Manage(false)], Some("4".to_string()), None, ), Rule::new( Some(Condition::FixedSize), vec![Consequence::Floating(true)], Some("5".into()), None, ), ]; parsed .into_iter() .zip(expected) .for_each(|(parsed, expected)| { assert_eq!(expected, parsed); }); } }