diff --git a/jay-config/src/_private.rs b/jay-config/src/_private.rs index 6b75daa1..2aa0c2ac 100644 --- a/jay-config/src/_private.rs +++ b/jay-config/src/_private.rs @@ -4,7 +4,11 @@ mod logging; pub(crate) mod string_error; use { - crate::{client::ClientMatcher, video::Mode}, + crate::{ + client::ClientMatcher, + video::Mode, + window::{WindowMatcher, WindowType}, + }, bincode::Options, serde::{Deserialize, Serialize}, std::marker::PhantomData, @@ -95,3 +99,17 @@ pub enum ClientCriterionStringField { Comm, Exe, } + +#[derive(Serialize, Deserialize, Clone, Debug, Hash, Eq, PartialEq)] +pub enum WindowCriterionIpc { + Generic(GenericCriterionIpc), + String { + string: String, + field: WindowCriterionStringField, + regex: bool, + }, + Types(WindowType), +} + +#[derive(Serialize, Deserialize, Clone, Debug, Hash, Eq, PartialEq)] +pub enum WindowCriterionStringField {} diff --git a/jay-config/src/_private/client.rs b/jay-config/src/_private/client.rs index df02a491..a1a7bc4c 100644 --- a/jay-config/src/_private/client.rs +++ b/jay-config/src/_private/client.rs @@ -4,7 +4,7 @@ use { crate::{ _private::{ ClientCriterionIpc, ClientCriterionStringField, Config, ConfigEntry, ConfigEntryGen, - GenericCriterionIpc, PollableId, VERSION, WireMode, bincode_ops, + GenericCriterionIpc, PollableId, VERSION, WindowCriterionIpc, WireMode, bincode_ops, ipc::{ ClientMessage, InitMessage, Response, ServerFeature, ServerMessage, WorkspaceSource, }, @@ -31,7 +31,7 @@ use { Transform, VrrMode, connector_type::{CON_UNKNOWN, ConnectorType}, }, - window::{Window, WindowType}, + window::{MatchedWindow, Window, WindowCriterion, WindowMatcher, WindowType}, xwayland::XScalingMode, }, bincode::Options, @@ -114,6 +114,7 @@ pub(crate) struct ConfigClient { i3bar_separator: RefCell>>, pressed_keysym: Cell>, client_match_handlers: RefCell>, + window_match_handlers: RefCell>, feat_mod_mask: Cell, } @@ -123,6 +124,11 @@ struct ClientMatchHandler { latched: HashMap>, } +struct WindowMatchHandler { + cb: Callback, + latched: HashMap>, +} + struct Interest { result: Option>, waker: Option, @@ -253,6 +259,7 @@ pub unsafe extern "C" fn init( i3bar_separator: Default::default(), pressed_keysym: Cell::new(None), client_match_handlers: Default::default(), + window_match_handlers: Default::default(), feat_mod_mask: Cell::new(false), }); let init = unsafe { slice::from_raw_parts(init, size) }; @@ -1593,6 +1600,95 @@ impl ConfigClient { self.client_match_handlers.borrow_mut().remove(&matcher); } + pub fn create_window_matcher(&self, criterion: WindowCriterion) -> WindowMatcher { + self.create_window_matcher_(criterion, false).0 + } + + fn create_window_matcher_( + &self, + criterion: WindowCriterion, + child: bool, + ) -> (WindowMatcher, bool) { + #[expect(unused_macros)] + macro_rules! string { + ($t:expr, $field:ident, $regex:expr) => { + WindowCriterionIpc::String { + string: $t.to_string(), + field: WindowCriterionStringField::$field, + regex: $regex, + } + }; + } + let create_matcher = |criterion| { + let res = self.send_with_response(&ClientMessage::CreateWindowMatcher { + criterion: WindowCriterionIpc::Generic(criterion), + }); + get_response!(res, WindowMatcher(0), CreateWindowMatcher { matcher }); + matcher + }; + let destroy_matcher = |matcher| { + self.send(&ClientMessage::DestroyWindowMatcher { matcher }); + }; + let generic = |crit: GenericCriterion| { + self.create_generic_matcher( + crit, + child, + |c| self.create_window_matcher_(c, true), + create_matcher, + destroy_matcher, + ) + }; + let criterion = match criterion { + WindowCriterion::Matcher(m) => return generic(GenericCriterion::Matcher(m)), + WindowCriterion::Not(c) => return generic(GenericCriterion::Not(c)), + WindowCriterion::All(c) => return generic(GenericCriterion::All(c)), + WindowCriterion::Any(c) => return generic(GenericCriterion::Any(c)), + WindowCriterion::Exactly(n, c) => return generic(GenericCriterion::Exactly(n, c)), + WindowCriterion::Types(t) => WindowCriterionIpc::Types(t), + }; + let res = self.send_with_response(&ClientMessage::CreateWindowMatcher { criterion }); + get_response!( + res, + (WindowMatcher(0), false), + CreateWindowMatcher { matcher } + ); + (matcher, true) + } + + pub fn set_window_matcher_handler( + &self, + matcher: WindowMatcher, + cb: impl FnMut(MatchedWindow) + 'static, + ) { + let cb = Rc::new(RefCell::new(cb)); + let handlers = &mut *self.window_match_handlers.borrow_mut(); + let handler = handlers.entry(matcher).or_insert_with(|| { + self.send(&ClientMessage::EnableWindowMatcherEvents { matcher }); + WindowMatchHandler { + cb: cb.clone(), + latched: Default::default(), + } + }); + handler.cb = cb.clone(); + } + + pub fn set_window_matcher_latch_handler( + &self, + matcher: WindowMatcher, + window: Window, + cb: impl FnOnce() + 'static, + ) { + let handlers = &mut *self.window_match_handlers.borrow_mut(); + if let Some(handler) = handlers.get_mut(&matcher) { + handler.latched.insert(window, Box::new(cb)); + } + } + + pub fn destroy_window_matcher(&self, matcher: WindowMatcher) { + self.send(&ClientMessage::DestroyWindowMatcher { matcher }); + self.window_match_handlers.borrow_mut().remove(&matcher); + } + fn handle_msg(&self, msg: &[u8]) { self.handle_msg2(msg); self.dispatch_futures(); @@ -1879,6 +1975,30 @@ impl ConfigClient { }; cb(); } + ServerMessage::WindowMatcherMatched { matcher, window } => { + let cb = { + let handlers = self.window_match_handlers.borrow(); + let Some(handler) = handlers.get(&matcher) else { + return; + }; + handler.cb.clone() + }; + let matched = MatchedWindow { matcher, window }; + cb.borrow_mut()(matched); + } + ServerMessage::WindowMatcherUnmatched { matcher, window } => { + let cb = { + let mut handlers = self.window_match_handlers.borrow_mut(); + let Some(handler) = handlers.get_mut(&matcher) else { + return; + }; + let Some(cb) = handler.latched.remove(&window) else { + return; + }; + cb + }; + cb(); + } } } diff --git a/jay-config/src/_private/ipc.rs b/jay-config/src/_private/ipc.rs index 3f9525ac..384c13ae 100644 --- a/jay-config/src/_private/ipc.rs +++ b/jay-config/src/_private/ipc.rs @@ -1,6 +1,6 @@ use { crate::{ - _private::{ClientCriterionIpc, PollableId, WireMode}, + _private::{ClientCriterionIpc, PollableId, WindowCriterionIpc, WireMode}, Axis, Direction, PciId, Workspace, client::{Client, ClientMatcher}, input::{ @@ -15,7 +15,7 @@ use { ColorSpace, Connector, DrmDevice, Format, GfxApi, TearingMode, TransferFunction, Transform, VrrMode, connector_type::ConnectorType, }, - window::{Window, WindowType}, + window::{Window, WindowMatcher, WindowType}, xwayland::XScalingMode, }, serde::{Deserialize, Serialize}, @@ -102,6 +102,14 @@ pub enum ServerMessage { matcher: ClientMatcher, client: Client, }, + WindowMatcherMatched { + matcher: WindowMatcher, + window: Window, + }, + WindowMatcherUnmatched { + matcher: WindowMatcher, + window: Window, + }, } #[derive(Serialize, Deserialize, Debug)] @@ -681,6 +689,15 @@ pub enum ClientMessage<'a> { EnableClientMatcherEvents { matcher: ClientMatcher, }, + CreateWindowMatcher { + criterion: WindowCriterionIpc, + }, + DestroyWindowMatcher { + matcher: WindowMatcher, + }, + EnableWindowMatcherEvents { + matcher: WindowMatcher, + }, } #[derive(Serialize, Deserialize, Debug)] @@ -904,6 +921,9 @@ pub enum Response { CreateClientMatcher { matcher: ClientMatcher, }, + CreateWindowMatcher { + matcher: WindowMatcher, + }, } #[derive(Serialize, Deserialize, Debug)] diff --git a/jay-config/src/window.rs b/jay-config/src/window.rs index ebf35122..3205e36c 100644 --- a/jay-config/src/window.rs +++ b/jay-config/src/window.rs @@ -3,6 +3,7 @@ use { crate::{Axis, Direction, Workspace, client::Client}, serde::{Deserialize, Serialize}, + std::ops::Deref, }; /// A toplevel window. @@ -202,3 +203,87 @@ impl Window { self.set_float_pinned(!self.float_pinned()); } } + +/// A window matcher. +#[derive(Serialize, Deserialize, Copy, Clone, Debug, Hash, Eq, PartialEq)] +pub struct WindowMatcher(pub u64); + +/// A matched window. +#[derive(Serialize, Deserialize, Copy, Clone, Debug, Hash, Eq, PartialEq)] +pub struct MatchedWindow { + pub(crate) matcher: WindowMatcher, + pub(crate) window: Window, +} + +/// A criterion for matching a window. +#[derive(Copy, Clone, Debug, Hash, Eq, PartialEq)] +#[non_exhaustive] +pub enum WindowCriterion<'a> { + /// Matches if the contained matcher matches. + Matcher(WindowMatcher), + /// Matches if the contained criterion does not match. + Not(&'a WindowCriterion<'a>), + /// Matches if the window has one of the types. + Types(WindowType), + /// Matches if all of the contained criteria match. + All(&'a [WindowCriterion<'a>]), + /// Matches if any of the contained criteria match. + Any(&'a [WindowCriterion<'a>]), + /// Matches if an exact number of the contained criteria match. + Exactly(usize, &'a [WindowCriterion<'a>]), +} + +impl WindowCriterion<'_> { + /// Converts the criterion to a matcher. + pub fn to_matcher(self) -> WindowMatcher { + get!(WindowMatcher(0)).create_window_matcher(self) + } + + /// Binds a function to execute when the criterion matches a window. + /// + /// This leaks the matcher. + pub fn bind(self, cb: F) { + self.to_matcher().bind(cb); + } +} + +impl WindowMatcher { + /// Destroys the matcher. + /// + /// Any bound callback will no longer be executed. + pub fn destroy(self) { + get!().destroy_window_matcher(self); + } + + /// Sets a function to execute when the criterion matches a window. + /// + /// Replaces any already bound callback. + pub fn bind(self, cb: F) { + get!().set_window_matcher_handler(self, cb); + } +} + +impl MatchedWindow { + /// Returns the window that matched. + pub fn window(self) -> Window { + self.window + } + + /// Returns the matcher. + pub fn matcher(self) -> WindowMatcher { + self.matcher + } + + /// Latches a function to be executed when the window no longer matches the criteria. + pub fn latch(self, cb: F) { + get!().set_window_matcher_latch_handler(self.matcher, self.window, cb); + } +} + +impl Deref for MatchedWindow { + type Target = Window; + + fn deref(&self) -> &Self::Target { + &self.window + } +} diff --git a/src/compositor.rs b/src/compositor.rs index b4854958..1a2627d1 100644 --- a/src/compositor.rs +++ b/src/compositor.rs @@ -18,6 +18,7 @@ use { criteria::{ CritMatcherIds, clm::{ClMatcherManager, handle_cl_changes, handle_cl_leaf_events}, + tlm::{TlMatcherManager, handle_tl_changes, handle_tl_leaf_events}, }, damage::{DamageVisualizer, visualize_damage}, dbus::Dbus, @@ -299,6 +300,7 @@ fn start_compositor2( icons: Default::default(), show_pin_icon: Cell::new(false), cl_matcher_manager: ClMatcherManager::new(&crit_ids), + tl_matcher_manager: TlMatcherManager::new(&crit_ids), }); state.tracker.register(ClientId::from_raw(0)); create_dummy_output(&state); @@ -476,6 +478,11 @@ fn start_global_event_handlers( "cl matcher leaf events", handle_cl_leaf_events(state.clone()), ), + eng.spawn("tl matcher manager", handle_tl_changes(state.clone())), + eng.spawn( + "tl matcher leaf events", + handle_tl_leaf_events(state.clone()), + ), ] } diff --git a/src/config.rs b/src/config.rs index ba31e844..841e3fad 100644 --- a/src/config.rs +++ b/src/config.rs @@ -22,6 +22,7 @@ use { input::{InputDevice, Seat, SwitchEvent}, keyboard::{mods::Modifiers, syms::KeySym}, video::{Connector, DrmDevice}, + window, }, libloading::Library, std::{cell::Cell, io, mem, ptr, rc::Rc}, @@ -218,6 +219,11 @@ impl ConfigProxy { client_matchers: Default::default(), client_matcher_cache: Default::default(), client_matcher_leafs: Default::default(), + window_matcher_ids: NumCell::new(1), + window_matchers: Default::default(), + window_matcher_cache: Default::default(), + window_matcher_leafs: Default::default(), + window_matcher_std_kinds: state.tl_matcher_manager.kind(window::CLIENT_WINDOW), }); let init_msg = bincode_ops() .serialize(&InitMessage::V1(V1InitMessage {})) diff --git a/src/config/handler.rs b/src/config/handler.rs index 6758eb01..b2adf0e9 100644 --- a/src/config/handler.rs +++ b/src/config/handler.rs @@ -10,7 +10,9 @@ use { compositor::MAX_EXTENTS, config::ConfigProxy, criteria::{ - CritLiteralOrRegex, CritMgrExt, CritTarget, CritUpstreamNode, clm::ClmLeafMatcher, + CritLiteralOrRegex, CritMgrExt, CritTarget, CritUpstreamNode, + clm::ClmLeafMatcher, + tlm::{TlmLeafMatcher, TlmUpstreamNode}, }, format::config_formats, ifs::wl_seat::{SeatId, WlSeatGlobal}, @@ -22,9 +24,9 @@ use { theme::{Color, ThemeSized}, tree::{ ContainerNode, ContainerSplit, FloatNode, Node, NodeVisitorBase, OutputNode, - TearingMode, ToplevelNode, VrrMode, WorkspaceNode, WsMoveConfig, move_ws_to_output, - toplevel_create_split, toplevel_parent_container, toplevel_set_floating, - toplevel_set_workspace, + TearingMode, ToplevelData, ToplevelNode, VrrMode, WorkspaceNode, WsMoveConfig, + move_ws_to_output, toplevel_create_split, toplevel_parent_container, + toplevel_set_floating, toplevel_set_workspace, }, utils::{ asyncevent::AsyncEvent, @@ -42,7 +44,7 @@ use { jay_config::{ _private::{ ClientCriterionIpc, ClientCriterionStringField, GenericCriterionIpc, PollableId, - WireMode, bincode_ops, + WindowCriterionIpc, WireMode, bincode_ops, ipc::{ClientMessage, Response, ServerMessage, WorkspaceSource}, }, Axis, Direction, Workspace, @@ -64,7 +66,7 @@ use { TearingMode as ConfigTearingMode, TransferFunction as ConfigTransferFunction, Transform, VrrMode as ConfigVrrMode, }, - window::Window, + window::{Window, WindowMatcher}, xwayland::XScalingMode, }, libloading::Library, @@ -115,6 +117,13 @@ pub(super) struct ConfigProxyHandler { CopyHashMap>>>, pub client_matcher_cache: CriterionCache>, pub client_matcher_leafs: CopyHashMap>, + + pub window_matcher_ids: NumCell, + pub window_matchers: + CopyHashMap>>, + pub window_matcher_cache: CriterionCache, + pub window_matcher_leafs: CopyHashMap>, + pub window_matcher_std_kinds: Rc, } pub struct Pollable { @@ -159,7 +168,6 @@ 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)) } @@ -177,6 +185,9 @@ impl ConfigProxyHandler { self.client_matcher_leafs.clear(); self.client_matchers.clear(); + self.window_matcher_leafs.clear(); + self.window_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))); @@ -1933,6 +1944,98 @@ impl ConfigProxyHandler { Ok(()) } + fn get_window_matcher( + &self, + matcher: WindowMatcher, + ) -> Result>, CphError> { + self.window_matchers + .get(&matcher) + .ok_or(CphError::WindowMatcherDoesNotExist(matcher)) + } + + fn handle_create_window_matcher( + &self, + mut criterion: WindowCriterionIpc, + ) -> Result<(), CphError> { + if let WindowCriterionIpc::Generic(generic) = &mut criterion { + self.sort_generic_matcher(generic, |m| m.0); + } + let id = WindowMatcher(self.window_matcher_ids.fetch_add(1)); + let cache = &self.window_matcher_cache; + if let Some(matcher) = cache.get(&criterion) { + if let Some(matcher) = matcher.upgrade() { + self.window_matchers.set(id, matcher); + self.respond(Response::CreateWindowMatcher { matcher: id }); + return Ok(()); + } + } + let mgr = &self.state.tl_matcher_manager; + let mut upstream = vec![]; + let matcher = match &criterion { + WindowCriterionIpc::Generic(m) => { + self.create_generic_matcher(mgr, m, &mut upstream, |m| self.get_window_matcher(*m))? + } + WindowCriterionIpc::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 {} + } + WindowCriterionIpc::Types(t) => mgr.kind(*t), + }; + let cached = Rc::new(CachedCriterion { + crit: criterion.clone(), + cache: cache.clone(), + upstream, + node: matcher.clone(), + }); + cache.set(criterion, Rc::downgrade(&cached)); + self.window_matchers.set(id, cached); + self.respond(Response::CreateWindowMatcher { matcher: id }); + Ok(()) + } + + fn handle_destroy_window_matcher(&self, matcher: WindowMatcher) { + self.window_matchers.remove(&matcher); + self.window_matcher_leafs.remove(&matcher); + } + + fn handle_enable_window_matcher_events( + self: &Rc, + matcher: WindowMatcher, + ) -> Result<(), CphError> { + if self.window_matcher_leafs.contains(&matcher) { + return Ok(()); + } + let upstream = self.get_window_matcher(matcher)?; + let mut node = upstream.node.clone(); + if !upstream.any(&|crit| matches!(crit, WindowCriterionIpc::Types(_))) { + let list = [self.window_matcher_std_kinds.clone(), node]; + node = self.state.tl_matcher_manager.list(&list, true); + } + let slf = self.clone(); + let leaf = self.state.tl_matcher_manager.leaf(&node, move |tl| { + let window = slf.tl_id_to_window(tl); + slf.send(&ServerMessage::WindowMatcherMatched { matcher, window }); + let slf = slf.clone(); + Box::new(move || { + slf.send(&ServerMessage::WindowMatcherUnmatched { matcher, window }); + }) + }); + self.window_matcher_leafs.set(matcher, leaf); + self.state.tl_matcher_manager.rematch_all(&self.state); + Ok(()) + } + fn spaces_change(&self) { struct V; impl NodeVisitorBase for V { @@ -2729,6 +2832,15 @@ impl ConfigProxyHandler { ClientMessage::EnableClientMatcherEvents { matcher } => self .handle_enable_client_matcher_events(matcher) .wrn("enable_window_matcher_events")?, + ClientMessage::CreateWindowMatcher { criterion } => self + .handle_create_window_matcher(criterion) + .wrn("create_window_matcher")?, + ClientMessage::DestroyWindowMatcher { matcher } => { + self.handle_destroy_window_matcher(matcher) + } + ClientMessage::EnableWindowMatcherEvents { matcher } => self + .handle_enable_window_matcher_events(matcher) + .wrn("enable_window_matcher_events")?, } Ok(()) } @@ -2814,6 +2926,8 @@ enum CphError { ClientMatcherDoesNotExist(ClientMatcher), #[error("Could not parse regex")] InvalidRegex(#[source] regex::Error), + #[error("Window matcher {0:?} does not exist")] + WindowMatcherDoesNotExist(WindowMatcher), } trait WithRequestName { diff --git a/src/criteria.rs b/src/criteria.rs index b55827ae..cf8125f2 100644 --- a/src/criteria.rs +++ b/src/criteria.rs @@ -3,6 +3,7 @@ mod crit_graph; pub mod crit_leaf; mod crit_matchers; mod crit_per_target_data; +pub mod tlm; use { crate::{ diff --git a/src/criteria/crit_graph/crit_root.rs b/src/criteria/crit_graph/crit_root.rs index 9992f40d..c8ca8abd 100644 --- a/src/criteria/crit_graph/crit_root.rs +++ b/src/criteria/crit_graph/crit_root.rs @@ -141,7 +141,6 @@ where self.downstream.update_matched(target, node, new, !new); } - #[expect(dead_code)] pub fn has_downstream(&self) -> bool { self.downstream.has_downstream() } diff --git a/src/criteria/tlm.rs b/src/criteria/tlm.rs new file mode 100644 index 00000000..376af00f --- /dev/null +++ b/src/criteria/tlm.rs @@ -0,0 +1,271 @@ +pub mod tlm_matchers; + +use { + crate::{ + criteria::{ + CritDestroyListener, CritMatcherId, CritMatcherIds, CritMgrExt, CritUpstreamNode, + FixedRootMatcher, RootMatcherMap, + crit_graph::{CritMgr, CritTarget, CritTargetOwner, WeakCritTargetOwner}, + crit_leaf::{CritLeafEvent, CritLeafMatcher}, + crit_matchers::critm_constant::CritMatchConstant, + tlm::tlm_matchers::tlmm_kind::TlmMatchKind, + }, + state::State, + tree::{NodeId, ToplevelData, ToplevelNode}, + utils::{ + copyhashmap::CopyHashMap, hash_map_ext::HashMapExt, queue::AsyncQueue, + toplevel_identifier::ToplevelIdentifier, + }, + }, + jay_config::window::WindowType, + std::rc::{Rc, Weak}, +}; + +bitflags! { + TlMatcherChange: u32; + TL_CHANGED_DESTROYED = 1 << 0, + TL_CHANGED_NEW = 1 << 1, +} + +type TlmFixedRootMatcher = FixedRootMatcher; + +pub struct TlMatcherManager { + ids: Rc, + changes: AsyncQueue>, + leaf_events: Rc>>, + constant: TlmFixedRootMatcher>, + matchers: Rc, +} + +type TlmRootMatcherMap = RootMatcherMap; + +#[derive(Default)] +pub struct RootMatchers { + kinds: TlmRootMatcherMap, +} + +pub async fn handle_tl_changes(state: Rc) { + let mgr = &state.tl_matcher_manager; + loop { + let tl = mgr.changes.pop().await; + mgr.update_matches(tl); + } +} + +pub async fn handle_tl_leaf_events(state: Rc) { + let mgr = &state.tl_matcher_manager; + let debouncer = state.ring.debouncer(1000); + loop { + let event = mgr.leaf_events.pop().await; + event.run(); + debouncer.debounce().await; + } +} + +pub type TlmUpstreamNode = dyn CritUpstreamNode; +pub type TlmLeafMatcher = CritLeafMatcher; + +impl TlMatcherManager { + pub fn new(ids: &Rc) -> Self { + let matchers = Rc::new(RootMatchers::default()); + 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 tl in state.toplevels.lock().values() { + if let Some(tl) = tl.upgrade() { + tl.tl_data().property_changed(TL_CHANGED_NEW); + } + } + } + + pub fn has_no_interest(&self, data: &ToplevelData, change: TlMatcherChange) -> bool { + !self.has_interest(data, change) + } + + pub fn has_interest(&self, data: &ToplevelData, mut change: TlMatcherChange) -> bool { + if change.contains(TL_CHANGED_DESTROYED) && data.destroyed.is_not_empty() { + return true; + } + #[expect(unused_macros)] + macro_rules! fixed { + ($name:ident) => { + if self.$name[false].has_downstream() || self.$name[true].has_downstream() { + return true; + } + }; + } + if change.contains(TL_CHANGED_NEW) { + macro_rules! unconditional { + ($field:ident) => { + if self.matchers.$field.is_not_empty() { + return true; + } + }; + } + unconditional!(kinds); + if self.constant[true].has_downstream() { + return true; + } + change |= TlMatcherChange::all(); + } + #[expect(unused_macros)] + macro_rules! conditional { + ($change:expr, $field:ident) => { + if change.contains($change) && self.matchers.$field.is_not_empty() { + return true; + } + }; + } + #[expect(unused_macros)] + macro_rules! fixed_conditional { + ($change:expr, $field:ident) => { + if change.contains($change) { + fixed!($field); + } + }; + } + false + } + + pub fn changed(&self, node: Rc) { + self.changes.push(node); + } + + fn update_matches(&self, node: Rc) { + let data = node.tl_data(); + let mut changed = data.changed_properties.replace(TlMatcherChange::none()); + if changed.contains(TL_CHANGED_DESTROYED) { + for destroyed in data.destroyed.lock().drain_values() { + if let Some(destroyed) = destroyed.upgrade() { + destroyed.destroyed(data.node_id); + } + } + } + if data.parent.is_none() { + return; + } + 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(TL_CHANGED_NEW) { + changed |= TlMatcherChange::all(); + macro_rules! unconditional { + ($field:ident) => { + for m in handlers!($field) { + m.handle(data); + } + }; + } + unconditional!(kinds); + self.constant[true].handle(data); + } + #[expect(unused_macros)] + macro_rules! conditional { + ($change:expr, $field:ident) => { + if changed.contains($change) { + for m in handlers!($field) { + m.handle(data); + } + } + }; + } + #[expect(unused_macros)] + macro_rules! fixed_conditional { + ($change:expr, $field:ident) => { + if changed.contains($change) { + fixed!($field); + } + }; + } + } + + pub fn kind(&self, kind: WindowType) -> Rc { + self.root(TlmMatchKind::new(kind)) + } +} + +impl CritTarget for ToplevelData { + type Id = NodeId; + type Mgr = TlMatcherManager; + type RootMatchers = RootMatchers; + type LeafData = ToplevelIdentifier; + type Owner = Weak; + + fn owner(&self) -> Self::Owner { + self.slf.clone() + } + + fn id(&self) -> Self::Id { + self.node_id + } + + fn destroyed(&self) -> &CopyHashMap>> { + &self.destroyed + } + + fn leaf_data(&self) -> Self::LeafData { + self.identifier.get() + } +} + +impl CritTargetOwner for Rc { + type Target = ToplevelData; + + fn data(&self) -> &Self::Target { + self.tl_data() + } +} + +impl WeakCritTargetOwner for Weak { + type Target = ToplevelData; + type Owner = Rc; + + fn upgrade(&self) -> Option { + self.upgrade() + } +} + +impl CritMgr for TlMatcherManager { + type Target = ToplevelData; + + 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/tlm/tlm_matchers.rs b/src/criteria/tlm/tlm_matchers.rs new file mode 100644 index 00000000..93871e79 --- /dev/null +++ b/src/criteria/tlm/tlm_matchers.rs @@ -0,0 +1,21 @@ +#[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::tlm::TlMatcherManager, + ) -> &'a crate::criteria::FixedRootMatcher { + &mgr.$field + } + } + }; +} + +pub mod tlmm_kind; diff --git a/src/criteria/tlm/tlm_matchers/tlmm_kind.rs b/src/criteria/tlm/tlm_matchers/tlmm_kind.rs new file mode 100644 index 00000000..8c332877 --- /dev/null +++ b/src/criteria/tlm/tlm_matchers/tlmm_kind.rs @@ -0,0 +1,31 @@ +use { + crate::{ + criteria::{ + crit_graph::CritRootCriterion, + tlm::{RootMatchers, TlmRootMatcherMap}, + }, + tree::ToplevelData, + utils::bitflags::BitflagsExt, + }, + jay_config::window::WindowType, +}; + +pub struct TlmMatchKind { + kind: WindowType, +} + +impl TlmMatchKind { + pub fn new(kind: WindowType) -> TlmMatchKind { + Self { kind } + } +} + +impl CritRootCriterion for TlmMatchKind { + fn matches(&self, data: &ToplevelData) -> bool { + self.kind.0.contains(data.kind.to_window_type().0) + } + + fn nodes(roots: &RootMatchers) -> Option<&TlmRootMatcherMap> { + Some(&roots.kinds) + } +} diff --git a/src/it/test_config.rs b/src/it/test_config.rs index 621073ca..e942cead 100644 --- a/src/it/test_config.rs +++ b/src/it/test_config.rs @@ -126,6 +126,8 @@ unsafe extern "C" fn handle_msg(data: *const u8, msg: *const u8, size: usize) { ServerMessage::SwitchEvent { .. } => {} ServerMessage::ClientMatcherMatched { .. } => {} ServerMessage::ClientMatcherUnmatched { .. } => {} + ServerMessage::WindowMatcherMatched { .. } => {} + ServerMessage::WindowMatcherUnmatched { .. } => {} } } diff --git a/src/state.rs b/src/state.rs index 2af9769a..b0dd776f 100644 --- a/src/state.rs +++ b/src/state.rs @@ -15,7 +15,7 @@ use { compositor::LIBEI_SOCKET, config::ConfigProxy, cpu_worker::CpuWorker, - criteria::clm::ClMatcherManager, + criteria::{clm::ClMatcherManager, tlm::TlMatcherManager}, cursor::{Cursor, ServerCursors}, cursor_user::{CursorUserGroup, CursorUserGroupId, CursorUserGroupIds, CursorUserIds}, damage::DamageVisualizer, @@ -243,6 +243,7 @@ pub struct State { pub icons: Icons, pub show_pin_icon: Cell, pub cl_matcher_manager: ClMatcherManager, + pub tl_matcher_manager: TlMatcherManager, } // impl Drop for State { @@ -952,6 +953,7 @@ impl State { self.toplevels.clear(); self.workspace_managers.clear(); self.cl_matcher_manager.clear(); + self.tl_matcher_manager.clear(); } pub fn remove_toplevel_id(&self, id: ToplevelIdentifier) { diff --git a/src/tree/toplevel.rs b/src/tree/toplevel.rs index dc8c1857..316a3aba 100644 --- a/src/tree/toplevel.rs +++ b/src/tree/toplevel.rs @@ -1,6 +1,10 @@ use { crate::{ client::{Client, ClientId}, + criteria::{ + CritDestroyListener, CritMatcherId, + tlm::{TL_CHANGED_DESTROYED, TL_CHANGED_NEW, TlMatcherChange}, + }, ifs::{ ext_foreign_toplevel_handle_v1::ExtForeignToplevelHandleV1, ext_foreign_toplevel_list_v1::ExtForeignToplevelListV1, @@ -92,7 +96,10 @@ impl ToplevelNode for T { fn tl_set_parent(&self, parent: Rc) { let data = self.tl_data(); - data.parent.set(Some(parent.clone())); + let parent_was_none = data.parent.set(Some(parent.clone())).is_none(); + if parent_was_none { + data.property_changed(TL_CHANGED_NEW); + } data.is_floating.set(parent.node_is_float()); self.tl_set_workspace(&parent.cnode_workspace()); } @@ -275,7 +282,6 @@ impl ToplevelType { } pub struct ToplevelData { - #[expect(dead_code)] pub node_id: NodeId, pub kind: ToplevelType, pub self_active: Cell, @@ -307,6 +313,8 @@ pub struct ToplevelData { pub ext_copy_sessions: CopyHashMap<(ClientId, ExtImageCopyCaptureSessionV1Id), Rc>, pub slf: Weak, + pub destroyed: CopyHashMap>>, + pub changed_properties: Cell, } impl ToplevelData { @@ -351,6 +359,8 @@ impl ToplevelData { jay_screencasts: Default::default(), ext_copy_sessions: Default::default(), slf: slf.clone(), + destroyed: Default::default(), + changed_properties: Default::default(), } } @@ -387,6 +397,20 @@ impl ToplevelData { (width, height) } + pub fn property_changed(&self, change: TlMatcherChange) { + let mgr = &self.state.tl_matcher_manager; + let props = self.changed_properties.get(); + if props.is_none() && mgr.has_no_interest(self, change) { + return; + } + self.changed_properties.set(props | change); + if props.is_none() && change.is_some() { + if let Some(node) = self.slf.upgrade() { + mgr.changed(node); + } + } + } + pub fn destroy_node(&self, node: &dyn Node) { for jay_tl in self.jay_toplevels.lock().drain_values() { jay_tl.destroy(); @@ -410,6 +434,7 @@ impl ToplevelData { } } self.detach_node(node); + self.property_changed(TL_CHANGED_DESTROYED); } pub fn detach_node(&self, node: &dyn Node) { diff --git a/toml-config/src/config.rs b/toml-config/src/config.rs index a19fb0a3..2360fc77 100644 --- a/toml-config/src/config.rs +++ b/toml-config/src/config.rs @@ -28,6 +28,7 @@ use { status::MessageFormat, theme::Color, video::{ColorSpace, Format, GfxApi, TearingMode, TransferFunction, Transform, VrrMode}, + window::WindowType, xwayland::XScalingMode, }, std::{ @@ -241,6 +242,20 @@ pub struct ClientMatch { pub exe_regex: Option, } +#[derive(Debug, Clone)] +pub struct WindowRule { + pub name: Option, + pub match_: WindowMatch, + pub action: Option, + pub latch: Option, +} + +#[derive(Default, Debug, Clone)] +pub struct WindowMatch { + pub generic: GenericMatch, + pub types: Option, +} + #[derive(Debug, Clone)] pub enum DrmDeviceMatch { Any(Vec), @@ -439,6 +454,7 @@ pub struct Config { pub named_actions: Vec, pub max_action_depth: u64, pub client_rules: Vec, + pub window_rules: Vec, } #[derive(Debug, Error)] diff --git a/toml-config/src/config/parsers.rs b/toml-config/src/config/parsers.rs index ca1dc2e0..d49fabfc 100644 --- a/toml-config/src/config/parsers.rs +++ b/toml-config/src/config/parsers.rs @@ -39,6 +39,9 @@ mod tearing; mod theme; mod ui_drag; mod vrr; +mod window_match; +mod window_rule; +mod window_type; mod xwayland; #[derive(Debug, Error)] diff --git a/toml-config/src/config/parsers/config.rs b/toml-config/src/config/parsers/config.rs index 4baf4d36..f93723c7 100644 --- a/toml-config/src/config/parsers/config.rs +++ b/toml-config/src/config/parsers/config.rs @@ -32,6 +32,7 @@ use { theme::ThemeParser, ui_drag::UiDragParser, vrr::VrrParser, + window_rule::WindowRulesParser, xwayland::XwaylandParser, }, spanned::SpannedErrorExt, @@ -121,7 +122,14 @@ impl Parser for ConfigParser<'_> { ui_drag_val, xwayland_val, ), - (color_management_val, float_val, actions_val, max_action_depth_val, client_rules_val), + ( + color_management_val, + float_val, + actions_val, + max_action_depth_val, + client_rules_val, + window_rules_val, + ), ) = ext.extract(( ( opt(val("keymap")), @@ -165,6 +173,7 @@ impl Parser for ConfigParser<'_> { opt(val("actions")), recover(opt(int("max-action-depth"))), opt(val("clients")), + opt(val("windows")), ), ))?; let mut keymap = None; @@ -428,6 +437,13 @@ impl Parser for ConfigParser<'_> { Err(e) => log::warn!("Could not parse the client rules: {}", self.0.error(e)), } } + let mut window_rules = vec![]; + if let Some(value) = window_rules_val { + match value.parse(&mut WindowRulesParser(self.0)) { + Ok(v) => window_rules = v, + Err(e) => log::warn!("Could not parse the window rules: {}", self.0.error(e)), + } + } Ok(Config { keymap, repeat_rate, @@ -463,6 +479,7 @@ impl Parser for ConfigParser<'_> { named_actions, max_action_depth, client_rules, + window_rules, }) } } diff --git a/toml-config/src/config/parsers/window_match.rs b/toml-config/src/config/parsers/window_match.rs new file mode 100644 index 00000000..3c41403d --- /dev/null +++ b/toml-config/src/config/parsers/window_match.rs @@ -0,0 +1,113 @@ +use { + crate::{ + config::{ + GenericMatch, MatchExactly, WindowMatch, + context::Context, + extractor::{Extractor, ExtractorError, arr, n32, opt, str, val}, + parser::{DataType, ParseResult, Parser, UnexpectedDataType}, + parsers::window_type::{WindowTypeParser, WindowTypeParserError}, + }, + toml::{ + toml_span::{DespanExt, Span, Spanned}, + toml_value::Value, + }, + }, + indexmap::IndexMap, + thiserror::Error, +}; + +#[derive(Debug, Error)] +pub enum WindowMatchParserError { + #[error(transparent)] + Expected(#[from] UnexpectedDataType), + #[error(transparent)] + Extract(#[from] ExtractorError), + #[error(transparent)] + WindowTypes(#[from] WindowTypeParserError), +} + +pub struct WindowMatchParser<'a>(pub &'a Context<'a>); + +impl Parser for WindowMatchParser<'_> { + type Value = WindowMatch; + type Error = WindowMatchParserError; + 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, types_val),) = ext.extract((( + opt(str("name")), + opt(val("not")), + opt(arr("all")), + opt(arr("any")), + opt(val("exactly")), + opt(val("types")), + ),))?; + let mut not = None; + if let Some(value) = not_val { + not = Some(Box::new(value.parse(&mut WindowMatchParser(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 WindowMatchParser(self.0))?); + } + list = Some(res); + } + list + }}; + } + let all = list!(all_val); + let any = list!(any_val); + let mut types = None; + if let Some(value) = types_val { + types = Some(value.parse_map(&mut WindowTypeParser)?); + } + let mut exactly = None; + if let Some(value) = exactly_val { + exactly = Some(value.parse(&mut WindowMatchExactlyParser(self.0))?); + } + Ok(WindowMatch { + generic: GenericMatch { + name: name.despan_into(), + not, + all, + any, + exactly, + }, + types, + }) + } +} + +pub struct WindowMatchExactlyParser<'a>(pub &'a Context<'a>); + +impl Parser for WindowMatchExactlyParser<'_> { + type Value = MatchExactly; + type Error = WindowMatchParserError; + 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 WindowMatchParser(self.0))?); + } + Ok(MatchExactly { + num: num.value as _, + list, + }) + } +} diff --git a/toml-config/src/config/parsers/window_rule.rs b/toml-config/src/config/parsers/window_rule.rs new file mode 100644 index 00000000..a31ab978 --- /dev/null +++ b/toml-config/src/config/parsers/window_rule.rs @@ -0,0 +1,104 @@ +use { + crate::{ + config::{ + WindowMatch, WindowRule, + context::Context, + extractor::{Extractor, ExtractorError, opt, str, val}, + parser::{DataType, ParseResult, Parser, UnexpectedDataType}, + parsers::{ + action::{ActionParser, ActionParserError}, + window_match::{WindowMatchParser, WindowMatchParserError}, + }, + spanned::SpannedErrorExt, + }, + toml::{ + toml_span::{DespanExt, Span, Spanned}, + toml_value::Value, + }, + }, + indexmap::IndexMap, + thiserror::Error, +}; + +#[derive(Debug, Error)] +pub enum WindowRuleParserError { + #[error(transparent)] + Expected(#[from] UnexpectedDataType), + #[error(transparent)] + Extract(#[from] ExtractorError), + #[error(transparent)] + Match(#[from] WindowMatchParserError), + #[error(transparent)] + Action(ActionParserError), + #[error(transparent)] + Latch(ActionParserError), +} + +pub struct WindowRuleParser<'a>(pub &'a Context<'a>); + +impl Parser for WindowRuleParser<'_> { + type Value = WindowRule; + type Error = WindowRuleParserError; + 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(WindowRuleParserError::Action)?, + ); + } + let mut latch = None; + if let Some(value) = latch_val { + latch = Some( + value + .parse(&mut ActionParser(self.0)) + .map_spanned_err(WindowRuleParserError::Latch)?, + ); + } + let match_ = match match_val { + None => WindowMatch::default(), + Some(m) => m.parse_map(&mut WindowMatchParser(self.0))?, + }; + Ok(WindowRule { + name: name.despan_into(), + match_, + action, + latch, + }) + } +} + +pub struct WindowRulesParser<'a>(pub &'a Context<'a>); + +impl Parser for WindowRulesParser<'_> { + type Value = Vec; + type Error = WindowRuleParserError; + 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 WindowRuleParser(self.0)) { + Ok(o) => res.push(o), + Err(e) => { + log::warn!("Could not parse window rule: {}", self.0.error(e)); + } + } + } + Ok(res) + } +} diff --git a/toml-config/src/config/parsers/window_type.rs b/toml-config/src/config/parsers/window_type.rs new file mode 100644 index 00000000..388fe317 --- /dev/null +++ b/toml-config/src/config/parsers/window_type.rs @@ -0,0 +1,53 @@ +use { + crate::{ + config::parser::{DataType, ParseResult, Parser, UnexpectedDataType}, + toml::{ + toml_span::{Span, Spanned, SpannedExt}, + toml_value::Value, + }, + }, + jay_config::{window, window::WindowType}, + thiserror::Error, +}; + +#[derive(Debug, Error)] +pub enum WindowTypeParserError { + #[error(transparent)] + Expected(#[from] UnexpectedDataType), + #[error("Unknown window type `{}`", .0)] + UnknownWindowType(String), +} + +pub struct WindowTypeParser; + +impl Parser for WindowTypeParser { + type Value = WindowType; + type Error = WindowTypeParserError; + const EXPECTED: &'static [DataType] = &[DataType::Array, DataType::String]; + + fn parse_string(&mut self, span: Span, string: &str) -> ParseResult { + let ty = match string { + "none" => WindowType(0), + "any" => WindowType(!0), + "container" => window::CONTAINER, + "placeholder" => window::PLACEHOLDER, + "xdg-toplevel" => window::XDG_TOPLEVEL, + "x-window" => window::X_WINDOW, + "client-window" => window::CLIENT_WINDOW, + _ => { + return Err( + WindowTypeParserError::UnknownWindowType(string.to_owned()).spanned(span) + ); + } + }; + Ok(ty) + } + + fn parse_array(&mut self, _span: Span, array: &[Spanned]) -> ParseResult { + let mut ty = WindowType(0); + for el in array { + ty |= el.parse(&mut WindowTypeParser)?; + } + Ok(ty) + } +} diff --git a/toml-config/src/lib.rs b/toml-config/src/lib.rs index cd3afb22..b70cb692 100644 --- a/toml-config/src/lib.rs +++ b/toml-config/src/lib.rs @@ -14,7 +14,7 @@ use { config::{ Action, ClientRule, Config, ConfigConnector, ConfigDrmDevice, ConfigKeymap, ConnectorMatch, DrmDeviceMatch, Exec, Input, InputMatch, Output, OutputMatch, Shortcut, - SimpleCommand, Status, Theme, parse_config, + SimpleCommand, Status, Theme, WindowRule, parse_config, }, rules::{MatcherTemp, RuleMapper}, }, @@ -47,6 +47,7 @@ use { on_new_connector, on_new_drm_device, set_direct_scanout_enabled, set_gfx_api, set_tearing_mode, set_vrr_cursor_hz, set_vrr_mode, }, + window::Window, xwayland::set_x_scaling_mode, }, run_on_drop::on_drop, @@ -100,24 +101,39 @@ impl Action { }}; } let s = state.persistent.seat; + macro_rules! window_or_seat { + ($name:ident, $expr:expr) => {{ + let state = state.clone(); + B::new(move || { + if let Some($name) = state.window.get() { + if let Some($name) = $name { + $expr; + } + } else { + let $name = s; + $expr; + } + }) + }}; + } match self { Action::SimpleCommand { cmd } => match cmd { SimpleCommand::Focus(dir) => B::new(move || s.focus(dir)), - SimpleCommand::Move(dir) => B::new(move || s.move_(dir)), - SimpleCommand::Split(axis) => B::new(move || s.create_split(axis)), - SimpleCommand::ToggleSplit => B::new(move || s.toggle_split()), - SimpleCommand::SetSplit(b) => B::new(move || s.set_split(b)), - SimpleCommand::ToggleMono => B::new(move || s.toggle_mono()), - SimpleCommand::SetMono(b) => B::new(move || s.set_mono(b)), - SimpleCommand::ToggleFullscreen => B::new(move || s.toggle_fullscreen()), - SimpleCommand::SetFullscreen(b) => B::new(move || s.set_fullscreen(b)), + SimpleCommand::Move(dir) => window_or_seat!(s, s.move_(dir)), + SimpleCommand::Split(axis) => window_or_seat!(s, s.create_split(axis)), + SimpleCommand::ToggleSplit => window_or_seat!(s, s.toggle_split()), + SimpleCommand::SetSplit(b) => window_or_seat!(s, s.set_split(b)), + SimpleCommand::ToggleMono => window_or_seat!(s, s.toggle_mono()), + SimpleCommand::SetMono(b) => window_or_seat!(s, s.set_mono(b)), + SimpleCommand::ToggleFullscreen => window_or_seat!(s, s.toggle_fullscreen()), + SimpleCommand::SetFullscreen(b) => window_or_seat!(s, s.set_fullscreen(b)), SimpleCommand::FocusParent => B::new(move || s.focus_parent()), - SimpleCommand::Close => B::new(move || s.close()), + SimpleCommand::Close => window_or_seat!(s, s.close()), SimpleCommand::DisablePointerConstraint => { B::new(move || s.disable_pointer_constraint()) } - SimpleCommand::ToggleFloating => B::new(move || s.toggle_floating()), - SimpleCommand::SetFloating(b) => B::new(move || s.set_floating(b)), + SimpleCommand::ToggleFloating => window_or_seat!(s, s.toggle_floating()), + SimpleCommand::SetFloating(b) => window_or_seat!(s, s.set_floating(b)), SimpleCommand::Quit => B::new(quit), SimpleCommand::ReloadConfigToml => { let persistent = state.persistent.clone(); @@ -133,8 +149,10 @@ impl Action { B::new(move || set_float_above_fullscreen(bool)) } 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::SetFloatPinned(pinned) => { + window_or_seat!(s, s.set_float_pinned(pinned)) + } + SimpleCommand::ToggleFloatPinned => window_or_seat!(s, s.toggle_float_pinned()), SimpleCommand::KillClient => client_action!(c, c.kill()), }, Action::Multi { actions } => { @@ -153,7 +171,7 @@ impl Action { } Action::MoveToWorkspace { name } => { let workspace = get_workspace(&name); - B::new(move || s.set_workspace(workspace)) + window_or_seat!(s, s.set_workspace(workspace)) } Action::ConfigureConnector { con } => B::new(move || { for c in connectors() { @@ -689,6 +707,8 @@ struct State { action_depth: Cell, client: Cell>, + + window: Cell>>, } impl Drop for State { @@ -897,13 +917,23 @@ impl State { fn with_client(&self, client: Client, check: bool, f: impl FnOnce()) { let mut opt = Some(client); - if check && client.does_not_exist() { + if client.0 == 0 || (check && client.does_not_exist()) { opt = None; } self.client.set(opt); f(); self.client.set(None); } + + fn with_window(&self, window: Window, check: bool, f: impl FnOnce()) { + let mut w = Some(window); + if check && !window.exists() { + w = None; + } + self.window.set(Some(w)); + f(); + self.window.set(None); + } } #[derive(Eq, PartialEq, Hash)] @@ -922,6 +952,7 @@ struct PersistentState { actions: RefCell, Rc>>, client_rules: Cell>>, client_rule_mapper: RefCell>>, + window_rules: Cell>>, } fn load_config(initial_load: bool, persistent: &Rc) { @@ -1003,10 +1034,13 @@ fn load_config(initial_load: bool, persistent: &Rc) { action_depth_max: config.max_action_depth, action_depth: Cell::new(0), client: Default::default(), + window: 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); + let (window_rules, _) = state.create_rules(&config.window_rules); + persistent.window_rules.set(window_rules); state.set_status(&config.status); persistent.actions.borrow_mut().clear(); for a in config.named_actions { @@ -1231,6 +1265,7 @@ pub fn configure() { actions: Default::default(), client_rules: Default::default(), client_rule_mapper: Default::default(), + window_rules: Default::default(), }); { let p = persistent.clone(); diff --git a/toml-config/src/rules.rs b/toml-config/src/rules.rs index 468fdb41..7b5d256a 100644 --- a/toml-config/src/rules.rs +++ b/toml-config/src/rules.rs @@ -1,10 +1,13 @@ use { crate::{ State, - config::{ClientMatch, ClientRule, GenericMatch}, + config::{ClientMatch, ClientRule, GenericMatch, WindowMatch, WindowRule}, }, ahash::{AHashMap, AHashSet}, - jay_config::client::{ClientCriterion, ClientMatcher}, + jay_config::{ + client::{ClientCriterion, ClientMatcher}, + window::{WindowCriterion, WindowMatcher}, + }, std::{mem::ManuallyDrop, rc::Rc}, }; @@ -195,6 +198,131 @@ impl Rule for ClientRule { } } +impl Rule for WindowRule { + type Match = WindowMatch; + type Matcher = WindowMatcher; + type Criterion<'a> = WindowCriterion<'a>; + + const NAME_UPPER: &str = "Window"; + const NAME_LOWER: &str = "window"; + + 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<()> { + let m = |c: WindowCriterion<'_>| MatcherTemp(c.to_matcher()); + #[expect(unused_macros)] + macro_rules! value { + ($ty:ident, $field:ident) => { + if let Some(value) = &match_.$field { + all.push(m(WindowCriterion::$ty(value))); + } + }; + } + #[expect(unused_macros)] + macro_rules! bool { + ($ty:ident, $field:ident) => { + if let Some(value) = &match_.$field { + let crit = WindowCriterion::$ty; + let matcher = match value { + false => m(WindowCriterion::Not(&crit)), + true => m(crit), + }; + all.push(matcher); + } + }; + } + if let Some(value) = &match_.types { + all.push(m(WindowCriterion::Types(*value))); + } + 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, $win:ident) => { + let g = $g.clone(); + let state = state.clone(); + $win.latch(move || { + state.with_client($client, true, || { + state.with_window(*$win, 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); + matcher.bind(move |win| { + let client = win.client(); + state.with_client(client, false, || { + state.with_window(*win, false, &f); + }); + latch!(g, client, win); + }); + } else { + matcher.bind(move |win| { + let client = win.client(); + state.with_client(client, false, || { + state.with_window(*win, false, &f); + }); + }); + } + } else { + if let Some(action) = &self.latch { + let g = action.clone().into_rc_fn(&state); + matcher.bind(move |win| { + let client = win.client(); + latch!(g, client, win); + }); + } + } + } + + fn gen_matcher(m: Self::Matcher) -> Self::Criterion<'static> { + WindowCriterion::Matcher(m) + } + + fn gen_not<'a, 'b: 'a>(m: &'a Self::Criterion<'b>) -> Self::Criterion<'a> { + WindowCriterion::Not(m) + } + + fn gen_all<'a, 'b: 'a>(m: &'a [Self::Criterion<'b>]) -> Self::Criterion<'a> { + WindowCriterion::All(m) + } + + fn gen_any<'a, 'b: 'a>(m: &'a [Self::Criterion<'b>]) -> Self::Criterion<'a> { + WindowCriterion::Any(m) + } + + fn gen_exactly<'a, 'b: 'a>(n: usize, m: &'a [Self::Criterion<'b>]) -> Self::Criterion<'a> { + WindowCriterion::Exactly(n, m) + } +} + pub struct RuleMapper where R: Rule, diff --git a/toml-spec/spec/spec.generated.json b/toml-spec/spec/spec.generated.json index cbd4ed2c..1b562e07 100644 --- a/toml-spec/spec/spec.generated.json +++ b/toml-spec/spec/spec.generated.json @@ -858,6 +858,14 @@ "description": "", "$ref": "#/$defs/ClientRule" } + }, + "windows": { + "type": "array", + "description": "An array of window rules.\n\nThese rules can be used to give names to windows and to manipulate them.\n\n- Example:\n\n ```toml\n [[windows]]\n name = \"spotify\"\n match.title-regex = \"Spotify\"\n action = { type = \"move-to-workspace\", name = \"music\" }\n ```\n", + "items": { + "description": "", + "$ref": "#/$defs/WindowRule" + } } }, "required": [] @@ -1487,7 +1495,7 @@ }, "SimpleActionName": { "type": "string", - "description": "The name of a `simple` Action.\n\n- Example:\n\n ```toml\n [shortcuts]\n alt-q = \"quit\"\n ```\n", + "description": "The name of a `simple` Action.\n\nWhen used inside a window rule, the following actions apply to the matched window\ninstead fo the focused window:\n\n- `move-left`\n- `move-down`\n- `move-up`\n- `move-right`\n- `split-horizontal`\n- `split-vertical`\n- `toggle-split`\n- `tile-horizontal`\n- `tile-vertical`\n- `toggle-split`\n- `show-single`\n- `show-all`\n- `toggle-fullscreen`\n- `enter-fullscreen`\n- `exit-fullscreen`\n- `close`\n- `toggle-floating`\n- `float`\n- `tile`\n- `toggle-float-pinned`\n- `pin-float`\n- `unpin-float`\n\n\n- Example:\n\n ```toml\n [shortcuts]\n alt-q = \"quit\"\n ```\n", "enum": [ "focus-left", "focus-down", @@ -1732,6 +1740,115 @@ "variant3" ] }, + "WindowMatch": { + "description": "Criteria for matching windows.\n\nIf no fields are set, all windows are matched. If multiple fields are set, all fields\nmust match the window.\n", + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Matches if the window rule with this name matches.\n\n- Example:\n\n ```toml\n [[windows]]\n name = \"spotify\"\n match.title-regex = \"Spotify\"\n\n # Matches the same windows as the previous rule.\n [[windows]]\n match.name = \"spotify\"\n ```\n" + }, + "not": { + "description": "Matches if the contained criteria don't match.\n\n- Example:\n\n ```toml\n [[windows]]\n name = \"not-spotify\"\n match.not.title-regex = \"Spotify\"\n ```\n", + "$ref": "#/$defs/WindowMatch" + }, + "all": { + "type": "array", + "description": "Matches if all of the contained criteria match.\n\n- Example:\n\n ```toml\n [[windows]]\n match.all = [\n { title-regex = \"Spotify\" },\n { title-regex = \"Premium\" },\n ]\n ```\n", + "items": { + "description": "", + "$ref": "#/$defs/WindowMatch" + } + }, + "any": { + "type": "array", + "description": "Matches if any of the contained criteria match.\n\n- Example:\n\n ```toml\n [[windows]]\n match.any = [\n { title-regex = \"Spotify\" },\n { title-regex = \"Alacritty\" },\n ]\n ```\n", + "items": { + "description": "", + "$ref": "#/$defs/WindowMatch" + } + }, + "exactly": { + "description": "Matches if a specific number of contained criteria match.\n\n- Example:\n\n ```toml\n # Matches any window that is either Alacritty or on workspace 3 but not both.\n [[windows]]\n match.exactly.num = 1\n match.exactly.list = [\n { workspace = \"3\" },\n { title-regex = \"Alacritty\" },\n ]\n ```\n", + "$ref": "#/$defs/WindowMatchExactly" + }, + "types": { + "description": "Matches windows whose type is contained in the mask.", + "$ref": "#/$defs/WindowTypeMask" + } + }, + "required": [] + }, + "WindowMatchExactly": { + "description": "Criterion for matching a specific number of window 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/WindowMatch" + } + } + }, + "required": [ + "num", + "list" + ] + }, + "WindowRule": { + "description": "A window 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 [[windows]]\n name = \"spotify\"\n match.title-regex = \"Spotify\"\n\n [[windows]]\n match.name = \"spotify\"\n action = \"enter-fullscreen\"\n ```\n" + }, + "match": { + "description": "The criteria that select the window that this rule applies to.", + "$ref": "#/$defs/WindowMatch" + }, + "action": { + "description": "An action to execute when a window matches the criteria.", + "$ref": "#/$defs/Action" + }, + "latch": { + "description": "An action to execute when a window no longer matches the criteria.", + "$ref": "#/$defs/Action" + } + }, + "required": [] + }, + "WindowTypeMask": { + "description": "A mask of window types.\n", + "anyOf": [ + { + "type": "string", + "description": "A named mask.", + "enum": [ + "none", + "any", + "container", + "xdg-toplevel", + "x-window", + "client-window" + ] + }, + { + "type": "array", + "description": "An array of masks that are OR'd.", + "items": { + "description": "", + "$ref": "#/$defs/WindowTypeMask" + } + } + ] + }, "XScalingMode": { "type": "string", "description": "The scaling mode of X windows.\n\n- Example:\n\n ```toml\n xwayland = { scaling-mode = \"downscaled\" }\n ```\n", diff --git a/toml-spec/spec/spec.generated.md b/toml-spec/spec/spec.generated.md index cdf4d275..97245dfd 100644 --- a/toml-spec/spec/spec.generated.md +++ b/toml-spec/spec/spec.generated.md @@ -1721,6 +1721,23 @@ The table has the following fields: The value of this field should be an array of [ClientRules](#types-ClientRule). +- `windows` (optional): + + An array of window rules. + + These rules can be used to give names to windows and to manipulate them. + + - Example: + + ```toml + [[windows]] + name = "spotify" + match.title-regex = "Spotify" + action = { type = "move-to-workspace", name = "music" } + ``` + + The value of this field should be an array of [WindowRules](#types-WindowRule). + ### `Connector` @@ -3235,6 +3252,33 @@ The table has the following fields: The name of a `simple` Action. +When used inside a window rule, the following actions apply to the matched window +instead fo the focused window: + +- `move-left` +- `move-down` +- `move-up` +- `move-right` +- `split-horizontal` +- `split-vertical` +- `toggle-split` +- `tile-horizontal` +- `tile-vertical` +- `toggle-split` +- `show-single` +- `show-all` +- `toggle-fullscreen` +- `enter-fullscreen` +- `exit-fullscreen` +- `close` +- `toggle-floating` +- `float` +- `tile` +- `toggle-float-pinned` +- `pin-float` +- `unpin-float` + + - Example: ```toml @@ -3437,7 +3481,8 @@ The string should have one of the following values: Kills a client. - This action has no effect outside of client rules. + Within a window rule, it applies to the client of the window. Within a client rule + it applies to the matched client. Has no effect otherwise. @@ -3859,6 +3904,222 @@ The string should have one of the following values: + +### `WindowMatch` + +Criteria for matching windows. + +If no fields are set, all windows are matched. If multiple fields are set, all fields +must match the window. + +Values of this type should be tables. + +The table has the following fields: + +- `name` (optional): + + Matches if the window rule with this name matches. + + - Example: + + ```toml + [[windows]] + name = "spotify" + match.title-regex = "Spotify" + + # Matches the same windows as the previous rule. + [[windows]] + match.name = "spotify" + ``` + + The value of this field should be a string. + +- `not` (optional): + + Matches if the contained criteria don't match. + + - Example: + + ```toml + [[windows]] + name = "not-spotify" + match.not.title-regex = "Spotify" + ``` + + The value of this field should be a [WindowMatch](#types-WindowMatch). + +- `all` (optional): + + Matches if all of the contained criteria match. + + - Example: + + ```toml + [[windows]] + match.all = [ + { title-regex = "Spotify" }, + { title-regex = "Premium" }, + ] + ``` + + The value of this field should be an array of [WindowMatchs](#types-WindowMatch). + +- `any` (optional): + + Matches if any of the contained criteria match. + + - Example: + + ```toml + [[windows]] + match.any = [ + { title-regex = "Spotify" }, + { title-regex = "Alacritty" }, + ] + ``` + + The value of this field should be an array of [WindowMatchs](#types-WindowMatch). + +- `exactly` (optional): + + Matches if a specific number of contained criteria match. + + - Example: + + ```toml + # Matches any window that is either Alacritty or on workspace 3 but not both. + [[windows]] + match.exactly.num = 1 + match.exactly.list = [ + { workspace = "3" }, + { title-regex = "Alacritty" }, + ] + ``` + + The value of this field should be a [WindowMatchExactly](#types-WindowMatchExactly). + +- `types` (optional): + + Matches windows whose type is contained in the mask. + + The value of this field should be a [WindowTypeMask](#types-WindowTypeMask). + + + +### `WindowMatchExactly` + +Criterion for matching a specific number of window 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 [WindowMatchs](#types-WindowMatch). + + + +### `WindowRule` + +A window 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 + [[windows]] + name = "spotify" + match.title-regex = "Spotify" + + [[windows]] + match.name = "spotify" + action = "enter-fullscreen" + ``` + + The value of this field should be a string. + +- `match` (optional): + + The criteria that select the window that this rule applies to. + + The value of this field should be a [WindowMatch](#types-WindowMatch). + +- `action` (optional): + + An action to execute when a window matches the criteria. + + The value of this field should be a [Action](#types-Action). + +- `latch` (optional): + + An action to execute when a window no longer matches the criteria. + + The value of this field should be a [Action](#types-Action). + + + +### `WindowTypeMask` + +A mask of window types. + +Values of this type should have one of the following forms: + +#### A string + +A named mask. + +The string should have one of the following values: + +- `none`: + + The empty mask. + +- `any`: + + The mask containing every possible type. + +- `container`: + + The mask matching a container. + +- `xdg-toplevel`: + + The mask matching an XDG toplevel. + +- `x-window`: + + The mask matching an X window. + +- `client-window`: + + The mask matching any type of client window. + + +#### An array + +An array of masks that are OR'd. + +Each element of this array should be a [WindowTypeMask](#types-WindowTypeMask). + + ### `XScalingMode` diff --git a/toml-spec/spec/spec.yaml b/toml-spec/spec/spec.yaml index 2b3659e5..c243a6aa 100644 --- a/toml-spec/spec/spec.yaml +++ b/toml-spec/spec/spec.yaml @@ -691,6 +691,33 @@ Exec: SimpleActionName: description: | The name of a `simple` Action. + + When used inside a window rule, the following actions apply to the matched window + instead fo the focused window: + + - `move-left` + - `move-down` + - `move-up` + - `move-right` + - `split-horizontal` + - `split-vertical` + - `toggle-split` + - `tile-horizontal` + - `tile-vertical` + - `toggle-split` + - `show-single` + - `show-all` + - `toggle-fullscreen` + - `enter-fullscreen` + - `exit-fullscreen` + - `close` + - `toggle-floating` + - `float` + - `tile` + - `toggle-float-pinned` + - `pin-float` + - `unpin-float` + - Example: @@ -825,7 +852,8 @@ SimpleActionName: description: | Kills a client. - This action has no effect outside of client rules. + Within a window rule, it applies to the client of the window. Within a client rule + it applies to the matched client. Has no effect otherwise. Color: @@ -2509,6 +2537,24 @@ Config: name = "spotify" match.sandbox-app-id = "com.spotify.Client" ``` + windows: + kind: array + items: + ref: WindowRule + required: false + description: | + An array of window rules. + + These rules can be used to give names to windows and to manipulate them. + + - Example: + + ```toml + [[windows]] + name = "spotify" + match.title-regex = "Spotify" + action = { type = "move-to-workspace", name = "music" } + ``` Idle: @@ -3284,3 +3330,179 @@ ClientMatchExactly: ref: ClientMatch required: true description: The list of criteria. + + +WindowRule: + kind: table + description: | + A window rule. + fields: + name: + kind: string + required: false + description: | + The name of this rule. + + This name can be referenced in other rules. + + - Example + + ```toml + [[windows]] + name = "spotify" + match.title-regex = "Spotify" + + [[windows]] + match.name = "spotify" + action = "enter-fullscreen" + ``` + match: + ref: WindowMatch + required: false + description: The criteria that select the window that this rule applies to. + action: + ref: Action + required: false + description: An action to execute when a window matches the criteria. + latch: + ref: Action + required: false + description: An action to execute when a window no longer matches the criteria. + + +WindowMatch: + kind: table + description: | + Criteria for matching windows. + + If no fields are set, all windows are matched. If multiple fields are set, all fields + must match the window. + fields: + name: + kind: string + required: false + description: | + Matches if the window rule with this name matches. + + - Example: + + ```toml + [[windows]] + name = "spotify" + match.title-regex = "Spotify" + + # Matches the same windows as the previous rule. + [[windows]] + match.name = "spotify" + ``` + not: + ref: WindowMatch + required: false + description: | + Matches if the contained criteria don't match. + + - Example: + + ```toml + [[windows]] + name = "not-spotify" + match.not.title-regex = "Spotify" + ``` + all: + kind: array + items: + ref: WindowMatch + required: false + description: | + Matches if all of the contained criteria match. + + - Example: + + ```toml + [[windows]] + match.all = [ + { title-regex = "Spotify" }, + { title-regex = "Premium" }, + ] + ``` + any: + kind: array + items: + ref: WindowMatch + required: false + description: | + Matches if any of the contained criteria match. + + - Example: + + ```toml + [[windows]] + match.any = [ + { title-regex = "Spotify" }, + { title-regex = "Alacritty" }, + ] + ``` + exactly: + ref: WindowMatchExactly + required: false + description: | + Matches if a specific number of contained criteria match. + + - Example: + + ```toml + # Matches any window that is either Alacritty or on workspace 3 but not both. + [[windows]] + match.exactly.num = 1 + match.exactly.list = [ + { workspace = "3" }, + { title-regex = "Alacritty" }, + ] + ``` + types: + ref: WindowTypeMask + required: false + description: Matches windows whose type is contained in the mask. + + +WindowMatchExactly: + kind: table + description: | + Criterion for matching a specific number of window criteria. + fields: + num: + kind: number + required: true + description: The number of criteria that must match. + list: + kind: array + items: + ref: WindowMatch + required: true + description: The list of criteria. + + +WindowTypeMask: + description: | + A mask of window types. + kind: variable + variants: + - kind: string + description: A named mask. + values: + - value: none + description: The empty mask. + - value: any + description: The mask containing every possible type. + - value: container + description: The mask matching a container. + - value: xdg-toplevel + description: The mask matching an XDG toplevel. + - value: x-window + description: The mask matching an X window. + - value: client-window + description: The mask matching any type of client window. + - kind: array + description: An array of masks that are OR'd. + items: + ref: WindowTypeMask