hlctl/src/hlwm/rule.rs

759 lines
25 KiB
Rust
Raw Normal View History

2024-02-29 20:36:38 +00:00
use std::{
borrow::BorrowMut,
2024-03-01 19:50:32 +00:00
convert::Infallible,
2024-02-29 20:36:38 +00:00
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,
};
2024-02-29 20:36:38 +00:00
#[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<String>,
flag: Option<Flag>,
/// 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<Condition>,
consequences: Vec<Consequence>,
}
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::<Vec<_>>()
.join("\t"),
);
args.join("\t")
}
}
impl Rule {
pub fn new(
condition: Option<Condition>,
consequences: Vec<Consequence>,
label: Option<String>,
flag: Option<Flag>,
) -> Self {
Self {
label,
flag,
condition,
consequences,
}
}
}
impl FromStrings for Rule {
fn from_strings<I: Iterator<Item = String>>(s: I) -> Result<Self, ParseError> {
2024-02-29 20:36:38 +00:00
let mut condition: Option<Condition> = None;
let mut consequences = vec![];
let mut label: Option<String> = None;
let mut flag: Option<Flag> = None;
let mut args = s.map(|part| part.strip_prefix("--").unwrap_or(part.as_str()).to_string());
2024-02-29 20:36:38 +00:00
let mut original = Vec::new(); // For InvalidValue error
2024-02-29 20:36:38 +00:00
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;
}
}
}
2024-02-29 20:36:38 +00:00
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",
});
2024-02-29 20:36:38 +00:00
}
Ok(Self {
label,
flag,
condition: condition,
consequences: consequences,
})
}
}
impl Serialize for Rule {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_command_string().replace("\t", " "))
}
}
impl<'de> Deserialize<'de> for Rule {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
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);
2024-02-29 20:36:38 +00:00
Ok(Self::from_strings(strings.into_iter()).map_err(|_| {
2024-02-29 20:36:38 +00:00
serde::de::Error::invalid_value(serde::de::Unexpected::Str(&str_val), &Expect)
})?)
}
}
#[derive(Debug, Clone, PartialEq, strum::EnumIter)]
pub enum RuleOperator {
/// ~ matches if clients property is matched by the regex value.
Regex,
/// = matches if clients 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<char> for RuleOperator {
type Error = ParseError;
2024-02-29 20:36:38 +00:00
fn try_from(value: char) -> Result<Self, Self::Error> {
Self::iter()
.find(|i| i.char() == value)
.ok_or(ParseError::InvalidCommand(value.to_string()))
2024-02-29 20:36:38 +00:00
}
}
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;
2024-02-29 20:36:38 +00:00
fn from_str(s: &str) -> Result<Self, Self::Err> {
s.chars().next().ok_or(ParseError::Empty)?.try_into()
2024-02-29 20:36:38 +00:00
}
}
#[derive(Debug, Clone, PartialEq, strum::Display, strum::EnumIter)]
#[strum(serialize_all = "lowercase")]
pub enum Condition {
/// the first entry in clients WM_CLASS.
Instance {
operator: RuleOperator,
value: String,
},
/// the second entry in clients WM_CLASS.
Class {
operator: RuleOperator,
value: String,
},
/// clients window title.
Title {
operator: RuleOperator,
value: String,
},
/// the clients 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 clients 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;
2024-02-29 20:36:38 +00:00
fn from_str(s: &str) -> Result<Self, Self::Err> {
// 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()) {
2024-02-29 20:36:38 +00:00
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),
2024-02-29 20:36:38 +00:00
};
let mut prop = Self::iter()
.find(|i| i.to_string() == name)
.ok_or(ParseError::InvalidCommand(s.to_string()))?;
2024-02-29 20:36:38 +00:00
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",
})
}
2024-02-29 20:36:38 +00:00
};
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 clients tag will be shown or not.
/// If the tag is shown on any monitor but is not focused, the clients 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 clients 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;
2024-02-29 20:36:38 +00:00
fn from_str(s: &str) -> Result<Self, Self::Err> {
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::<Vec<_>>();
if parts.len() != 2 {
return Err(ParseError::InvalidCommand(s.to_string()));
2024-02-29 20:36:38 +00:00
}
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()));
2024-02-29 20:36:38 +00:00
}
}
None => return Err(ParseError::InvalidCommand(s.to_string())),
2024-02-29 20:36:38 +00:00
};
let value = parts.collect::<Vec<_>>().join("\t");
(name, value)
};
let mut cons = Self::iter()
.find(|i| i.to_string() == name)
.ok_or(ParseError::InvalidCommand(s.to_string()))?;
2024-02-29 20:36:38 +00:00
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())?;
2024-02-29 20:36:38 +00:00
}
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)?)?;
2024-02-29 20:36:38 +00:00
}
};
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;
2024-02-29 20:36:38 +00:00
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::iter()
.find(|i| i.to_string() == s)
.ok_or(ParseError::InvalidCommand(s.to_string()))
2024-02-29 20:36:38 +00:00
}
}
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;
2024-02-29 20:36:38 +00:00
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"!" | "not" => Ok(Self::Not),
"once" => Ok(Self::Once),
"printlabel" => Ok(Self::PrintLabel),
"prepend" => Ok(Self::Prepend),
_ => Err(ParseError::InvalidCommand(s.to_string())),
2024-02-29 20:36:38 +00:00
}
}
}
#[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")
}
}
2024-03-01 19:50:32 +00:00
#[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<Self, Self::Err> {
match s {
"--all" | "-F" => Ok(Self::All),
_ => Ok(Self::Rule(s.to_string())),
}
}
}
2024-02-29 20:36:38 +00:00
#[cfg(test)]
mod test {
use std::str::FromStr;
2024-02-29 20:36:38 +00:00
use serde::{Deserialize, Serialize};
use crate::{
hlwm::{parser::FromStrings, rule::FloatPlacement},
split,
};
use pretty_assertions::assert_eq;
2024-02-29 20:36:38 +00:00
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,
),
] {
2024-02-29 20:36:38 +00:00
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::<Result<Vec<_>, _>>()
.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);
});
}
2024-02-29 20:36:38 +00:00
}