1
0
Fork 0
forked from wry/wry

Merge pull request #511 from mahkoh/jorth/content-type-window-rules

config: add content-type window criteria
This commit is contained in:
mahkoh 2025-07-17 11:24:02 +02:00 committed by GitHub
commit 35adc21ca6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 327 additions and 18 deletions

View file

@ -249,7 +249,6 @@ The full specification of window criteria can be found in
- `urgent` - Matches if the window wants/doesn't want attentions.
- `focused` - Matches if the window is/isn't focused.
- `fullscreen` - Matches if the window is/isn't fullscreen.
- `just-mapped` - Matches if the window has/hasn't just been mapped. This is
- `just-mapped` - Matches if the window has/hasn't just been mapped. This is
true for a single frame after the window has been mapped.
- `tag`, `tag-regex` - Matches the XDG toplevel tag of the window.
@ -257,3 +256,5 @@ The full specification of window criteria can be found in
- `x-instance`, `x-instance-regex` - Matches the X instance of the window.
- `x-role`, `x-role-regex` - Matches the X role of the window.
- `workspace`, `workspace-regex` - Matches the workspace of the window.
- `content-types` - Matches the content type of a window. Currently there are
three types: photos, videos, and games.

View file

@ -9,7 +9,7 @@ use {
client::ClientMatcher,
input::Seat,
video::Mode,
window::{WindowMatcher, WindowType},
window::{ContentType, WindowMatcher, WindowType},
},
bincode::Options,
serde::{Deserialize, Serialize},
@ -119,6 +119,7 @@ pub enum WindowCriterionIpc {
Fullscreen,
JustMapped,
Workspace(Workspace),
ContentTypes(ContentType),
}
#[derive(Serialize, Deserialize, Clone, Debug, Hash, Eq, PartialEq)]

View file

@ -32,7 +32,10 @@ use {
Transform, VrrMode,
connector_type::{CON_UNKNOWN, ConnectorType},
},
window::{MatchedWindow, TileState, Window, WindowCriterion, WindowMatcher, WindowType},
window::{
ContentType, MatchedWindow, TileState, Window, WindowCriterion, WindowMatcher,
WindowType,
},
xwayland::XScalingMode,
},
bincode::Options,
@ -413,6 +416,12 @@ impl ConfigClient {
kind
}
pub fn content_type(&self, window: Window) -> ContentType {
let res = self.send_with_response(&ClientMessage::GetContentType { window });
get_response!(res, ContentType(0), GetContentType { kind });
kind
}
pub fn window_id(&self, window: Window) -> String {
let res = self.send_with_response(&ClientMessage::GetWindowId { window });
get_response!(res, String::new(), GetWindowId { id });
@ -1682,6 +1691,7 @@ impl ConfigClient {
WindowCriterion::Workspace(t) => WindowCriterionIpc::Workspace(t),
WindowCriterion::WorkspaceName(t) => string!(t, Workspace, false),
WindowCriterion::WorkspaceNameRegex(t) => string!(t, Workspace, true),
WindowCriterion::ContentTypes(t) => WindowCriterionIpc::ContentTypes(t),
};
let res = self.send_with_response(&ClientMessage::CreateWindowMatcher { criterion });
get_response!(

View file

@ -15,7 +15,7 @@ use {
ColorSpace, Connector, DrmDevice, Format, GfxApi, TearingMode, TransferFunction,
Transform, VrrMode, connector_type::ConnectorType,
},
window::{TileState, Window, WindowMatcher, WindowType},
window::{ContentType, TileState, Window, WindowMatcher, WindowType},
xwayland::XScalingMode,
},
serde::{Deserialize, Serialize},
@ -718,6 +718,9 @@ pub enum ClientMessage<'a> {
device: InputDevice,
enabled: bool,
},
GetContentType {
window: Window,
},
}
#[derive(Serialize, Deserialize, Debug)]
@ -944,6 +947,9 @@ pub enum Response {
CreateWindowMatcher {
matcher: WindowMatcher,
},
GetContentType {
kind: ContentType,
},
}
#[derive(Serialize, Deserialize, Debug)]

View file

@ -41,6 +41,21 @@ bitflags! {
}
}
bitflags! {
/// The content type of a window.
#[derive(Serialize, Deserialize, Copy, Clone, Hash, Eq, PartialEq)]
pub struct ContentType(pub u64) {
/// No content type.
pub const NO_CONTENT_TYPE = 1 << 0,
/// Photo content type.
pub const PHOTO_CONTENT = 1 << 1,
/// Video content type.
pub const VIDEO_CONTENT = 1 << 2,
/// Game content type.
pub const GAME_CONTENT = 1 << 3,
}
}
/// The tile state of a window.
#[non_exhaustive]
#[derive(Serialize, Deserialize, Copy, Clone, Debug, Hash, Eq, PartialEq)]
@ -86,6 +101,11 @@ impl Window {
get!(WindowType(0)).window_type(self)
}
/// Returns the content type of the window.
pub fn content_type(self) -> ContentType {
get!(ContentType(0)).content_type(self)
}
/// Returns the identifier of the window.
///
/// This is the identifier used in the `ext-foreign-toplevel-list-v1` protocol.
@ -292,6 +312,8 @@ pub enum WindowCriterion<'a> {
WorkspaceName(&'a str),
/// Matches the workspace name of the window with a regular expression.
WorkspaceNameRegex(&'a str),
/// Matches if the window has one of the content types.
ContentTypes(ContentType),
}
impl WindowCriterion<'_> {

View file

@ -16,7 +16,10 @@ use {
tlm::{TlmLeafMatcher, TlmUpstreamNode},
},
format::config_formats,
ifs::wl_seat::{SeatId, WlSeatGlobal},
ifs::{
wl_seat::{SeatId, WlSeatGlobal},
wp_content_type_v1::ContentTypeExt,
},
io_uring::TaskResultExt,
kbvm::{KbvmError, KbvmMap},
output_schedule::map_cursor_hz,
@ -2062,6 +2065,7 @@ impl ConfigProxyHandler {
WindowCriterionIpc::Workspace(w) => mgr.workspace(CritLiteralOrRegex::Literal(
self.get_workspace(*w)?.to_string(),
)),
WindowCriterionIpc::ContentTypes(t) => mgr.content_type(*t),
};
let cached = Rc::new(CachedCriterion {
crit: criterion.clone(),
@ -2356,6 +2360,17 @@ impl ConfigProxyHandler {
Ok(())
}
fn handle_get_content_type(&self, window: Window) -> Result<(), CphError> {
let kind = self
.get_window(window)?
.tl_data()
.content_type
.get()
.to_config();
self.respond(Response::GetContentType { kind });
Ok(())
}
fn handle_window_exists(&self, window: Window) {
self.respond(Response::WindowExists {
exists: self.get_window(window).is_ok(),
@ -2964,6 +2979,9 @@ impl ConfigProxyHandler {
ClientMessage::SetMiddleButtonEmulationEnabled { device, enabled } => self
.handle_set_middle_button_emulation_enabled(device, enabled)
.wrn("set_middle_button_emulation_enabled")?,
ClientMessage::GetContentType { window } => self
.handle_get_content_type(window)
.wrn("get_content_type")?,
}
Ok(())
}

View file

@ -13,6 +13,7 @@ use {
crit_matchers::critm_constant::CritMatchConstant,
tlm::tlm_matchers::{
tlmm_client::TlmMatchClient,
tlmm_content_type::TlmMatchContentType,
tlmm_floating::TlmMatchFloating,
tlmm_fullscreen::TlmMatchFullscreen,
tlmm_just_mapped::TlmMatchJustMapped,
@ -34,7 +35,7 @@ use {
toplevel_identifier::ToplevelIdentifier,
},
},
jay_config::window::WindowType,
jay_config::window::{ContentType, WindowType},
linearize::static_map,
std::{
marker::PhantomData,
@ -58,6 +59,7 @@ bitflags! {
TL_CHANGED_CLASS_INST = 1 << 11,
TL_CHANGED_ROLE = 1 << 12,
TL_CHANGED_WORKSPACE = 1 << 13,
TL_CHANGED_CONTENT_TY = 1 << 14,
}
type TlmFixedRootMatcher<T> = FixedRootMatcher<ToplevelData, T>;
@ -90,6 +92,7 @@ pub struct RootMatchers {
instance: TlmRootMatcherMap<TlmMatchInstance>,
role: TlmRootMatcherMap<TlmMatchRole>,
workspace: TlmRootMatcherMap<TlmMatchWorkspace>,
content_ty: TlmRootMatcherMap<TlmMatchContentType>,
}
pub async fn handle_tl_changes(state: Rc<State>) {
@ -222,6 +225,7 @@ impl TlMatcherManager {
conditional!(TL_CHANGED_CLASS_INST, instance);
conditional!(TL_CHANGED_ROLE, role);
conditional!(TL_CHANGED_WORKSPACE, workspace);
conditional!(TL_CHANGED_CONTENT_TY, content_ty);
fixed_conditional!(TL_CHANGED_FLOATING, floating);
fixed_conditional!(TL_CHANGED_VISIBLE, visible);
fixed_conditional!(TL_CHANGED_URGENT, urgent);
@ -299,6 +303,7 @@ impl TlMatcherManager {
conditional!(TL_CHANGED_CLASS_INST, instance);
conditional!(TL_CHANGED_ROLE, role);
conditional!(TL_CHANGED_WORKSPACE, workspace);
conditional!(TL_CHANGED_CONTENT_TY, content_ty);
fixed_conditional!(TL_CHANGED_FLOATING, floating);
fixed_conditional!(TL_CHANGED_VISIBLE, visible);
fixed_conditional!(TL_CHANGED_URGENT, urgent);
@ -372,6 +377,10 @@ impl TlMatcherManager {
pub fn workspace(&self, string: CritLiteralOrRegex) -> Rc<TlmUpstreamNode> {
self.root(TlmMatchWorkspace::new(string))
}
pub fn content_type(&self, kind: ContentType) -> Rc<TlmUpstreamNode> {
self.root(TlmMatchContentType::new(kind))
}
}
impl CritTarget for ToplevelData {

View file

@ -18,6 +18,7 @@ macro_rules! fixed_root_criterion {
}
pub mod tlmm_client;
pub mod tlmm_content_type;
pub mod tlmm_floating;
pub mod tlmm_fullscreen;
pub mod tlmm_just_mapped;

View file

@ -0,0 +1,32 @@
use {
crate::{
criteria::{
crit_graph::CritRootCriterion,
tlm::{RootMatchers, TlmRootMatcherMap},
},
ifs::wp_content_type_v1::ContentTypeExt,
tree::ToplevelData,
utils::bitflags::BitflagsExt,
},
jay_config::window::ContentType,
};
pub struct TlmMatchContentType {
kind: ContentType,
}
impl TlmMatchContentType {
pub fn new(kind: ContentType) -> TlmMatchContentType {
Self { kind }
}
}
impl CritRootCriterion<ToplevelData> for TlmMatchContentType {
fn matches(&self, data: &ToplevelData) -> bool {
self.kind.0.contains(data.content_type.get().to_config().0)
}
fn nodes(roots: &RootMatchers) -> Option<&TlmRootMatcherMap<Self>> {
Some(&roots.content_ty)
}
}

View file

@ -26,6 +26,9 @@ impl SurfaceExt for XSurface {
fn after_apply_commit(self: Rc<Self>) {
if let Some(xwindow) = self.xwindow.get() {
xwindow.map_status_changed();
xwindow
.toplevel_data
.set_content_type(self.surface.content_type.get());
}
}

View file

@ -217,6 +217,7 @@ impl Xwindow {
weak,
);
tld.pos.set(surface.extents.get());
tld.content_type.set(surface.content_type.get());
Self {
id,
data: data.clone(),

View file

@ -146,6 +146,17 @@ impl XdgToplevel {
let data = Rc::new(XdgToplevelToplevelData {
tag: Default::default(),
});
let toplevel_data = ToplevelData::new(
state,
String::new(),
Some(surface.surface.client.clone()),
ToplevelType::XdgToplevel(data.clone()),
node_id,
slf,
);
toplevel_data
.content_type
.set(surface.surface.content_type.get());
Self {
id,
state: state.clone(),
@ -161,14 +172,7 @@ impl XdgToplevel {
max_width: Cell::new(None),
max_height: Cell::new(None),
tracker: Default::default(),
toplevel_data: ToplevelData::new(
state,
String::new(),
Some(surface.surface.client.clone()),
ToplevelType::XdgToplevel(data.clone()),
node_id,
slf,
),
toplevel_data,
drag: Default::default(),
is_mapped: Cell::new(false),
dialog: Default::default(),
@ -518,6 +522,8 @@ impl XdgToplevel {
self.state.tree_changed();
self.toplevel_data.broadcast(self.clone());
}
self.toplevel_data
.set_content_type(self.xdg.surface.content_type.get());
}
}

View file

@ -6,6 +6,10 @@ use {
object::{Object, Version},
wire::{WpContentTypeV1Id, wp_content_type_v1::*},
},
jay_config::window::{
ContentType as ConfigContentType, GAME_CONTENT, NO_CONTENT_TYPE, PHOTO_CONTENT,
VIDEO_CONTENT,
},
std::rc::Rc,
thiserror::Error,
};
@ -22,6 +26,21 @@ pub enum ContentType {
Game,
}
pub trait ContentTypeExt {
fn to_config(&self) -> ConfigContentType;
}
impl ContentTypeExt for Option<ContentType> {
fn to_config(&self) -> ConfigContentType {
match self {
None => NO_CONTENT_TYPE,
Some(ContentType::Photo) => PHOTO_CONTENT,
Some(ContentType::Video) => VIDEO_CONTENT,
Some(ContentType::Game) => GAME_CONTENT,
}
}
}
pub struct WpContentTypeV1 {
pub id: WpContentTypeV1Id,
pub client: Rc<Client>,

View file

@ -4,9 +4,9 @@ use {
criteria::{
CritDestroyListener, CritMatcherId,
tlm::{
TL_CHANGED_APP_ID, TL_CHANGED_DESTROYED, TL_CHANGED_FLOATING,
TL_CHANGED_FULLSCREEN, TL_CHANGED_NEW, TL_CHANGED_TITLE, TL_CHANGED_URGENT,
TL_CHANGED_VISIBLE, TL_CHANGED_WORKSPACE, TlMatcherChange,
TL_CHANGED_APP_ID, TL_CHANGED_CONTENT_TY, TL_CHANGED_DESTROYED,
TL_CHANGED_FLOATING, TL_CHANGED_FULLSCREEN, TL_CHANGED_NEW, TL_CHANGED_TITLE,
TL_CHANGED_URGENT, TL_CHANGED_VISIBLE, TL_CHANGED_WORKSPACE, TlMatcherChange,
},
},
ifs::{
@ -20,6 +20,7 @@ use {
WlSurface, x_surface::xwindow::XwindowData,
xdg_surface::xdg_toplevel::XdgToplevelToplevelData,
},
wp_content_type_v1::ContentType,
zwlr_foreign_toplevel_handle_v1::ZwlrForeignToplevelHandleV1,
zwlr_foreign_toplevel_manager_v1::ZwlrForeignToplevelManagerV1,
},
@ -371,6 +372,7 @@ pub struct ToplevelData {
pub changed_properties: Cell<TlMatcherChange>,
pub just_mapped_scheduled: Cell<bool>,
pub seat_foci: CopyHashMap<SeatId, ()>,
pub content_type: Cell<Option<ContentType>>,
}
impl ToplevelData {
@ -422,6 +424,7 @@ impl ToplevelData {
changed_properties: Default::default(),
just_mapped_scheduled: Cell::new(false),
seat_foci: Default::default(),
content_type: Default::default(),
}
}
@ -857,6 +860,12 @@ impl ToplevelData {
pub fn just_mapped(&self) -> bool {
self.mapped_during_iteration.get() == self.state.eng.iteration()
}
pub fn set_content_type(&self, content_type: Option<ContentType>) {
if self.content_type.replace(content_type) != content_type {
self.property_changed(TL_CHANGED_CONTENT_TY);
}
}
}
impl Drop for ToplevelData {

View file

@ -28,7 +28,7 @@ use {
status::MessageFormat,
theme::Color,
video::{ColorSpace, Format, GfxApi, TearingMode, TransferFunction, Transform, VrrMode},
window::{TileState, WindowType},
window::{ContentType, TileState, WindowType},
xwayland::XScalingMode,
},
std::{
@ -277,6 +277,7 @@ pub struct WindowMatch {
pub x_role_regex: Option<String>,
pub workspace: Option<String>,
pub workspace_regex: Option<String>,
pub content_types: Option<ContentType>,
}
#[derive(Debug, Clone)]

View file

@ -15,6 +15,7 @@ pub mod color_management;
pub mod config;
mod connector;
mod connector_match;
mod content_type;
mod drm_device;
mod drm_device_match;
mod env;

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::{
ContentType, GAME_CONTENT, NO_CONTENT_TYPE, PHOTO_CONTENT, VIDEO_CONTENT,
},
thiserror::Error,
};
#[derive(Debug, Error)]
pub enum ContentTypeParserError {
#[error(transparent)]
Expected(#[from] UnexpectedDataType),
#[error("Unknown content type `{}`", .0)]
UnknownContentType(String),
}
pub struct ContentTypeParser;
impl Parser for ContentTypeParser {
type Value = ContentType;
type Error = ContentTypeParserError;
const EXPECTED: &'static [DataType] = &[DataType::Array, DataType::String];
fn parse_string(&mut self, span: Span, string: &str) -> ParseResult<Self> {
let ty = match string {
"none" => NO_CONTENT_TYPE,
"any" => !NO_CONTENT_TYPE,
"photo" => PHOTO_CONTENT,
"video" => VIDEO_CONTENT,
"game" => GAME_CONTENT,
_ => {
return Err(
ContentTypeParserError::UnknownContentType(string.to_owned()).spanned(span),
);
}
};
Ok(ty)
}
fn parse_array(&mut self, _span: Span, array: &[Spanned<Value>]) -> ParseResult<Self> {
let mut ty = ContentType(0);
for el in array {
ty |= el.parse(&mut ContentTypeParser)?;
}
Ok(ty)
}
}

View file

@ -7,6 +7,7 @@ use {
parser::{DataType, ParseResult, Parser, UnexpectedDataType},
parsers::{
client_match::{ClientMatchParser, ClientMatchParserError},
content_type::{ContentTypeParser, ContentTypeParserError},
window_type::{WindowTypeParser, WindowTypeParserError},
},
},
@ -29,6 +30,8 @@ pub enum WindowMatchParserError {
WindowTypes(#[from] WindowTypeParserError),
#[error(transparent)]
ClientMatchParserError(#[from] ClientMatchParserError),
#[error(transparent)]
ContentTypes(#[from] ContentTypeParserError),
}
pub struct WindowMatchParser<'a>(pub &'a Context<'a>);
@ -77,6 +80,7 @@ impl Parser for WindowMatchParser<'_> {
x_role_regex,
workspace,
workspace_regex,
content_types_val,
),
) = ext.extract((
(
@ -111,6 +115,7 @@ impl Parser for WindowMatchParser<'_> {
opt(str("x-role-regex")),
opt(str("workspace")),
opt(str("workspace-regex")),
opt(val("content-types")),
),
))?;
let mut not = None;
@ -144,6 +149,10 @@ impl Parser for WindowMatchParser<'_> {
if let Some(value) = client_val {
client = Some(value.parse_map(&mut ClientMatchParser(self.0))?);
}
let mut content_types = None;
if let Some(value) = content_types_val {
content_types = Some(value.parse_map(&mut ContentTypeParser)?);
}
Ok(WindowMatch {
generic: GenericMatch {
name: name.despan_into(),
@ -174,6 +183,7 @@ impl Parser for WindowMatchParser<'_> {
workspace_regex: workspace_regex.despan_into(),
types,
client,
content_types,
})
}
}

View file

@ -281,6 +281,9 @@ impl Rule for WindowRule {
};
all.push(matcher);
}
if let Some(value) = &match_.content_types {
all.push(m(WindowCriterion::ContentTypes(*value)));
}
Some(())
}

View file

@ -924,6 +924,30 @@
}
]
},
"ContentTypeMask": {
"description": "A mask of content types.\n",
"anyOf": [
{
"type": "string",
"description": "A named mask.",
"enum": [
"none",
"any",
"photo",
"video",
"game"
]
},
{
"type": "array",
"description": "An array of masks that are OR'd.",
"items": {
"description": "",
"$ref": "#/$defs/ContentTypeMask"
}
}
]
},
"DrmDevice": {
"description": "Describes configuration to apply to a DRM device (graphics card).\n\n- Example: To disable direct scanout on a device:\n\n ```toml\n [[drm-devices]]\n match = { pci-vendor = 0x1002, pci-model = 0x73ff }\n direct-scanout = false\n ```\n",
"type": "object",
@ -1888,6 +1912,10 @@
"workspace-regex": {
"type": "string",
"description": "Matches the workspace of the window with a regular expression."
},
"content-types": {
"description": "Matches windows whose content type is contained in the mask.",
"$ref": "#/$defs/ContentTypeMask"
}
},
"required": []

View file

@ -1855,6 +1855,47 @@ The table has the following fields:
The value of this field should be a string.
<a name="types-ContentTypeMask"></a>
### `ContentTypeMask`
A mask of content 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 mask matching windows without a content type.
- `any`:
The mask containing every possible type except `none`.
- `photo`:
The mask matching photo content.
- `video`:
The mask matching video content.
- `game`:
The mask matching game content.
#### An array
An array of masks that are OR'd.
Each element of this array should be a [ContentTypeMask](#types-ContentTypeMask).
<a name="types-DrmDevice"></a>
### `DrmDevice`
@ -4216,6 +4257,12 @@ The table has the following fields:
The value of this field should be a string.
- `content-types` (optional):
Matches windows whose content type is contained in the mask.
The value of this field should be a [ContentTypeMask](#types-ContentTypeMask).
<a name="types-WindowMatchExactly"></a>
### `WindowMatchExactly`

View file

@ -3615,6 +3615,10 @@ WindowMatch:
kind: string
required: false
description: Matches the workspace of the window with a regular expression.
content-types:
ref: ContentTypeMask
required: false
description: Matches windows whose content type is contained in the mask.
WindowMatchExactly:
@ -3668,3 +3672,27 @@ TileState:
description: The window is tiled.
- value: floating
description: The window is floating.
ContentTypeMask:
description: |
A mask of content types.
kind: variable
variants:
- kind: string
description: A named mask.
values:
- value: none
description: The mask matching windows without a content type.
- value: any
description: The mask containing every possible type except `none`.
- value: photo
description: The mask matching photo content.
- value: video
description: The mask matching video content.
- value: game
description: The mask matching game content.
- kind: array
description: An array of masks that are OR'd.
items:
ref: ContentTypeMask