diff --git a/jay-config/src/_private.rs b/jay-config/src/_private.rs index cf4b6064..32c28af9 100644 --- a/jay-config/src/_private.rs +++ b/jay-config/src/_private.rs @@ -4,7 +4,7 @@ mod logging; pub(crate) mod string_error; use { - crate::video::Mode, + crate::{client::ClientMatcher, video::Mode}, bincode::Options, serde::{Deserialize, Serialize}, std::marker::PhantomData, @@ -64,3 +64,24 @@ impl WireMode { pub struct PollableId(pub u64); pub const DEFAULT_SEAT_NAME: &str = "default"; + +#[derive(Serialize, Deserialize, Clone, Debug, Hash, Eq, PartialEq)] +pub enum GenericCriterionIpc { + Matcher(T), + Not(T), + List { list: Vec, all: bool }, + Exactly { list: Vec, num: usize }, +} + +#[derive(Serialize, Deserialize, Clone, Debug, Hash, Eq, PartialEq)] +pub enum ClientCriterionIpc { + Generic(GenericCriterionIpc), + String { + string: String, + field: ClientCriterionStringField, + regex: bool, + }, +} + +#[derive(Serialize, Deserialize, Clone, Debug, Hash, Eq, PartialEq)] +pub enum ClientCriterionStringField {} diff --git a/jay-config/src/_private/client.rs b/jay-config/src/_private/client.rs index 7a98bc94..fd0208db 100644 --- a/jay-config/src/_private/client.rs +++ b/jay-config/src/_private/client.rs @@ -3,14 +3,15 @@ use { crate::{ _private::{ - Config, ConfigEntry, ConfigEntryGen, PollableId, VERSION, WireMode, bincode_ops, + ClientCriterionIpc, Config, ConfigEntry, ConfigEntryGen, GenericCriterionIpc, + PollableId, VERSION, WireMode, bincode_ops, ipc::{ ClientMessage, InitMessage, Response, ServerFeature, ServerMessage, WorkspaceSource, }, logging, }, Axis, Direction, ModifiedKeySym, PciId, Workspace, - client::Client, + client::{Client, ClientCriterion, ClientMatcher, MatchedClient}, exec::Command, input::{ FocusFollowsMouseMode, InputDevice, Seat, SwitchEvent, acceleration::AccelProfile, @@ -112,10 +113,16 @@ pub(crate) struct ConfigClient { status_task: Cell>>, i3bar_separator: RefCell>>, pressed_keysym: Cell>, + client_match_handlers: RefCell>, feat_mod_mask: Cell, } +struct ClientMatchHandler { + cb: Callback, + latched: HashMap>, +} + struct Interest { result: Option>, waker: Option, @@ -245,6 +252,7 @@ pub unsafe extern "C" fn init( status_task: Default::default(), i3bar_separator: Default::default(), pressed_keysym: Cell::new(None), + client_match_handlers: Default::default(), feat_mod_mask: Cell::new(false), }); let init = unsafe { slice::from_raw_parts(init, size) }; @@ -280,6 +288,16 @@ macro_rules! get_response { } } +#[derive(Copy, Clone, Debug, Hash, Eq, PartialEq)] +#[non_exhaustive] +enum GenericCriterion<'a, Crit, Matcher> { + Matcher(Matcher), + Not(&'a Crit), + All(&'a [Crit]), + Any(&'a [Crit]), + Exactly(usize, &'a [Crit]), +} + impl ConfigClient { fn send(&self, msg: &ClientMessage) { let mut buf = self.bufs.borrow_mut().pop().unwrap_or_default(); @@ -1419,6 +1437,151 @@ impl ConfigClient { self.send(&ClientMessage::ClientKill { client }); } + fn create_generic_matcher( + &self, + criterion: GenericCriterion<'_, Crit, Matcher>, + child: bool, + create_child_matcher: impl Fn(Crit) -> (Matcher, bool), + create_matcher: impl Fn(GenericCriterionIpc) -> Matcher, + destroy_matcher: impl Fn(Matcher), + ) -> (Matcher, bool) + where + Crit: Copy, + Matcher: Copy, + { + let mut ad_hoc = vec![]; + let mut create_child_matcher = |c: Crit| { + let (m, original) = create_child_matcher(c); + if original { + ad_hoc.push(m); + } + m + }; + let mut create_vec = |l: &[Crit]| { + let mut list = Vec::with_capacity(l.len()); + for c in l { + list.push(create_child_matcher(*c)); + } + list + }; + let criterion = match criterion { + GenericCriterion::Matcher(m) => { + if child { + return (m, false); + } + GenericCriterionIpc::Matcher(m) + } + GenericCriterion::Not(c) => GenericCriterionIpc::Not(create_child_matcher(*c)), + GenericCriterion::All(l) => GenericCriterionIpc::List { + list: create_vec(l), + all: true, + }, + GenericCriterion::Any(l) => GenericCriterionIpc::List { + list: create_vec(l), + all: false, + }, + GenericCriterion::Exactly(num, l) => GenericCriterionIpc::Exactly { + list: create_vec(l), + num, + }, + }; + let matcher = create_matcher(criterion); + for matcher in ad_hoc { + destroy_matcher(matcher); + } + (matcher, true) + } + + pub fn create_client_matcher(&self, criterion: ClientCriterion<'_>) -> ClientMatcher { + self.create_client_matcher_(criterion, false).0 + } + + fn create_client_matcher_( + &self, + criterion: ClientCriterion<'_>, + child: bool, + ) -> (ClientMatcher, bool) { + #[expect(unused_macros)] + macro_rules! string { + ($t:expr, $field:ident, $regex:expr) => { + ClientCriterionIpc::String { + string: $t.to_string(), + field: ClientCriterionStringField::$field, + regex: $regex, + } + }; + } + let create_matcher = |criterion| { + let res = self.send_with_response(&ClientMessage::CreateClientMatcher { + criterion: ClientCriterionIpc::Generic(criterion), + }); + get_response!(res, ClientMatcher(0), CreateClientMatcher { matcher }); + matcher + }; + let destroy_matcher = |matcher| { + self.send(&ClientMessage::DestroyClientMatcher { matcher }); + }; + let generic = |crit: GenericCriterion| { + self.create_generic_matcher::( + crit, + child, + |c| self.create_client_matcher_(c, true), + create_matcher, + destroy_matcher, + ) + }; + #[expect(unused_variables)] + let criterion = match criterion { + ClientCriterion::Matcher(m) => return generic(GenericCriterion::Matcher(m)), + ClientCriterion::Not(c) => return generic(GenericCriterion::Not(c)), + ClientCriterion::All(c) => return generic(GenericCriterion::All(c)), + ClientCriterion::Any(c) => return generic(GenericCriterion::Any(c)), + ClientCriterion::Exactly(n, c) => return generic(GenericCriterion::Exactly(n, c)), + }; + #[expect(unreachable_code)] + let res = self.send_with_response(&ClientMessage::CreateClientMatcher { criterion }); + get_response!( + res, + (ClientMatcher(0), false), + CreateClientMatcher { matcher } + ); + (matcher, true) + } + + pub fn set_client_matcher_handler( + &self, + matcher: ClientMatcher, + cb: impl FnMut(MatchedClient) + 'static, + ) { + let cb = Rc::new(RefCell::new(cb)); + let handlers = &mut *self.client_match_handlers.borrow_mut(); + let handler = handlers.entry(matcher).or_insert_with(|| { + self.send(&ClientMessage::EnableClientMatcherEvents { matcher }); + ClientMatchHandler { + cb: cb.clone(), + latched: Default::default(), + } + }); + handler.cb = cb.clone(); + } + + pub fn set_client_matcher_latch_handler( + &self, + matcher: ClientMatcher, + client: Client, + cb: impl FnOnce() + 'static, + ) { + let handlers = &mut *self.client_match_handlers.borrow_mut(); + if let Some(handler) = handlers.get_mut(&matcher) { + handler.latched.insert(client, Box::new(cb)); + } + } + + pub fn destroy_client_matcher(&self, matcher: ClientMatcher) { + self.send(&ClientMessage::DestroyClientMatcher { matcher }); + self.client_match_handlers.borrow_mut().remove(&matcher); + } + fn handle_msg(&self, msg: &[u8]) { self.handle_msg2(msg); self.dispatch_futures(); @@ -1681,6 +1844,30 @@ impl ConfigClient { run_cb("switch event", &cb, event); } } + ServerMessage::ClientMatcherMatched { matcher, client } => { + let cb = { + let handlers = self.client_match_handlers.borrow(); + let Some(handler) = handlers.get(&matcher) else { + return; + }; + handler.cb.clone() + }; + let matched = MatchedClient { matcher, client }; + cb.borrow_mut()(matched); + } + ServerMessage::ClientMatcherUnmatched { matcher, client } => { + let cb = { + let mut handlers = self.client_match_handlers.borrow_mut(); + let Some(handler) = handlers.get_mut(&matcher) else { + return; + }; + let Some(cb) = handler.latched.remove(&client) else { + return; + }; + cb + }; + cb(); + } } } diff --git a/jay-config/src/_private/ipc.rs b/jay-config/src/_private/ipc.rs index 07b66d54..3f9525ac 100644 --- a/jay-config/src/_private/ipc.rs +++ b/jay-config/src/_private/ipc.rs @@ -1,8 +1,8 @@ use { crate::{ - _private::{PollableId, WireMode}, + _private::{ClientCriterionIpc, PollableId, WireMode}, Axis, Direction, PciId, Workspace, - client::Client, + client::{Client, ClientMatcher}, input::{ FocusFollowsMouseMode, InputDevice, Seat, SwitchEvent, acceleration::AccelProfile, capability::Capability, @@ -94,6 +94,14 @@ pub enum ServerMessage { input_device: InputDevice, event: SwitchEvent, }, + ClientMatcherMatched { + matcher: ClientMatcher, + client: Client, + }, + ClientMatcherUnmatched { + matcher: ClientMatcher, + client: Client, + }, } #[derive(Serialize, Deserialize, Debug)] @@ -664,6 +672,15 @@ pub enum ClientMessage<'a> { window: Window, pinned: bool, }, + CreateClientMatcher { + criterion: ClientCriterionIpc, + }, + DestroyClientMatcher { + matcher: ClientMatcher, + }, + EnableClientMatcherEvents { + matcher: ClientMatcher, + }, } #[derive(Serialize, Deserialize, Debug)] @@ -884,6 +901,9 @@ pub enum Response { GetWindowIsVisible { visible: bool, }, + CreateClientMatcher { + matcher: ClientMatcher, + }, } #[derive(Serialize, Deserialize, Debug)] diff --git a/jay-config/src/client.rs b/jay-config/src/client.rs index 5e908352..e60e100f 100644 --- a/jay-config/src/client.rs +++ b/jay-config/src/client.rs @@ -1,6 +1,9 @@ //! Tools for inspecting and manipulating clients. -use serde::{Deserialize, Serialize}; +use { + serde::{Deserialize, Serialize}, + std::ops::Deref, +}; /// A client connected to the compositor. #[derive(Serialize, Deserialize, Copy, Clone, Debug, Hash, Eq, PartialEq)] @@ -34,3 +37,85 @@ impl Client { pub fn clients() -> Vec { get!().clients() } + +/// A client matcher. +#[derive(Serialize, Deserialize, Copy, Clone, Debug, Hash, Eq, PartialEq)] +pub struct ClientMatcher(pub u64); + +/// A matched client. +#[derive(Serialize, Deserialize, Copy, Clone, Debug, Hash, Eq, PartialEq)] +pub struct MatchedClient { + pub(crate) matcher: ClientMatcher, + pub(crate) client: Client, +} + +/// A criterion for matching a client. +#[derive(Copy, Clone, Debug, Hash, Eq, PartialEq)] +#[non_exhaustive] +pub enum ClientCriterion<'a> { + /// Matches if the contained matcher matches. + Matcher(ClientMatcher), + /// Matches if the contained criterion does not match. + Not(&'a ClientCriterion<'a>), + /// Matches if all of the contained criteria match. + All(&'a [ClientCriterion<'a>]), + /// Matches if any of the contained criteria match. + Any(&'a [ClientCriterion<'a>]), + /// Matches if an exact number of the contained criteria match. + Exactly(usize, &'a [ClientCriterion<'a>]), +} + +impl ClientCriterion<'_> { + /// Converts the criterion to a matcher. + pub fn to_matcher(self) -> ClientMatcher { + get!(ClientMatcher(0)).create_client_matcher(self) + } + + /// Binds a function to execute when the criterion matches a client. + /// + /// This leaks the matcher. + pub fn bind(self, cb: F) { + self.to_matcher().bind(cb); + } +} + +impl ClientMatcher { + /// Destroys the matcher. + /// + /// Any bound callback will no longer be executed. + pub fn destroy(self) { + get!().destroy_client_matcher(self); + } + + /// Sets a function to execute when the criterion matches a client. + /// + /// Replaces any already bound callback. + pub fn bind(self, cb: F) { + get!().set_client_matcher_handler(self, cb); + } +} + +impl MatchedClient { + /// Returns the client that matched. + pub fn client(self) -> Client { + self.client + } + + /// Returns the matcher. + pub fn matcher(self) -> ClientMatcher { + self.matcher + } + + /// Latches a function to be executed when the client no longer matches the criteria. + pub fn latch(self, cb: F) { + get!().set_client_matcher_latch_handler(self.matcher, self.client, cb); + } +} + +impl Deref for MatchedClient { + type Target = Client; + + fn deref(&self) -> &Self::Target { + &self.client + } +} diff --git a/src/client.rs b/src/client.rs index f925a2ea..42075314 100644 --- a/src/client.rs +++ b/src/client.rs @@ -2,6 +2,10 @@ use { crate::{ async_engine::SpawnedFuture, client::{error::LookupError, objects::Objects}, + criteria::{ + CritDestroyListener, CritMatcherId, + clm::{CL_CHANGED_DESTROYED, CL_CHANGED_NEW, ClMatcherChange}, + }, ifs::{ wl_display::WlDisplay, wl_registry::WlRegistry, @@ -31,7 +35,7 @@ use { fmt::{Debug, Display, Formatter}, mem, ops::DerefMut, - rc::Rc, + rc::{Rc, Weak}, }, uapi::{OwnedFd, c}, }; @@ -177,6 +181,8 @@ impl Clients { )), wire_scale: Default::default(), focus_stealing_serial: Default::default(), + changed_properties: Default::default(), + destroyed: Default::default(), }); track!(data, data); let display = Rc::new(WlDisplay::new(&data)); @@ -196,6 +202,7 @@ impl Clients { data.pid_info.comm, effective_caps, ); + client.data.property_changed(CL_CHANGED_NEW); self.clients.borrow_mut().insert(client.data.id, client); Ok(data) } @@ -251,6 +258,7 @@ impl Drop for ClientHolder { self.data.surfaces_by_xwayland_serial.clear(); self.data.remove_activation_tokens(); self.data.commit_timelines.clear(); + self.data.property_changed(CL_CHANGED_DESTROYED); if self.data.is_xwayland { if let Some(pidfd) = self.data.state.xwayland.pidfd.get() { if let Err(e) = pidfd_send_signal(&pidfd, c::SIGKILL) { @@ -296,6 +304,8 @@ pub struct Client { pub commit_timelines: Rc, pub wire_scale: Cell>, pub focus_stealing_serial: Cell>, + pub changed_properties: Cell, + pub destroyed: CopyHashMap>>>, } pub const NUM_CACHED_SERIAL_RANGES: usize = 64; @@ -501,6 +511,14 @@ impl Client { self.state.activation_tokens.remove(token); } } + + pub fn property_changed(self: &Rc, change: ClMatcherChange) { + let props = self.changed_properties.get(); + self.changed_properties.set(props | change); + if props.is_none() && change.is_some() { + self.state.cl_matcher_manager.changed(self); + } + } } pub trait WaylandObject: Object { diff --git a/src/compositor.rs b/src/compositor.rs index 7574f20f..b4854958 100644 --- a/src/compositor.rs +++ b/src/compositor.rs @@ -15,6 +15,10 @@ use { cmm::{cmm_manager::ColorManager, cmm_primaries::Primaries}, config::ConfigProxy, cpu_worker::{CpuWorker, CpuWorkerError}, + criteria::{ + CritMatcherIds, + clm::{ClMatcherManager, handle_cl_changes, handle_cl_leaf_events}, + }, damage::{DamageVisualizer, visualize_damage}, dbus::Dbus, ei::ei_client::EiClients, @@ -156,6 +160,7 @@ fn start_compositor2( scales.add(Scale::from_int(1)); let cpu_worker = Rc::new(CpuWorker::new(&ring, &engine)?); let color_manager = ColorManager::new(); + let crit_ids = Rc::new(CritMatcherIds::default()); let state = Rc::new(State { kb_ctx, backend: CloneCell::new(Rc::new(DummyBackend)), @@ -293,6 +298,7 @@ fn start_compositor2( float_above_fullscreen: Cell::new(false), icons: Default::default(), show_pin_icon: Cell::new(false), + cl_matcher_manager: ClMatcherManager::new(&crit_ids), }); state.tracker.register(ClientId::from_raw(0)); create_dummy_output(&state); @@ -465,6 +471,11 @@ fn start_global_event_handlers( "workspace manager done", workspace_manager_done(state.clone()), ), + eng.spawn("cl matcher manager", handle_cl_changes(state.clone())), + eng.spawn( + "cl matcher leaf events", + handle_cl_leaf_events(state.clone()), + ), ] } diff --git a/src/config.rs b/src/config.rs index c35b90de..ba31e844 100644 --- a/src/config.rs +++ b/src/config.rs @@ -214,6 +214,10 @@ impl ConfigProxy { window_ids: NumCell::new(1), windows_from_tl_id: Default::default(), windows_to_tl_id: Default::default(), + client_matcher_ids: NumCell::new(1), + client_matchers: Default::default(), + client_matcher_cache: Default::default(), + client_matcher_leafs: Default::default(), }); let init_msg = bincode_ops() .serialize(&InitMessage::V1(V1InitMessage {})) diff --git a/src/config/handler.rs b/src/config/handler.rs index 83c1abdf..ace6a277 100644 --- a/src/config/handler.rs +++ b/src/config/handler.rs @@ -9,6 +9,9 @@ use { cmm::cmm_transfer_function::TransferFunction, compositor::MAX_EXTENTS, config::ConfigProxy, + criteria::{ + CritLiteralOrRegex, CritMgrExt, CritTarget, CritUpstreamNode, clm::ClmLeafMatcher, + }, format::config_formats, ifs::wl_seat::{SeatId, WlSeatGlobal}, io_uring::TaskResultExt, @@ -38,11 +41,11 @@ use { bincode::Options, jay_config::{ _private::{ - PollableId, WireMode, bincode_ops, + ClientCriterionIpc, GenericCriterionIpc, PollableId, WireMode, bincode_ops, ipc::{ClientMessage, Response, ServerMessage, WorkspaceSource}, }, Axis, Direction, Workspace, - client::Client as ConfigClient, + client::{Client as ConfigClient, ClientMatcher}, input::{ FocusFollowsMouseMode, InputDevice, Seat, acceleration::{ACCEL_PROFILE_ADAPTIVE, ACCEL_PROFILE_FLAT, AccelProfile}, @@ -65,7 +68,15 @@ use { }, libloading::Library, log::Level, - std::{cell::Cell, ops::Deref, rc::Rc, sync::Arc, time::Duration}, + regex::Regex, + std::{ + cell::Cell, + hash::Hash, + ops::Deref, + rc::{Rc, Weak}, + sync::Arc, + time::Duration, + }, thiserror::Error, uapi::{OwnedFd, c, fcntl_dupfd_cloexec}, }; @@ -97,6 +108,12 @@ pub(super) struct ConfigProxyHandler { pub window_ids: NumCell, pub windows_from_tl_id: CopyHashMap, pub windows_to_tl_id: CopyHashMap, + + pub client_matcher_ids: NumCell, + pub client_matchers: + CopyHashMap>>>, + pub client_matcher_cache: CriterionCache>, + pub client_matcher_leafs: CopyHashMap>, } pub struct Pollable { @@ -113,6 +130,40 @@ pub(super) struct TimerData { _handler: SpawnedFuture<()>, } +pub type CriterionCache = Rc>>>; + +pub struct CachedCriterion +where + K: Hash + Eq, + T: CritTarget, +{ + crit: K, + cache: CriterionCache, + upstream: Vec>>, + node: Rc>, +} + +impl Drop for CachedCriterion +where + K: Hash + Eq, + T: CritTarget, +{ + fn drop(&mut self) { + self.cache.remove(&self.crit); + } +} + +impl CachedCriterion +where + K: Hash + Eq, + T: CritTarget, +{ + #[allow(clippy::allow_attributes, dead_code)] + fn any(&self, v: &impl Fn(&K) -> bool) -> bool { + v(&self.crit) || self.upstream.iter().any(|u| u.any(v)) + } +} + impl ConfigProxyHandler { pub fn do_drop(&self) { self.dropped.set(true); @@ -122,6 +173,9 @@ impl ConfigProxyHandler { self.pollables.clear(); + self.client_matcher_leafs.clear(); + self.client_matchers.clear(); + if let Some(path) = &self.path { if let Err(e) = uapi::unlink(path.as_str()) { log::error!("Could not unlink {}: {}", path, ErrorFmt(OsError(e.0))); @@ -1725,6 +1779,148 @@ impl ConfigProxyHandler { .ok_or(CphError::WindowDoesNotExist(window)) } + fn get_client_matcher( + &self, + matcher: ClientMatcher, + ) -> Result>>, CphError> { + self.client_matchers + .get(&matcher) + .ok_or(CphError::ClientMatcherDoesNotExist(matcher)) + } + + fn sort_generic_matcher( + &self, + generic: &mut GenericCriterionIpc, + key: impl FnMut(&T) -> K, + ) where + K: Ord, + { + match generic { + GenericCriterionIpc::List { list, .. } | GenericCriterionIpc::Exactly { list, .. } => { + list.sort_by_key(key) + } + GenericCriterionIpc::Matcher(_) | GenericCriterionIpc::Not(_) => {} + } + } + + fn create_generic_matcher( + &self, + mgr: &Mgr, + generic: &GenericCriterionIpc, + upstream: &mut Vec>>, + get_matcher: impl Fn(&Matcher) -> Result>, CphError>, + ) -> Result>, CphError> + where + Crit: Clone + Hash + Eq, + Mgr: CritMgrExt, + { + let mut get_upstream = |m: &Matcher| -> Result<_, CphError> { + let m = get_matcher(m)?; + let node = m.node.clone(); + upstream.push(m); + Ok(node) + }; + let node = match generic { + GenericCriterionIpc::Matcher(m) => get_matcher(m)?.node.clone(), + GenericCriterionIpc::Not(m) => mgr.not(&get_upstream(m)?), + GenericCriterionIpc::List { list, all } => { + let mut m = Vec::with_capacity(list.len()); + for c in list { + m.push(get_upstream(c)?); + } + mgr.list(&m, *all) + } + GenericCriterionIpc::Exactly { list, num } => { + let mut m = Vec::with_capacity(list.len()); + for c in list { + m.push(get_upstream(c)?); + } + mgr.exactly(&m, *num) + } + }; + Ok(node) + } + + fn handle_create_client_matcher( + &self, + mut criterion: ClientCriterionIpc, + ) -> Result<(), CphError> { + if let ClientCriterionIpc::Generic(generic) = &mut criterion { + self.sort_generic_matcher(generic, |m| m.0); + } + let id = ClientMatcher(self.client_matcher_ids.fetch_add(1)); + let cache = &self.client_matcher_cache; + if let Some(matcher) = cache.get(&criterion) { + if let Some(matcher) = matcher.upgrade() { + self.client_matchers.set(id, matcher); + self.respond(Response::CreateClientMatcher { matcher: id }); + return Ok(()); + } + } + let mgr = &self.state.cl_matcher_manager; + let mut upstream = vec![]; + let matcher = match &criterion { + ClientCriterionIpc::Generic(m) => { + self.create_generic_matcher(mgr, m, &mut upstream, |m| self.get_client_matcher(*m))? + } + ClientCriterionIpc::String { + string, + field, + regex, + } => { + #[expect(unused_variables)] + let needle = match *regex { + true => { + let regex = Regex::new(string).map_err(CphError::InvalidRegex)?; + CritLiteralOrRegex::Regex(regex) + } + false => CritLiteralOrRegex::Literal(string.to_string()), + }; + match *field {} + } + }; + let cached = Rc::new(CachedCriterion { + crit: criterion.clone(), + cache: cache.clone(), + upstream, + node: matcher.clone(), + }); + cache.set(criterion, Rc::downgrade(&cached)); + self.client_matchers.set(id, cached); + self.respond(Response::CreateClientMatcher { matcher: id }); + Ok(()) + } + + fn handle_destroy_client_matcher(&self, matcher: ClientMatcher) { + self.client_matchers.remove(&matcher); + self.client_matcher_leafs.remove(&matcher); + } + + fn handle_enable_client_matcher_events( + self: &Rc, + matcher: ClientMatcher, + ) -> Result<(), CphError> { + if self.client_matcher_leafs.contains(&matcher) { + return Ok(()); + } + let upstream = self.get_client_matcher(matcher)?; + let slf = self.clone(); + let leaf = self + .state + .cl_matcher_manager + .leaf(&upstream.node, move |id| { + let client = ConfigClient(id.raw()); + slf.send(&ServerMessage::ClientMatcherMatched { matcher, client }); + let slf = slf.clone(); + Box::new(move || { + slf.send(&ServerMessage::ClientMatcherUnmatched { matcher, client }); + }) + }); + self.client_matcher_leafs.set(matcher, leaf); + self.state.cl_matcher_manager.rematch_all(&self.state); + Ok(()) + } + fn spaces_change(&self) { struct V; impl NodeVisitorBase for V { @@ -2512,6 +2708,15 @@ impl ConfigProxyHandler { ClientMessage::GetWindowClient { window } => self .handle_get_window_client(window) .wrn("get_window_client")?, + ClientMessage::CreateClientMatcher { criterion } => self + .handle_create_client_matcher(criterion) + .wrn("create_window_matcher")?, + ClientMessage::DestroyClientMatcher { matcher } => { + self.handle_destroy_client_matcher(matcher) + } + ClientMessage::EnableClientMatcherEvents { matcher } => self + .handle_enable_client_matcher_events(matcher) + .wrn("enable_window_matcher_events")?, } Ok(()) } @@ -2593,6 +2798,10 @@ enum CphError { WindowDoesNotExist(Window), #[error("Window {0:?} is not visible")] WindowNotVisible(Window), + #[error("Client matcher {0:?} does not exist")] + ClientMatcherDoesNotExist(ClientMatcher), + #[error("Could not parse regex")] + InvalidRegex(#[source] regex::Error), } trait WithRequestName { diff --git a/src/criteria.rs b/src/criteria.rs index 5e6f21f2..2da4f95b 100644 --- a/src/criteria.rs +++ b/src/criteria.rs @@ -1,3 +1,4 @@ +pub mod clm; mod crit_graph; pub mod crit_leaf; mod crit_matchers; @@ -27,7 +28,6 @@ type RootMatcherMap = CopyHashMap = StaticMap>>>; #[derive(Clone)] -#[expect(dead_code)] pub enum CritLiteralOrRegex { Literal(String), Regex(Regex), @@ -42,7 +42,6 @@ impl CritLiteralOrRegex { } } -#[expect(dead_code)] pub trait CritMgrExt: CritMgr { fn list( &self, @@ -85,6 +84,7 @@ pub trait CritMgrExt: CritMgr { upstream.not(self) } + #[expect(dead_code)] fn root(&self, criterion: T) -> Rc> where T: CritRootCriterion, diff --git a/src/criteria/clm.rs b/src/criteria/clm.rs new file mode 100644 index 00000000..19538ef0 --- /dev/null +++ b/src/criteria/clm.rs @@ -0,0 +1,203 @@ +pub mod clm_matchers; + +use { + crate::{ + client::{Client, ClientId}, + criteria::{ + CritDestroyListener, CritMatcherId, CritMatcherIds, CritUpstreamNode, FixedRootMatcher, + RootMatcherMap, + crit_graph::{CritMgr, CritTarget, CritTargetOwner, WeakCritTargetOwner}, + crit_leaf::{CritLeafEvent, CritLeafMatcher}, + crit_matchers::critm_constant::CritMatchConstant, + }, + state::State, + utils::{copyhashmap::CopyHashMap, hash_map_ext::HashMapExt, queue::AsyncQueue}, + }, + std::rc::{Rc, Weak}, +}; + +bitflags! { + ClMatcherChange: u32; + CL_CHANGED_DESTROYED = 1 << 0, + CL_CHANGED_NEW = 1 << 1, +} + +type ClmFixedRootMatcher = FixedRootMatcher, T>; + +pub struct ClMatcherManager { + ids: Rc, + changes: AsyncQueue>, + leaf_events: Rc>>>, + constant: ClmFixedRootMatcher>>, + matchers: Rc, +} + +#[expect(dead_code)] +type ClmRootMatcherMap = RootMatcherMap, T>; + +#[derive(Default)] +pub struct RootMatchers {} + +pub async fn handle_cl_changes(state: Rc) { + let mgr = &state.cl_matcher_manager; + loop { + let tl = mgr.changes.pop().await; + mgr.update_matches(&tl); + } +} + +pub async fn handle_cl_leaf_events(state: Rc) { + let mgr = &state.cl_matcher_manager; + let debouncer = state.ring.debouncer(1000); + loop { + let event = mgr.leaf_events.pop().await; + event.run(); + debouncer.debounce().await; + } +} + +#[expect(dead_code)] +pub type ClmUpstreamNode = dyn CritUpstreamNode>; +pub type ClmLeafMatcher = CritLeafMatcher>; + +impl ClMatcherManager { + pub fn new(ids: &Rc) -> Self { + let matchers = Rc::new(RootMatchers::default()); + #[expect(unused_macros)] + macro_rules! bool { + ($name:ident) => {{ + static_map! { + v => CritRoot::new( + &matchers, + ids.next(), + CritRootFixed($name(v), PhantomData), + ) + } + }}; + } + Self { + constant: CritMatchConstant::create(&matchers, ids), + changes: Default::default(), + leaf_events: Default::default(), + ids: ids.clone(), + matchers, + } + } + + pub fn clear(&self) { + self.changes.clear(); + self.leaf_events.clear(); + } + + pub fn rematch_all(&self, state: &Rc) { + for client in state.clients.clients.borrow().values() { + client.data.property_changed(CL_CHANGED_NEW); + } + } + + pub fn changed(&self, client: &Rc) { + self.changes.push(client.clone()); + } + + fn update_matches(&self, data: &Rc) { + let mut changed = data.changed_properties.take(); + if changed.contains(CL_CHANGED_DESTROYED) { + for destroyed in data.destroyed.lock().drain_values() { + if let Some(destroyed) = destroyed.upgrade() { + destroyed.destroyed(data.id); + } + } + return; + } + #[expect(unused_macros)] + macro_rules! handlers { + ($name:ident) => { + self.matchers + .$name + .lock() + .values() + .filter_map(|m| m.upgrade()) + }; + } + #[expect(unused_macros)] + macro_rules! fixed { + ($name:ident) => { + self.$name[false].handle(data); + self.$name[true].handle(data); + }; + } + if changed.contains(CL_CHANGED_NEW) { + changed |= ClMatcherChange::all(); + #[expect(unused_macros)] + macro_rules! unconditional { + ($field:ident) => { + for m in handlers!($field) { + m.handle(data); + } + }; + } + self.constant[true].handle(data); + } + } +} + +impl CritTarget for Rc { + type Id = ClientId; + type Mgr = ClMatcherManager; + type RootMatchers = RootMatchers; + type LeafData = ClientId; + type Owner = Weak; + + fn owner(&self) -> Self::Owner { + Rc::downgrade(self) + } + + fn id(&self) -> Self::Id { + self.id + } + + fn destroyed(&self) -> &CopyHashMap>> { + &self.destroyed + } + + fn leaf_data(&self) -> Self::LeafData { + self.id + } +} + +impl CritTargetOwner for Rc { + type Target = Rc; + + fn data(&self) -> &Self::Target { + self + } +} + +impl WeakCritTargetOwner for Weak { + type Target = Rc; + type Owner = Rc; + + fn upgrade(&self) -> Option { + self.upgrade() + } +} + +impl CritMgr for ClMatcherManager { + type Target = Rc; + + fn id(&self) -> CritMatcherId { + self.ids.next() + } + + fn leaf_events(&self) -> &Rc>> { + &self.leaf_events + } + + fn match_constant(&self) -> &FixedRootMatcher> { + &self.constant + } + + fn roots(&self) -> &Rc<::RootMatchers> { + &self.matchers + } +} diff --git a/src/criteria/clm/clm_matchers.rs b/src/criteria/clm/clm_matchers.rs new file mode 100644 index 00000000..246a4f9c --- /dev/null +++ b/src/criteria/clm/clm_matchers.rs @@ -0,0 +1,19 @@ +#[expect(unused_macros)] +macro_rules! fixed_root_criterion { + ($ty:ty, $field:ident) => { + impl crate::criteria::crit_graph::CritFixedRootCriterionBase> + for $ty + { + fn constant(&self) -> bool { + self.0 + } + + fn not<'a>( + &self, + mgr: &'a crate::criteria::clm::ClMatcherManager, + ) -> &'a crate::criteria::FixedRootMatcher, Self> { + &mgr.$field + } + } + }; +} diff --git a/src/criteria/crit_graph/crit_root.rs b/src/criteria/crit_graph/crit_root.rs index 20828765..9992f40d 100644 --- a/src/criteria/crit_graph/crit_root.rs +++ b/src/criteria/crit_graph/crit_root.rs @@ -129,7 +129,6 @@ where slf } - #[expect(dead_code)] pub fn handle(&self, target: &Target) { let new = self.criterion.matches(target) ^ self.not; let node = match new { diff --git a/src/criteria/crit_leaf.rs b/src/criteria/crit_leaf.rs index 5bc3ddb4..68a4cd7a 100644 --- a/src/criteria/crit_leaf.rs +++ b/src/criteria/crit_leaf.rs @@ -104,7 +104,6 @@ impl CritLeafEvent where Target: CritTarget, { - #[expect(dead_code)] pub fn run(self) { let n = self.node; n.needs_event.set(true); diff --git a/src/criteria/crit_matchers/critm_constant.rs b/src/criteria/crit_matchers/critm_constant.rs index c96404bc..b45eea19 100644 --- a/src/criteria/crit_matchers/critm_constant.rs +++ b/src/criteria/crit_matchers/critm_constant.rs @@ -16,7 +16,6 @@ impl CritMatchConstant where Target: CritTarget, { - #[expect(dead_code)] pub fn create( roots: &Rc, ids: &CritMatcherIds, diff --git a/src/criteria/crit_per_target_data.rs b/src/criteria/crit_per_target_data.rs index ea9dd86f..506d8480 100644 --- a/src/criteria/crit_per_target_data.rs +++ b/src/criteria/crit_per_target_data.rs @@ -41,7 +41,6 @@ pub trait CritDestroyListener: 'static where Target: CritTarget, { - #[expect(dead_code)] fn destroyed(&self, target_id: Target::Id); } diff --git a/src/io_uring.rs b/src/io_uring.rs index d5e1a249..eef3933b 100644 --- a/src/io_uring.rs +++ b/src/io_uring.rs @@ -266,7 +266,6 @@ impl IoUring { self.ring.cancel_task(id); } - #[expect(dead_code)] pub fn debouncer(&self, max: u64) -> Debouncer { Debouncer { cur: Default::default(), diff --git a/src/io_uring/debounce.rs b/src/io_uring/debounce.rs index 6c68693f..f5b65f40 100644 --- a/src/io_uring/debounce.rs +++ b/src/io_uring/debounce.rs @@ -11,7 +11,6 @@ pub struct Debouncer { } impl Debouncer { - #[expect(dead_code)] pub async fn debounce(&self) { let iteration = self.ring.iteration.get(); if self.iteration.replace(iteration) != iteration { diff --git a/src/it/test_config.rs b/src/it/test_config.rs index 12c086d7..621073ca 100644 --- a/src/it/test_config.rs +++ b/src/it/test_config.rs @@ -124,6 +124,8 @@ unsafe extern "C" fn handle_msg(data: *const u8, msg: *const u8, size: usize) { ServerMessage::InterestReady { .. } => {} ServerMessage::Features { .. } => {} ServerMessage::SwitchEvent { .. } => {} + ServerMessage::ClientMatcherMatched { .. } => {} + ServerMessage::ClientMatcherUnmatched { .. } => {} } } diff --git a/src/macros.rs b/src/macros.rs index 65bd71c7..ad0d6121 100644 --- a/src/macros.rs +++ b/src/macros.rs @@ -475,6 +475,10 @@ macro_rules! bitflags { self.0 != 0 } + pub fn is_none(self) -> bool { + self.0 == 0 + } + pub fn all() -> Self { Self(0 $(| $val)*) } diff --git a/src/state.rs b/src/state.rs index 3c96c63b..2af9769a 100644 --- a/src/state.rs +++ b/src/state.rs @@ -15,6 +15,7 @@ use { compositor::LIBEI_SOCKET, config::ConfigProxy, cpu_worker::CpuWorker, + criteria::clm::ClMatcherManager, cursor::{Cursor, ServerCursors}, cursor_user::{CursorUserGroup, CursorUserGroupId, CursorUserGroupIds, CursorUserIds}, damage::DamageVisualizer, @@ -241,6 +242,7 @@ pub struct State { pub float_above_fullscreen: Cell, pub icons: Icons, pub show_pin_icon: Cell, + pub cl_matcher_manager: ClMatcherManager, } // impl Drop for State { @@ -949,6 +951,7 @@ impl State { self.slow_ei_clients.clear(); self.toplevels.clear(); self.workspace_managers.clear(); + self.cl_matcher_manager.clear(); } pub fn remove_toplevel_id(&self, id: ToplevelIdentifier) { diff --git a/toml-config/src/config.rs b/toml-config/src/config.rs index 7b4a91c5..d01cbc93 100644 --- a/toml-config/src/config.rs +++ b/toml-config/src/config.rs @@ -66,6 +66,7 @@ pub enum SimpleCommand { ToggleFloatAboveFullscreen, SetFloatPinned(bool), ToggleFloatPinned, + KillClient, } #[derive(Debug, Clone)] @@ -198,6 +199,34 @@ pub enum OutputMatch { }, } +#[derive(Default, Debug, Clone)] +pub struct GenericMatch { + pub name: Option, + pub not: Option>, + pub all: Option>, + pub any: Option>, + pub exactly: Option>, +} + +#[derive(Debug, Clone)] +pub struct MatchExactly { + pub num: usize, + pub list: Vec, +} + +#[derive(Debug, Clone)] +pub struct ClientRule { + pub name: Option, + pub match_: ClientMatch, + pub action: Option, + pub latch: Option, +} + +#[derive(Default, Debug, Clone)] +pub struct ClientMatch { + pub generic: GenericMatch, +} + #[derive(Debug, Clone)] pub enum DrmDeviceMatch { Any(Vec), @@ -395,6 +424,7 @@ pub struct Config { pub float: Option, pub named_actions: Vec, pub max_action_depth: u64, + pub client_rules: Vec, } #[derive(Debug, Error)] diff --git a/toml-config/src/config/parsers.rs b/toml-config/src/config/parsers.rs index 0112410b..ca1dc2e0 100644 --- a/toml-config/src/config/parsers.rs +++ b/toml-config/src/config/parsers.rs @@ -8,6 +8,8 @@ use { pub mod action; mod actions; +mod client_match; +mod client_rule; mod color; pub mod color_management; pub mod config; diff --git a/toml-config/src/config/parsers/action.rs b/toml-config/src/config/parsers/action.rs index 4457f42b..618c0cb1 100644 --- a/toml-config/src/config/parsers/action.rs +++ b/toml-config/src/config/parsers/action.rs @@ -132,6 +132,7 @@ impl ActionParser<'_> { "pin-float" => SetFloatPinned(true), "unpin-float" => SetFloatPinned(false), "toggle-float-pinned" => ToggleFloatPinned, + "kill-client" => KillClient, _ => { return Err( ActionParserError::UnknownSimpleAction(string.to_string()).spanned(span) diff --git a/toml-config/src/config/parsers/client_match.rs b/toml-config/src/config/parsers/client_match.rs new file mode 100644 index 00000000..b3a1ca54 --- /dev/null +++ b/toml-config/src/config/parsers/client_match.rs @@ -0,0 +1,104 @@ +use { + crate::{ + config::{ + ClientMatch, GenericMatch, MatchExactly, + context::Context, + extractor::{Extractor, ExtractorError, arr, n32, opt, str, val}, + parser::{DataType, ParseResult, Parser, UnexpectedDataType}, + }, + toml::{ + toml_span::{DespanExt, Span, Spanned}, + toml_value::Value, + }, + }, + indexmap::IndexMap, + thiserror::Error, +}; + +#[derive(Debug, Error)] +pub enum ClientMatchParserError { + #[error(transparent)] + Expected(#[from] UnexpectedDataType), + #[error(transparent)] + Extract(#[from] ExtractorError), +} + +pub struct ClientMatchParser<'a>(pub &'a Context<'a>); + +impl Parser for ClientMatchParser<'_> { + type Value = ClientMatch; + type Error = ClientMatchParserError; + const EXPECTED: &'static [DataType] = &[DataType::Table]; + + fn parse_table( + &mut self, + span: Span, + table: &IndexMap, Spanned>, + ) -> ParseResult { + let mut ext = Extractor::new(self.0, span, table); + let ((name, not_val, all_val, any_val, exactly_val),) = ext.extract((( + opt(str("name")), + opt(val("not")), + opt(arr("all")), + opt(arr("any")), + opt(val("exactly")), + ),))?; + let mut not = None; + if let Some(value) = not_val { + not = Some(Box::new(value.parse(&mut ClientMatchParser(self.0))?)); + } + macro_rules! list { + ($val:expr) => {{ + let mut list = None; + if let Some(value) = $val { + let mut res = vec![]; + for value in value.value { + res.push(value.parse(&mut ClientMatchParser(self.0))?); + } + list = Some(res); + } + list + }}; + } + let all = list!(all_val); + let any = list!(any_val); + let mut exactly = None; + if let Some(value) = exactly_val { + exactly = Some(value.parse(&mut ClientMatchExactlyParser(self.0))?); + } + Ok(ClientMatch { + generic: GenericMatch { + name: name.despan_into(), + not, + all, + any, + exactly, + }, + }) + } +} + +pub struct ClientMatchExactlyParser<'a>(pub &'a Context<'a>); + +impl Parser for ClientMatchExactlyParser<'_> { + type Value = MatchExactly; + type Error = ClientMatchParserError; + const EXPECTED: &'static [DataType] = &[DataType::Table]; + + fn parse_table( + &mut self, + span: Span, + table: &IndexMap, Spanned>, + ) -> ParseResult { + let mut ext = Extractor::new(self.0, span, table); + let (num, list_val) = ext.extract((n32("num"), arr("list")))?; + let mut list = vec![]; + for el in list_val.value { + list.push(el.parse(&mut ClientMatchParser(self.0))?); + } + Ok(MatchExactly { + num: num.value as _, + list, + }) + } +} diff --git a/toml-config/src/config/parsers/client_rule.rs b/toml-config/src/config/parsers/client_rule.rs new file mode 100644 index 00000000..50bf7f0d --- /dev/null +++ b/toml-config/src/config/parsers/client_rule.rs @@ -0,0 +1,104 @@ +use { + crate::{ + config::{ + ClientMatch, ClientRule, + context::Context, + extractor::{Extractor, ExtractorError, opt, str, val}, + parser::{DataType, ParseResult, Parser, UnexpectedDataType}, + parsers::{ + action::{ActionParser, ActionParserError}, + client_match::{ClientMatchParser, ClientMatchParserError}, + }, + spanned::SpannedErrorExt, + }, + toml::{ + toml_span::{DespanExt, Span, Spanned}, + toml_value::Value, + }, + }, + indexmap::IndexMap, + thiserror::Error, +}; + +#[derive(Debug, Error)] +pub enum ClientRuleParserError { + #[error(transparent)] + Expected(#[from] UnexpectedDataType), + #[error(transparent)] + Extract(#[from] ExtractorError), + #[error(transparent)] + Match(#[from] ClientMatchParserError), + #[error(transparent)] + Action(ActionParserError), + #[error(transparent)] + Latch(ActionParserError), +} + +pub struct ClientRuleParser<'a>(pub &'a Context<'a>); + +impl Parser for ClientRuleParser<'_> { + type Value = ClientRule; + type Error = ClientRuleParserError; + const EXPECTED: &'static [DataType] = &[DataType::Table]; + + fn parse_table( + &mut self, + span: Span, + table: &IndexMap, Spanned>, + ) -> ParseResult { + let mut ext = Extractor::new(self.0, span, table); + let (name, match_val, action_val, latch_val) = ext.extract(( + opt(str("name")), + opt(val("match")), + opt(val("action")), + opt(val("latch")), + ))?; + let mut action = None; + if let Some(value) = action_val { + action = Some( + value + .parse(&mut ActionParser(self.0)) + .map_spanned_err(ClientRuleParserError::Action)?, + ); + } + let mut latch = None; + if let Some(value) = latch_val { + latch = Some( + value + .parse(&mut ActionParser(self.0)) + .map_spanned_err(ClientRuleParserError::Latch)?, + ); + } + let match_ = match match_val { + None => ClientMatch::default(), + Some(m) => m.parse_map(&mut ClientMatchParser(self.0))?, + }; + Ok(ClientRule { + name: name.despan_into(), + match_, + action, + latch, + }) + } +} + +pub struct ClientRulesParser<'a>(pub &'a Context<'a>); + +impl Parser for ClientRulesParser<'_> { + type Value = Vec; + type Error = ClientRuleParserError; + const EXPECTED: &'static [DataType] = &[DataType::Array]; + + fn parse_array(&mut self, _span: Span, array: &[Spanned]) -> ParseResult { + let mut res = vec![]; + for el in array { + match el.parse(&mut ClientRuleParser(self.0)) { + Ok(o) => res.push(o), + Err(e) => { + log::warn!("Could not parse client rule: {}", self.0.error(e)); + } + } + } + Ok(res) + } +} diff --git a/toml-config/src/config/parsers/config.rs b/toml-config/src/config/parsers/config.rs index 98520a14..4baf4d36 100644 --- a/toml-config/src/config/parsers/config.rs +++ b/toml-config/src/config/parsers/config.rs @@ -8,6 +8,7 @@ use { parsers::{ action::ActionParser, actions::ActionsParser, + client_rule::ClientRulesParser, color_management::ColorManagementParser, connector::ConnectorsParser, drm_device::DrmDevicesParser, @@ -120,7 +121,7 @@ impl Parser for ConfigParser<'_> { ui_drag_val, xwayland_val, ), - (color_management_val, float_val, actions_val, max_action_depth_val), + (color_management_val, float_val, actions_val, max_action_depth_val, client_rules_val), ) = ext.extract(( ( opt(val("keymap")), @@ -163,6 +164,7 @@ impl Parser for ConfigParser<'_> { opt(val("float")), opt(val("actions")), recover(opt(int("max-action-depth"))), + opt(val("clients")), ), ))?; let mut keymap = None; @@ -419,6 +421,13 @@ impl Parser for ConfigParser<'_> { } max_action_depth = value.value as _; } + let mut client_rules = vec![]; + if let Some(value) = client_rules_val { + match value.parse(&mut ClientRulesParser(self.0)) { + Ok(v) => client_rules = v, + Err(e) => log::warn!("Could not parse the client rules: {}", self.0.error(e)), + } + } Ok(Config { keymap, repeat_rate, @@ -453,6 +462,7 @@ impl Parser for ConfigParser<'_> { float, named_actions, max_action_depth, + client_rules, }) } } diff --git a/toml-config/src/config/parsers/output_match.rs b/toml-config/src/config/parsers/output_match.rs index f771135a..4af292f2 100644 --- a/toml-config/src/config/parsers/output_match.rs +++ b/toml-config/src/config/parsers/output_match.rs @@ -28,7 +28,7 @@ pub struct OutputMatchParser<'a>(pub &'a Context<'a>); impl Parser for OutputMatchParser<'_> { type Value = OutputMatch; type Error = OutputMatchParserError; - const EXPECTED: &'static [DataType] = &[DataType::Table, DataType::Table]; + const EXPECTED: &'static [DataType] = &[DataType::Table, DataType::Array]; fn parse_array(&mut self, _span: Span, array: &[Spanned]) -> ParseResult { let mut res = vec![]; diff --git a/toml-config/src/lib.rs b/toml-config/src/lib.rs index f19cc546..cd3afb22 100644 --- a/toml-config/src/lib.rs +++ b/toml-config/src/lib.rs @@ -1,17 +1,27 @@ -#![allow(clippy::len_zero, clippy::single_char_pattern, clippy::collapsible_if)] +#![allow( + clippy::len_zero, + clippy::single_char_pattern, + clippy::collapsible_if, + clippy::collapsible_else_if +)] mod config; +mod rules; mod toml; use { - crate::config::{ - Action, Config, ConfigConnector, ConfigDrmDevice, ConfigKeymap, ConnectorMatch, - DrmDeviceMatch, Exec, Input, InputMatch, Output, OutputMatch, Shortcut, SimpleCommand, - Status, Theme, parse_config, + crate::{ + config::{ + Action, ClientRule, Config, ConfigConnector, ConfigDrmDevice, ConfigKeymap, + ConnectorMatch, DrmDeviceMatch, Exec, Input, InputMatch, Output, OutputMatch, Shortcut, + SimpleCommand, Status, Theme, parse_config, + }, + rules::{MatcherTemp, RuleMapper}, }, ahash::{AHashMap, AHashSet}, error_reporter::Report, jay_config::{ + client::Client, config, config_dir, exec::{Command, set_env, unset_env}, get_workspace, @@ -79,6 +89,16 @@ impl Action { } fn into_fn_impl(self, state: &Rc) -> B { + macro_rules! client_action { + ($name:ident, $opt:expr) => {{ + let state = state.clone(); + B::new(move || { + if let Some($name) = state.client.get() { + $opt + } + }) + }}; + } let s = state.persistent.seat; match self { Action::SimpleCommand { cmd } => match cmd { @@ -115,6 +135,7 @@ impl Action { SimpleCommand::ToggleFloatAboveFullscreen => B::new(toggle_float_above_fullscreen), SimpleCommand::SetFloatPinned(pinned) => B::new(move || s.set_float_pinned(pinned)), SimpleCommand::ToggleFloatPinned => B::new(move || s.toggle_float_pinned()), + SimpleCommand::KillClient => client_action!(c, c.kill()), }, Action::Multi { actions } => { let actions: Vec<_> = actions.into_iter().map(|a| a.into_fn(state)).collect(); @@ -666,6 +687,8 @@ struct State { action_depth_max: u64, action_depth: Cell, + + client: Cell>, } impl Drop for State { @@ -871,6 +894,16 @@ impl State { } } } + + fn with_client(&self, client: Client, check: bool, f: impl FnOnce()) { + let mut opt = Some(client); + if check && client.does_not_exist() { + opt = None; + } + self.client.set(opt); + f(); + self.client.set(None); + } } #[derive(Eq, PartialEq, Hash)] @@ -887,6 +920,8 @@ struct PersistentState { binds: RefCell>, #[expect(clippy::type_complexity)] actions: RefCell, Rc>>, + client_rules: Cell>>, + client_rule_mapper: RefCell>>, } fn load_config(initial_load: bool, persistent: &Rc) { @@ -967,7 +1002,11 @@ fn load_config(initial_load: bool, persistent: &Rc) { io_outputs: Default::default(), action_depth_max: config.max_action_depth, action_depth: Cell::new(0), + client: Default::default(), }); + let (client_rules, client_rule_mapper) = state.create_rules(&config.client_rules); + persistent.client_rules.set(client_rules); + *state.persistent.client_rule_mapper.borrow_mut() = Some(client_rule_mapper); state.set_status(&config.status); persistent.actions.borrow_mut().clear(); for a in config.named_actions { @@ -1190,10 +1229,15 @@ pub fn configure() { seat: default_seat(), binds: Default::default(), actions: Default::default(), + client_rules: Default::default(), + client_rule_mapper: Default::default(), }); { let p = persistent.clone(); - on_unload(move || p.actions.borrow_mut().clear()); + on_unload(move || { + p.actions.borrow_mut().clear(); + p.client_rule_mapper.borrow_mut().take(); + }); } load_config(true, &persistent); } diff --git a/toml-config/src/rules.rs b/toml-config/src/rules.rs new file mode 100644 index 00000000..87eff524 --- /dev/null +++ b/toml-config/src/rules.rs @@ -0,0 +1,258 @@ +use { + crate::{ + State, + config::{ClientMatch, ClientRule, GenericMatch}, + }, + ahash::{AHashMap, AHashSet}, + jay_config::client::{ClientCriterion, ClientMatcher}, + std::{mem::ManuallyDrop, rc::Rc}, +}; + +impl State { + pub fn create_rules(self: &Rc, rules: &[R]) -> (Vec>, RuleMapper) + where + R: Rule, + { + let mut names = AHashMap::new(); + for (idx, rule) in rules.iter().enumerate() { + if let Some(name) = rule.name() { + names.insert(name.to_string(), idx); + } + } + let mut mapper = RuleMapper { + state: self.clone(), + names, + pending: Default::default(), + mapped: Default::default(), + }; + let mut matchers = vec![]; + for idx in 0..rules.len() { + if let Some(matcher) = mapper.map_rule(rules, idx) { + matchers.push(MatcherTemp(matcher)); + } + } + (matchers, mapper) + } +} + +pub trait Rule: Sized + 'static { + type Match; + type Matcher: Copy + 'static; + type Criterion<'a>; + + const NAME_UPPER: &str; + const NAME_LOWER: &str; + + fn name(&self) -> Option<&str>; + fn match_(&self) -> &Self::Match; + fn generic(m: &Self::Match) -> &GenericMatch; + fn map_custom( + state: &Rc, + all: &mut Vec>, + match_: &Self::Match, + ) -> Option<()>; + fn create(c: Self::Criterion<'_>) -> Self::Matcher; + fn destroy(m: Self::Matcher); + fn bind(&self, state: &Rc, matcher: Self::Matcher); + + fn gen_matcher(m: Self::Matcher) -> Self::Criterion<'static>; + fn gen_not<'a, 'b: 'a>(m: &'a Self::Criterion<'b>) -> Self::Criterion<'a>; + fn gen_all<'a, 'b: 'a>(m: &'a [Self::Criterion<'b>]) -> Self::Criterion<'a>; + fn gen_any<'a, 'b: 'a>(m: &'a [Self::Criterion<'b>]) -> Self::Criterion<'a>; + fn gen_exactly<'a, 'b: 'a>(n: usize, m: &'a [Self::Criterion<'b>]) -> Self::Criterion<'a>; +} + +impl Rule for ClientRule { + type Match = ClientMatch; + type Matcher = ClientMatcher; + type Criterion<'a> = ClientCriterion<'a>; + + const NAME_UPPER: &str = "Client"; + const NAME_LOWER: &str = "client"; + + fn name(&self) -> Option<&str> { + self.name.as_deref() + } + + fn match_(&self) -> &Self::Match { + &self.match_ + } + + fn generic(m: &Self::Match) -> &GenericMatch { + &m.generic + } + + fn map_custom( + _state: &Rc, + _all: &mut Vec>, + _match_: &Self::Match, + ) -> Option<()> { + Some(()) + } + + fn create(c: Self::Criterion<'_>) -> Self::Matcher { + c.to_matcher() + } + + fn destroy(m: Self::Matcher) { + m.destroy(); + } + + fn bind(&self, state: &Rc, matcher: Self::Matcher) { + let state = state.clone(); + macro_rules! latch { + ($g:ident, $client:ident) => { + let g = $g.clone(); + let state = state.clone(); + $client.latch(move || { + state.with_client($client.client(), true, || g()); + }); + }; + } + if let Some(action) = &self.action { + let f = action.clone().into_fn(&state); + if let Some(action) = &self.latch { + let g = action.clone().into_rc_fn(&state); + let state = state.clone(); + matcher.bind(move |client| { + state.with_client(client.client(), false, &f); + latch!(g, client); + }); + } else { + matcher.bind(move |client| { + state.with_client(client.client(), false, &f); + }); + } + } else { + if let Some(action) = &self.latch { + let g = action.clone().into_rc_fn(&state); + matcher.bind(move |client| { + latch!(g, client); + }); + } + } + } + + fn gen_matcher(m: Self::Matcher) -> Self::Criterion<'static> { + ClientCriterion::Matcher(m) + } + + fn gen_not<'a, 'b: 'a>(m: &'a Self::Criterion<'b>) -> Self::Criterion<'a> { + ClientCriterion::Not(m) + } + + fn gen_all<'a, 'b: 'a>(m: &'a [Self::Criterion<'b>]) -> Self::Criterion<'a> { + ClientCriterion::All(m) + } + + fn gen_any<'a, 'b: 'a>(m: &'a [Self::Criterion<'b>]) -> Self::Criterion<'a> { + ClientCriterion::Any(m) + } + + fn gen_exactly<'a, 'b: 'a>(n: usize, m: &'a [Self::Criterion<'b>]) -> Self::Criterion<'a> { + ClientCriterion::Exactly(n, m) + } +} + +pub struct RuleMapper +where + R: Rule, +{ + state: Rc, + names: AHashMap, + pending: AHashSet, + mapped: AHashMap, +} + +pub struct MatcherTemp(R::Matcher) +where + R: Rule; + +impl Drop for MatcherTemp +where + R: Rule, +{ + fn drop(&mut self) { + R::destroy(self.0); + } +} + +impl RuleMapper +where + R: Rule, +{ + fn map_rule(&mut self, rules: &[R], idx: usize) -> Option { + if let Some(matcher) = self.mapped.get(&idx) { + return Some(*matcher); + } + if !self.pending.insert(idx) { + if let Some(name) = rules.get(idx).and_then(|r| r.name()) { + log::error!("{} rule `{name}` has a loop", R::NAME_UPPER); + } + return None; + } + let rule = &rules[idx]; + let matcher = self.map_match(rules, rule.match_())?; + self.mapped.insert(idx, matcher); + rule.bind(&self.state, matcher); + Some(matcher) + } + + fn map_temporary_match(&mut self, rules: &[R], matcher: &R::Match) -> Option> { + self.map_match(rules, matcher).map(MatcherTemp) + } + + fn map_match(&mut self, rules: &[R], matcher: &R::Match) -> Option { + let mut all = vec![]; + self.map_generic_match(rules, &mut all, R::generic(matcher))?; + R::map_custom(&self.state, &mut all, matcher)?; + if all.len() == 1 { + return Some(ManuallyDrop::new(all.pop().unwrap()).0); + } + let all: Vec<_> = all.iter().map(|m| R::gen_matcher(m.0)).collect(); + Some(R::create(R::gen_all(&all))) + } + + fn map_generic_match( + &mut self, + rules: &[R], + all: &mut Vec>, + matcher: &GenericMatch, + ) -> Option<()> { + let m = |c: R::Criterion<'_>| MatcherTemp(R::create(c)); + if let Some(name) = &matcher.name { + let Some(&idx) = self.names.get(&**name) else { + log::error!("There is no {} rule named `{name}`", R::NAME_LOWER); + return None; + }; + let matcher = self.map_rule(rules, idx)?; + all.push(m(R::gen_matcher(matcher))); + } + if let Some(not) = &matcher.not { + let matcher = self.map_temporary_match(rules, not)?; + all.push(m(R::gen_not(&R::gen_matcher(matcher.0)))); + } + if let Some(list) = &matcher.all { + for match_ in list { + all.push(self.map_temporary_match(rules, match_)?); + } + } + if let Some(list) = &matcher.any { + let mut any = vec![]; + for match_ in list { + any.push(self.map_temporary_match(rules, match_)?); + } + let any: Vec<_> = any.iter().map(|m| R::gen_matcher(m.0)).collect(); + all.push(m(R::gen_any(&any))); + } + if let Some(exactly) = &matcher.exactly { + let mut list = vec![]; + for match_ in &exactly.list { + list.push(self.map_temporary_match(rules, match_)?); + } + let list: Vec<_> = list.iter().map(|m| R::gen_matcher(m.0)).collect(); + all.push(m(R::gen_exactly(exactly.num, &list))) + } + Some(()) + } +} diff --git a/toml-spec/spec/spec.generated.json b/toml-spec/spec/spec.generated.json index 1f05aa76..eef59221 100644 --- a/toml-spec/spec/spec.generated.json +++ b/toml-spec/spec/spec.generated.json @@ -500,6 +500,86 @@ } ] }, + "ClientMatch": { + "description": "Criteria for matching clients.\n\nIf no fields are set, all clients are matched. If multiple fields are set, all fields\nmust match the client.\n", + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Matches if the client rule with this name matches.\n\n- Example:\n\n ```toml\n [[clients]]\n name = \"spotify\"\n match.sandbox-app-id = \"com.spotify.Client\"\n\n # Matches the same clients as the previous rule.\n [[clients]]\n match.name = \"spotify\"\n ```\n" + }, + "not": { + "description": "Matches if the contained criteria don't match.\n\n- Example:\n\n ```toml\n [[clients]]\n name = \"not-spotify\"\n match.not.sandbox-app-id = \"com.spotify.Client\"\n ```\n", + "$ref": "#/$defs/ClientMatch" + }, + "all": { + "type": "array", + "description": "Matches if all of the contained criteria match.\n\n- Example:\n\n ```toml\n [[clients]]\n match.all = [\n { sandbox-app-id = \"com.spotify.Client\" },\n { sandbox-engine = \"org.flatpak\" },\n ]\n ```\n", + "items": { + "description": "", + "$ref": "#/$defs/ClientMatch" + } + }, + "any": { + "type": "array", + "description": "Matches if any of the contained criteria match.\n\n- Example:\n\n ```toml\n [[clients]]\n match.any = [\n { sandbox-app-id = \"com.spotify.Client\" },\n { sandbox-app-id = \"com.valvesoftware.Steam\" },\n ]\n ```\n", + "items": { + "description": "", + "$ref": "#/$defs/ClientMatch" + } + }, + "exactly": { + "description": "Matches if a specific number of contained criteria match.\n\n- Example:\n\n ```toml\n # Matches any client that is either steam or sandboxed by flatpak but not both.\n [[clients]]\n match.exactly.num = 1\n match.exactly.list = [\n { sandbox-engine = \"org.flatpak\" },\n { sandbox-app-id = \"com.valvesoftware.Steam\" },\n ]\n ```\n", + "$ref": "#/$defs/ClientMatchExactly" + } + }, + "required": [] + }, + "ClientMatchExactly": { + "description": "Criterion for matching a specific number of client criteria.\n", + "type": "object", + "properties": { + "num": { + "type": "number", + "description": "The number of criteria that must match." + }, + "list": { + "type": "array", + "description": "The list of criteria.", + "items": { + "description": "", + "$ref": "#/$defs/ClientMatch" + } + } + }, + "required": [ + "num", + "list" + ] + }, + "ClientRule": { + "description": "A client rule.\n", + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of this rule.\n\nThis name can be referenced in other rules.\n\n- Example\n\n ```toml\n [[clients]]\n name = \"spotify\"\n match.sandbox-app-id = \"com.spotify.Client\"\n\n [[clients]]\n match.name = \"spotify\"\n action = \"kill-client\"\n ```\n" + }, + "match": { + "description": "The criteria that select the client that this rule applies to.", + "$ref": "#/$defs/ClientMatch" + }, + "action": { + "description": "An action to execute when a client matches the criteria.", + "$ref": "#/$defs/Action" + }, + "latch": { + "description": "An action to execute when a client no longer matches the criteria.", + "$ref": "#/$defs/Action" + } + }, + "required": [] + }, "Color": { "type": "string", "description": "A color.\n\nThe format should be one of the following:\n\n- `#rgb`\n- `#rrggbb`\n- `#rgba`\n- `#rrggbba`\n" @@ -714,6 +794,14 @@ "type": "integer", "description": "The maximum call depth of named actions. This setting prevents infinite recursion\nwhen using named actions. Setting this value to 0 or less disables named actions\ncompletely. The default is `16`.\n", "minimum": 0.0 + }, + "clients": { + "type": "array", + "description": "An array of client rules.\n\nThese rules can be used to give names to clients and to manipulate them.\n\n- Example:\n\n ```toml\n [[clients]]\n name = \"spotify\"\n match.sandbox-app-id = \"com.spotify.Client\"\n ```\n", + "items": { + "description": "", + "$ref": "#/$defs/ClientRule" + } } }, "required": [] @@ -1384,7 +1472,8 @@ "toggle-float-above-fullscreen", "pin-float", "unpin-float", - "toggle-float-pinned" + "toggle-float-pinned", + "kill-client" ] }, "Status": { diff --git a/toml-spec/spec/spec.generated.md b/toml-spec/spec/spec.generated.md index aeca6373..1c5b44a0 100644 --- a/toml-spec/spec/spec.generated.md +++ b/toml-spec/spec/spec.generated.md @@ -700,6 +700,171 @@ The string should have one of the following values: The brightness in cd/m^2. + +### `ClientMatch` + +Criteria for matching clients. + +If no fields are set, all clients are matched. If multiple fields are set, all fields +must match the client. + +Values of this type should be tables. + +The table has the following fields: + +- `name` (optional): + + Matches if the client rule with this name matches. + + - Example: + + ```toml + [[clients]] + name = "spotify" + match.sandbox-app-id = "com.spotify.Client" + + # Matches the same clients as the previous rule. + [[clients]] + match.name = "spotify" + ``` + + The value of this field should be a string. + +- `not` (optional): + + Matches if the contained criteria don't match. + + - Example: + + ```toml + [[clients]] + name = "not-spotify" + match.not.sandbox-app-id = "com.spotify.Client" + ``` + + The value of this field should be a [ClientMatch](#types-ClientMatch). + +- `all` (optional): + + Matches if all of the contained criteria match. + + - Example: + + ```toml + [[clients]] + match.all = [ + { sandbox-app-id = "com.spotify.Client" }, + { sandbox-engine = "org.flatpak" }, + ] + ``` + + The value of this field should be an array of [ClientMatchs](#types-ClientMatch). + +- `any` (optional): + + Matches if any of the contained criteria match. + + - Example: + + ```toml + [[clients]] + match.any = [ + { sandbox-app-id = "com.spotify.Client" }, + { sandbox-app-id = "com.valvesoftware.Steam" }, + ] + ``` + + The value of this field should be an array of [ClientMatchs](#types-ClientMatch). + +- `exactly` (optional): + + Matches if a specific number of contained criteria match. + + - Example: + + ```toml + # Matches any client that is either steam or sandboxed by flatpak but not both. + [[clients]] + match.exactly.num = 1 + match.exactly.list = [ + { sandbox-engine = "org.flatpak" }, + { sandbox-app-id = "com.valvesoftware.Steam" }, + ] + ``` + + The value of this field should be a [ClientMatchExactly](#types-ClientMatchExactly). + + + +### `ClientMatchExactly` + +Criterion for matching a specific number of client criteria. + +Values of this type should be tables. + +The table has the following fields: + +- `num` (required): + + The number of criteria that must match. + + The value of this field should be a number. + +- `list` (required): + + The list of criteria. + + The value of this field should be an array of [ClientMatchs](#types-ClientMatch). + + + +### `ClientRule` + +A client rule. + +Values of this type should be tables. + +The table has the following fields: + +- `name` (optional): + + The name of this rule. + + This name can be referenced in other rules. + + - Example + + ```toml + [[clients]] + name = "spotify" + match.sandbox-app-id = "com.spotify.Client" + + [[clients]] + match.name = "spotify" + action = "kill-client" + ``` + + The value of this field should be a string. + +- `match` (optional): + + The criteria that select the client that this rule applies to. + + The value of this field should be a [ClientMatch](#types-ClientMatch). + +- `action` (optional): + + An action to execute when a client matches the criteria. + + The value of this field should be a [Action](#types-Action). + +- `latch` (optional): + + An action to execute when a client no longer matches the criteria. + + The value of this field should be a [Action](#types-Action). + + ### `Color` @@ -1417,6 +1582,22 @@ The table has the following fields: The numbers should be greater than or equal to 0. +- `clients` (optional): + + An array of client rules. + + These rules can be used to give names to clients and to manipulate them. + + - Example: + + ```toml + [[clients]] + name = "spotify" + match.sandbox-app-id = "com.spotify.Client" + ``` + + The value of this field should be an array of [ClientRules](#types-ClientRule). + ### `Connector` @@ -3129,6 +3310,12 @@ The string should have one of the following values: Toggles whether the currently focused floating window is pinned. +- `kill-client`: + + Kills a client. + + This action has no effect outside of client rules. + diff --git a/toml-spec/spec/spec.yaml b/toml-spec/spec/spec.yaml index 50761da3..e44eac62 100644 --- a/toml-spec/spec/spec.yaml +++ b/toml-spec/spec/spec.yaml @@ -821,6 +821,11 @@ SimpleActionName: - value: toggle-float-pinned description: | Toggles whether the currently focused floating window is pinned. + - value: kill-client + description: | + Kills a client. + + This action has no effect outside of client rules. Color: @@ -2487,6 +2492,23 @@ Config: The maximum call depth of named actions. This setting prevents infinite recursion when using named actions. Setting this value to 0 or less disables named actions completely. The default is `16`. + clients: + kind: array + items: + ref: ClientRule + required: false + description: | + An array of client rules. + + These rules can be used to give names to clients and to manipulate them. + + - Example: + + ```toml + [[clients]] + name = "spotify" + match.sandbox-app-id = "com.spotify.Client" + ``` Idle: @@ -3016,3 +3038,149 @@ Float: The default is `false`. kind: boolean required: false + + +ClientRule: + kind: table + description: | + A client rule. + fields: + name: + kind: string + required: false + description: | + The name of this rule. + + This name can be referenced in other rules. + + - Example + + ```toml + [[clients]] + name = "spotify" + match.sandbox-app-id = "com.spotify.Client" + + [[clients]] + match.name = "spotify" + action = "kill-client" + ``` + match: + ref: ClientMatch + required: false + description: The criteria that select the client that this rule applies to. + action: + ref: Action + required: false + description: An action to execute when a client matches the criteria. + latch: + ref: Action + required: false + description: An action to execute when a client no longer matches the criteria. + + +ClientMatch: + kind: table + description: | + Criteria for matching clients. + + If no fields are set, all clients are matched. If multiple fields are set, all fields + must match the client. + fields: + name: + kind: string + required: false + description: | + Matches if the client rule with this name matches. + + - Example: + + ```toml + [[clients]] + name = "spotify" + match.sandbox-app-id = "com.spotify.Client" + + # Matches the same clients as the previous rule. + [[clients]] + match.name = "spotify" + ``` + not: + ref: ClientMatch + required: false + description: | + Matches if the contained criteria don't match. + + - Example: + + ```toml + [[clients]] + name = "not-spotify" + match.not.sandbox-app-id = "com.spotify.Client" + ``` + all: + kind: array + items: + ref: ClientMatch + required: false + description: | + Matches if all of the contained criteria match. + + - Example: + + ```toml + [[clients]] + match.all = [ + { sandbox-app-id = "com.spotify.Client" }, + { sandbox-engine = "org.flatpak" }, + ] + ``` + any: + kind: array + items: + ref: ClientMatch + required: false + description: | + Matches if any of the contained criteria match. + + - Example: + + ```toml + [[clients]] + match.any = [ + { sandbox-app-id = "com.spotify.Client" }, + { sandbox-app-id = "com.valvesoftware.Steam" }, + ] + ``` + exactly: + ref: ClientMatchExactly + required: false + description: | + Matches if a specific number of contained criteria match. + + - Example: + + ```toml + # Matches any client that is either steam or sandboxed by flatpak but not both. + [[clients]] + match.exactly.num = 1 + match.exactly.list = [ + { sandbox-engine = "org.flatpak" }, + { sandbox-app-id = "com.valvesoftware.Steam" }, + ] + ``` + + +ClientMatchExactly: + kind: table + description: | + Criterion for matching a specific number of client criteria. + fields: + num: + kind: number + required: true + description: The number of criteria that must match. + list: + kind: array + items: + ref: ClientMatch + required: true + description: The list of criteria.