1
0
Fork 0
forked from wry/wry

config: add window-rule infrastructure

This commit is contained in:
Julian Orth 2025-05-01 17:49:21 +02:00
parent a6257910bb
commit 59f8acdfde
26 changed files with 1829 additions and 38 deletions

View file

@ -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<WindowMatcher>),
String {
string: String,
field: WindowCriterionStringField,
regex: bool,
},
Types(WindowType),
}
#[derive(Serialize, Deserialize, Clone, Debug, Hash, Eq, PartialEq)]
pub enum WindowCriterionStringField {}

View file

@ -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<Option<Rc<String>>>,
pressed_keysym: Cell<Option<KeySym>>,
client_match_handlers: RefCell<HashMap<ClientMatcher, ClientMatchHandler>>,
window_match_handlers: RefCell<HashMap<WindowMatcher, WindowMatchHandler>>,
feat_mod_mask: Cell<bool>,
}
@ -123,6 +124,11 @@ struct ClientMatchHandler {
latched: HashMap<Client, Box<dyn FnOnce()>>,
}
struct WindowMatchHandler {
cb: Callback<MatchedWindow>,
latched: HashMap<Window, Box<dyn FnOnce()>>,
}
struct Interest {
result: Option<Result<(), String>>,
waker: Option<Waker>,
@ -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<WindowCriterion, WindowMatcher>| {
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();
}
}
}

View file

@ -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)]

View file

@ -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<F: FnMut(MatchedWindow) + 'static>(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<F: FnMut(MatchedWindow) + 'static>(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<F: FnOnce() + 'static>(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
}
}

View file

@ -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()),
),
]
}

View file

@ -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 {}))

View file

@ -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<ClientMatcher, Rc<CachedCriterion<ClientCriterionIpc, Rc<Client>>>>,
pub client_matcher_cache: CriterionCache<ClientCriterionIpc, Rc<Client>>,
pub client_matcher_leafs: CopyHashMap<ClientMatcher, Rc<ClmLeafMatcher>>,
pub window_matcher_ids: NumCell<u64>,
pub window_matchers:
CopyHashMap<WindowMatcher, Rc<CachedCriterion<WindowCriterionIpc, ToplevelData>>>,
pub window_matcher_cache: CriterionCache<WindowCriterionIpc, ToplevelData>,
pub window_matcher_leafs: CopyHashMap<WindowMatcher, Rc<TlmLeafMatcher>>,
pub window_matcher_std_kinds: Rc<TlmUpstreamNode>,
}
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<Rc<CachedCriterion<WindowCriterionIpc, ToplevelData>>, 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<Self>,
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 {

View file

@ -3,6 +3,7 @@ mod crit_graph;
pub mod crit_leaf;
mod crit_matchers;
mod crit_per_target_data;
pub mod tlm;
use {
crate::{

View file

@ -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()
}

271
src/criteria/tlm.rs Normal file
View file

@ -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<T> = FixedRootMatcher<ToplevelData, T>;
pub struct TlMatcherManager {
ids: Rc<CritMatcherIds>,
changes: AsyncQueue<Rc<dyn ToplevelNode>>,
leaf_events: Rc<AsyncQueue<CritLeafEvent<ToplevelData>>>,
constant: TlmFixedRootMatcher<CritMatchConstant<ToplevelData>>,
matchers: Rc<RootMatchers>,
}
type TlmRootMatcherMap<T> = RootMatcherMap<ToplevelData, T>;
#[derive(Default)]
pub struct RootMatchers {
kinds: TlmRootMatcherMap<TlmMatchKind>,
}
pub async fn handle_tl_changes(state: Rc<State>) {
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<State>) {
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<ToplevelData>;
pub type TlmLeafMatcher = CritLeafMatcher<ToplevelData>;
impl TlMatcherManager {
pub fn new(ids: &Rc<CritMatcherIds>) -> 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<State>) {
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<dyn ToplevelNode>) {
self.changes.push(node);
}
fn update_matches(&self, node: Rc<dyn ToplevelNode>) {
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<TlmUpstreamNode> {
self.root(TlmMatchKind::new(kind))
}
}
impl CritTarget for ToplevelData {
type Id = NodeId;
type Mgr = TlMatcherManager;
type RootMatchers = RootMatchers;
type LeafData = ToplevelIdentifier;
type Owner = Weak<dyn ToplevelNode>;
fn owner(&self) -> Self::Owner {
self.slf.clone()
}
fn id(&self) -> Self::Id {
self.node_id
}
fn destroyed(&self) -> &CopyHashMap<CritMatcherId, Weak<dyn CritDestroyListener<Self>>> {
&self.destroyed
}
fn leaf_data(&self) -> Self::LeafData {
self.identifier.get()
}
}
impl CritTargetOwner for Rc<dyn ToplevelNode> {
type Target = ToplevelData;
fn data(&self) -> &Self::Target {
self.tl_data()
}
}
impl WeakCritTargetOwner for Weak<dyn ToplevelNode> {
type Target = ToplevelData;
type Owner = Rc<dyn ToplevelNode>;
fn upgrade(&self) -> Option<Self::Owner> {
self.upgrade()
}
}
impl CritMgr for TlMatcherManager {
type Target = ToplevelData;
fn id(&self) -> CritMatcherId {
self.ids.next()
}
fn leaf_events(&self) -> &Rc<AsyncQueue<CritLeafEvent<Self::Target>>> {
&self.leaf_events
}
fn match_constant(&self) -> &FixedRootMatcher<Self::Target, CritMatchConstant<Self::Target>> {
&self.constant
}
fn roots(&self) -> &Rc<<Self::Target as CritTarget>::RootMatchers> {
&self.matchers
}
}

View file

@ -0,0 +1,21 @@
#[expect(unused_macros)]
macro_rules! fixed_root_criterion {
($ty:ty, $field:ident) => {
impl crate::criteria::crit_graph::CritFixedRootCriterionBase<crate::tree::ToplevelData>
for $ty
{
fn constant(&self) -> bool {
self.0
}
fn not<'a>(
&self,
mgr: &'a crate::criteria::tlm::TlMatcherManager,
) -> &'a crate::criteria::FixedRootMatcher<crate::tree::ToplevelData, Self> {
&mgr.$field
}
}
};
}
pub mod tlmm_kind;

View file

@ -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<ToplevelData> for TlmMatchKind {
fn matches(&self, data: &ToplevelData) -> bool {
self.kind.0.contains(data.kind.to_window_type().0)
}
fn nodes(roots: &RootMatchers) -> Option<&TlmRootMatcherMap<Self>> {
Some(&roots.kinds)
}
}

View file

@ -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 { .. } => {}
}
}

View file

@ -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<bool>,
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) {

View file

@ -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<T: ToplevelNodeBase> ToplevelNode for T {
fn tl_set_parent(&self, parent: Rc<dyn ContainingNode>) {
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<bool>,
@ -307,6 +313,8 @@ pub struct ToplevelData {
pub ext_copy_sessions:
CopyHashMap<(ClientId, ExtImageCopyCaptureSessionV1Id), Rc<ExtImageCopyCaptureSessionV1>>,
pub slf: Weak<dyn ToplevelNode>,
pub destroyed: CopyHashMap<CritMatcherId, Weak<dyn CritDestroyListener<ToplevelData>>>,
pub changed_properties: Cell<TlMatcherChange>,
}
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) {

View file

@ -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<String>,
}
#[derive(Debug, Clone)]
pub struct WindowRule {
pub name: Option<String>,
pub match_: WindowMatch,
pub action: Option<Action>,
pub latch: Option<Action>,
}
#[derive(Default, Debug, Clone)]
pub struct WindowMatch {
pub generic: GenericMatch<Self>,
pub types: Option<WindowType>,
}
#[derive(Debug, Clone)]
pub enum DrmDeviceMatch {
Any(Vec<DrmDeviceMatch>),
@ -439,6 +454,7 @@ pub struct Config {
pub named_actions: Vec<NamedAction>,
pub max_action_depth: u64,
pub client_rules: Vec<ClientRule>,
pub window_rules: Vec<WindowRule>,
}
#[derive(Debug, Error)]

View file

@ -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)]

View file

@ -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,
})
}
}

View file

@ -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<String>, Spanned<Value>>,
) -> ParseResult<Self> {
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<WindowMatch>;
type Error = WindowMatchParserError;
const EXPECTED: &'static [DataType] = &[DataType::Table];
fn parse_table(
&mut self,
span: Span,
table: &IndexMap<Spanned<String>, Spanned<Value>>,
) -> ParseResult<Self> {
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,
})
}
}

View file

@ -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<String>, Spanned<Value>>,
) -> ParseResult<Self> {
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<WindowRule>;
type Error = WindowRuleParserError;
const EXPECTED: &'static [DataType] = &[DataType::Array];
fn parse_array(&mut self, _span: Span, array: &[Spanned<Value>]) -> ParseResult<Self> {
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)
}
}

View file

@ -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<Self> {
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<Value>]) -> ParseResult<Self> {
let mut ty = WindowType(0);
for el in array {
ty |= el.parse(&mut WindowTypeParser)?;
}
Ok(ty)
}
}

View file

@ -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<u64>,
client: Cell<Option<Client>>,
window: Cell<Option<Option<Window>>>,
}
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<AHashMap<Rc<String>, Rc<dyn Fn()>>>,
client_rules: Cell<Vec<MatcherTemp<ClientRule>>>,
client_rule_mapper: RefCell<Option<RuleMapper<ClientRule>>>,
window_rules: Cell<Vec<MatcherTemp<WindowRule>>>,
}
fn load_config(initial_load: bool, persistent: &Rc<PersistentState>) {
@ -1003,10 +1034,13 @@ fn load_config(initial_load: bool, persistent: &Rc<PersistentState>) {
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();

View file

@ -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<Self::Match> {
&m.generic
}
fn map_custom(
_state: &Rc<State>,
all: &mut Vec<MatcherTemp<Self>>,
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<State>, 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<R>
where
R: Rule,

View file

@ -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",

View file

@ -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).
<a name="types-Connector"></a>
### `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:
<a name="types-WindowMatch"></a>
### `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).
<a name="types-WindowMatchExactly"></a>
### `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).
<a name="types-WindowRule"></a>
### `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).
<a name="types-WindowTypeMask"></a>
### `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).
<a name="types-XScalingMode"></a>
### `XScalingMode`

View file

@ -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