hlctl/src/hlwm/rule.rs

759 lines
25 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<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> {
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());
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<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);
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 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;
fn try_from(value: char) -> Result<Self, Self::Error> {
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<Self, Self::Err> {
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 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;
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()) {
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 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;
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()));
}
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::<Vec<_>>().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, Self::Err> {
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<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())),
}
}
}
#[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<Self, Self::Err> {
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::<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);
});
}
}