From ca6fc5424670e30a1fb8a45d5fd71ee2e8cd71c0 Mon Sep 17 00:00:00 2001 From: Julian Orth Date: Thu, 5 Mar 2026 18:59:43 +0100 Subject: [PATCH] control-center: add window pane --- src/control_center.rs | 20 +- src/control_center/cc_clients.rs | 109 ++++++- src/control_center/cc_criterion.rs | 1 - src/control_center/cc_sidebar.rs | 5 + src/control_center/cc_window.rs | 481 +++++++++++++++++++++++++++++ src/criteria/clm.rs | 1 - src/tree/toplevel.rs | 1 - src/utils/opaque.rs | 2 +- src/utils/toplevel_identifier.rs | 2 +- 9 files changed, 611 insertions(+), 11 deletions(-) create mode 100644 src/control_center/cc_window.rs diff --git a/src/control_center.rs b/src/control_center.rs index 661afba0..203146b6 100644 --- a/src/control_center.rs +++ b/src/control_center.rs @@ -9,6 +9,7 @@ use { cc_input::InputPane, cc_look_and_feel::LookAndFeelPane, cc_outputs::OutputsPane, + cc_window::{WindowPane, WindowSearchPane}, cc_xwayland::XwaylandPane, }, egui_adapter::egui_platform::{ @@ -18,8 +19,8 @@ use { macros::Bitflag, state::State, utils::{ - asyncevent::AsyncEvent, copyhashmap::CopyHashMap, numcell::NumCell, - static_text::StaticText, + asyncevent::AsyncEvent, copyhashmap::CopyHashMap, + event_listener::LazyEventSourceListener, numcell::NumCell, static_text::StaticText, }, }, egui::{ @@ -50,6 +51,7 @@ mod cc_input; mod cc_look_and_feel; mod cc_outputs; mod cc_sidebar; +mod cc_window; mod cc_xwayland; #[derive(Debug, Error)] @@ -141,6 +143,8 @@ enum PaneType { LookAndFeel(LookAndFeelPane), Clients(ClientsPane), Client(ClientPane), + WindowSearch(WindowSearchPane), + Window(WindowPane), } struct CcBehavior<'a> { @@ -168,6 +172,8 @@ impl Pane { PaneType::LookAndFeel(v) => v.title(res), PaneType::Clients(v) => v.title(res), PaneType::Client(v) => v.title(res), + PaneType::WindowSearch(v) => v.title(res), + PaneType::Window(v) => v.title(res), } } @@ -183,6 +189,8 @@ impl Pane { PaneType::LookAndFeel(p) => p.show(ui), PaneType::Clients(p) => p.show(behavior, ui), PaneType::Client(p) => p.show(behavior, ui), + PaneType::WindowSearch(p) => p.show(behavior, ui), + PaneType::Window(p) => p.show(behavior, ui), } } } @@ -200,6 +208,8 @@ impl PaneType { PaneType::LookAndFeel(_) => CCI_LOOK_AND_FEEL, PaneType::Clients(_) => ControlCenterInterest::none(), PaneType::Client(_) => ControlCenterInterest::none(), + PaneType::WindowSearch(_) => ControlCenterInterest::none(), + PaneType::Window(_) => ControlCenterInterest::none(), } } } @@ -663,3 +673,9 @@ impl Drop for GridRow<'_> { self.end_row(); } } + +impl LazyEventSourceListener for ControlCenterInner { + fn triggered(self: Rc) { + self.window.request_redraw(); + } +} diff --git a/src/control_center/cc_clients.rs b/src/control_center/cc_clients.rs index 8ffa73af..1e002895 100644 --- a/src/control_center/cc_clients.rs +++ b/src/control_center/cc_clients.rs @@ -4,16 +4,28 @@ use { control_center::{ CcBehavior, ControlCenterInner, PaneType, cc_criterion::{CcCriterion, CritImpl, CritRegex}, + cc_window::show_window_collapsible, grid, icon_label, label, read_only_bool, }, criteria::{CritMgrExt, CritUpstreamNode, crit_leaf::CritLeafMatcher}, egui_adapter::egui_platform::icons::ICON_OPEN_IN_NEW, state::State, - utils::{copyhashmap::CopyHashMap, static_text::StaticText}, + tree::ToplevelData, + utils::{ + copyhashmap::CopyHashMap, static_text::StaticText, + toplevel_identifier::ToplevelIdentifier, + }, + }, + ahash::AHashMap, + egui::{ + CollapsingHeader, DragValue, Sense, TextFormat, Ui, Widget, cache::CacheTrait, + text::LayoutJob, }, - egui::{CollapsingHeader, DragValue, Sense, TextFormat, Ui, Widget, text::LayoutJob}, linearize::Linearize, - std::rc::{Rc, Weak}, + std::{ + any::Any, + rc::{Rc, Weak}, + }, }; pub enum ClientCrit { @@ -318,7 +330,7 @@ pub fn show_client_collapsible(behavior: &mut CcBehavior, ui: &mut Ui, client: & }); } -pub fn show_client(_behavior: &mut CcBehavior<'_>, ui: &mut Ui, client: &Client) { +pub fn show_client(behavior: &mut CcBehavior<'_>, ui: &mut Ui, client: &Client) { grid(ui, ("client", client.id), |ui| { label(ui, "ID", client.id.to_string()); label(ui, "PID", client.pid_info.pid.to_string()); @@ -359,4 +371,93 @@ pub fn show_client(_behavior: &mut CcBehavior<'_>, ui: &mut Ui, client: &Client) } }); }); + ui.collapsing("Windows", |ui| { + let matcher = ui.memory_mut(|m| { + m.caches + .cache::() + .get(behavior.cc, client.id) + .clone() + }); + let mut windows: Vec<_> = matcher.windows.lock().keys().copied().collect(); + windows.sort(); + for id in windows { + let Some(window) = client.state.toplevels.get(&id).and_then(|v| v.upgrade()) else { + continue; + }; + show_window_collapsible(behavior, ui, &window); + } + }); +} + +#[derive(Default)] +struct ClientWindowMatchersCache { + generation: u64, + matchers: AHashMap, +} + +struct CachedWindowMatcher { + generation: u64, + _matcher: Rc>, + matchers: Rc, +} + +struct WindowMatchers { + cc: Weak, + windows: CopyHashMap, +} + +impl ClientWindowMatchersCache { + fn get(&mut self, cc: &Rc, id: ClientId) -> &Rc { + let res = self.matchers.entry(id).or_insert_with(|| { + let state = &cc.state; + let node = state.cl_matcher_manager.id(id); + let node = state.tl_matcher_manager.client(state, &node); + let matchers = Rc::new(WindowMatchers { + cc: Rc::downgrade(&cc), + windows: Default::default(), + }); + let matchers2 = matchers.clone(); + let matcher = state.tl_matcher_manager.leaf(&node, move |id| { + matchers2.windows.set(id, ()); + if let Some(cc) = matchers2.cc.upgrade() { + cc.window.request_redraw(); + } + let matchers2 = matchers2.clone(); + Box::new(move || { + matchers2.windows.remove(&id); + if let Some(cc) = matchers2.cc.upgrade() { + cc.window.request_redraw(); + } + }) + }); + let res = CachedWindowMatcher { + generation: 0, + _matcher: matcher, + matchers, + }; + state.cl_matcher_manager.rematch_all(state); + state.tl_matcher_manager.rematch_all(state); + res + }); + res.generation = self.generation; + &res.matchers + } +} + +unsafe impl Sync for ClientWindowMatchersCache {} +unsafe impl Send for ClientWindowMatchersCache {} + +impl CacheTrait for ClientWindowMatchersCache { + fn update(&mut self) { + self.matchers.retain(|_, m| m.generation == self.generation); + self.generation += 1; + } + + fn len(&self) -> usize { + self.matchers.len() + } + + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } } diff --git a/src/control_center/cc_criterion.rs b/src/control_center/cc_criterion.rs index 6c93acd7..ed37aa8a 100644 --- a/src/control_center/cc_criterion.rs +++ b/src/control_center/cc_criterion.rs @@ -201,7 +201,6 @@ where } } - #[expect(dead_code)] pub fn any(&self, mut any: impl FnMut(&T) -> bool) -> bool { self.any_(&mut any) } diff --git a/src/control_center/cc_sidebar.rs b/src/control_center/cc_sidebar.rs index 61b5194c..23f2116b 100644 --- a/src/control_center/cc_sidebar.rs +++ b/src/control_center/cc_sidebar.rs @@ -17,6 +17,7 @@ enum PaneName { Input, LookAndFeel, Clients, + WindowSearch, } impl PaneName { @@ -31,6 +32,7 @@ impl PaneName { PaneName::Input => "Input", PaneName::LookAndFeel => "Look and Feel", PaneName::Clients => "Clients", + PaneName::WindowSearch => "Window Search", } } } @@ -74,6 +76,9 @@ impl ControlCenterInner { PaneType::LookAndFeel(self.create_look_and_feel_pane()) } PaneName::Clients => PaneType::Clients(self.create_clients_pane()), + PaneName::WindowSearch => { + PaneType::WindowSearch(self.create_window_search_pane()) + } }; self.open(tree, ty); ui.ctx().request_repaint(); diff --git a/src/control_center/cc_window.rs b/src/control_center/cc_window.rs new file mode 100644 index 00000000..80edf4c7 --- /dev/null +++ b/src/control_center/cc_window.rs @@ -0,0 +1,481 @@ +use { + crate::{ + control_center::{ + CcBehavior, ControlCenterInner, PaneType, + cc_clients::{ClientCrit, show_client_collapsible}, + cc_criterion::{CcCriterion, CritImpl, CritRegex}, + grid, icon_label, label, read_only_bool, + }, + criteria::{CritMgrExt, CritUpstreamNode, crit_leaf::CritLeafMatcher}, + egui_adapter::egui_platform::icons::ICON_OPEN_IN_NEW, + state::State, + tree::{NodeId, ToplevelData, ToplevelNode, ToplevelType}, + utils::{ + copyhashmap::CopyHashMap, + event_listener::{EventListener, LazyEventSourceListener}, + static_text::StaticText, + toplevel_identifier::ToplevelIdentifier, + }, + }, + ahash::AHashMap, + egui::{CollapsingHeader, Sense, TextFormat, Ui, Widget, cache::CacheTrait, text::LayoutJob}, + isnt::std_1::primitive::IsntStrExt, + jay_config::window::{ + ContentType, GAME_CONTENT, NO_CONTENT_TYPE, PHOTO_CONTENT, VIDEO_CONTENT, + }, + linearize::Linearize, + std::{ + any::Any, + mem, + rc::{Rc, Weak}, + }, +}; + +enum WindowClit { + Client(CcCriterion), + Title(CritRegex), + AppId(CritRegex), + Floating, + Visible, + Urgent, + Fullscreen, + Tag(CritRegex), + XClass(CritRegex), + XInstance(CritRegex), + XRole(CritRegex), + Workspace(CritRegex), + ContentTypes(ContentType), +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq, Linearize)] +enum WindowCritTy { + Client, + Title, + AppId, + Floating, + Visible, + Urgent, + Fullscreen, + Tag, + XClass, + XInstance, + XRole, + Workspace, + ContentTypes, +} + +impl Default for WindowClit { + fn default() -> Self { + WindowClit::Title(Default::default()) + } +} + +impl StaticText for WindowCritTy { + fn text(&self) -> &'static str { + match self { + WindowCritTy::Client => "Client", + WindowCritTy::Title => "Title", + WindowCritTy::AppId => "App ID", + WindowCritTy::Floating => "Floating", + WindowCritTy::Visible => "Visible", + WindowCritTy::Urgent => "Urgent", + WindowCritTy::Fullscreen => "Fullscreen", + WindowCritTy::Tag => "Tag", + WindowCritTy::XClass => "X Class", + WindowCritTy::XInstance => "X Instance", + WindowCritTy::XRole => "X Role", + WindowCritTy::Workspace => "Workspace", + WindowCritTy::ContentTypes => "Content Types", + } + } +} + +impl CritImpl for WindowClit { + type Type = WindowCritTy; + type Target = ToplevelData; + + fn ty(&self) -> Self::Type { + macro_rules! map { + ($($n:ident,)*) => { + match self { + $( + Self::$n { .. } => WindowCritTy::$n, + )* + } + }; + } + map! { + Client, + Title, + AppId, + Floating, + Visible, + Urgent, + Fullscreen, + Tag, + XClass, + XInstance, + XRole, + Workspace, + ContentTypes, + } + } + + fn from_ty(ty: Self::Type) -> Self { + match ty { + WindowCritTy::Client => Self::Client(Default::default()), + WindowCritTy::Title => Self::Title(Default::default()), + WindowCritTy::AppId => Self::AppId(Default::default()), + WindowCritTy::Floating => Self::Floating, + WindowCritTy::Visible => Self::Visible, + WindowCritTy::Urgent => Self::Urgent, + WindowCritTy::Fullscreen => Self::Fullscreen, + WindowCritTy::Tag => Self::Tag(Default::default()), + WindowCritTy::XClass => Self::XClass(Default::default()), + WindowCritTy::XInstance => Self::XInstance(Default::default()), + WindowCritTy::XRole => Self::XRole(Default::default()), + WindowCritTy::Workspace => Self::Workspace(Default::default()), + WindowCritTy::ContentTypes => { + Self::ContentTypes(PHOTO_CONTENT | VIDEO_CONTENT | GAME_CONTENT) + } + } + } + + fn show(&mut self, ui: &mut Ui) -> bool { + match self { + WindowClit::Client(v) => v.show(ui), + WindowClit::Title(v) => v.show(ui), + WindowClit::AppId(v) => v.show(ui), + WindowClit::Floating => false, + WindowClit::Visible => false, + WindowClit::Urgent => false, + WindowClit::Fullscreen => false, + WindowClit::Tag(v) => v.show(ui), + WindowClit::XClass(v) => v.show(ui), + WindowClit::XInstance(v) => v.show(ui), + WindowClit::XRole(v) => v.show(ui), + WindowClit::Workspace(v) => v.show(ui), + WindowClit::ContentTypes(v) => show_content_types(ui, v), + } + } + + fn to_crit(&self, state: &Rc) -> Option>> { + let m = &state.tl_matcher_manager; + let res = match self { + WindowClit::Client(v) => m.client(state, &v.to_crit(state)?), + WindowClit::Title(v) => m.title(v.to_crit()?), + WindowClit::AppId(v) => m.app_id(v.to_crit()?), + WindowClit::Floating => m.floating(), + WindowClit::Visible => m.visible(), + WindowClit::Urgent => m.urgent(), + WindowClit::Fullscreen => m.fullscreen(), + WindowClit::Tag(v) => m.tag(v.to_crit()?), + WindowClit::XClass(v) => m.class(v.to_crit()?), + WindowClit::XInstance(v) => m.instance(v.to_crit()?), + WindowClit::XRole(v) => m.role(v.to_crit()?), + WindowClit::Workspace(v) => m.workspace(v.to_crit()?), + WindowClit::ContentTypes(v) => m.content_type(*v), + }; + Some(res) + } + + fn not( + state: &State, + upstream: &Rc>, + ) -> Rc> { + state.tl_matcher_manager.not(upstream) + } + + fn list( + state: &State, + upstream: &[Rc>], + all: bool, + ) -> Rc> { + state.tl_matcher_manager.list(upstream, all) + } + + fn exactly( + state: &State, + n: usize, + upstream: &[Rc>], + ) -> Rc> { + state.tl_matcher_manager.exactly(upstream, n) + } +} + +pub struct WindowSearchPane { + state: Rc, + criterion: CcCriterion, + matched: Rc, + leaf: Option>>, +} + +struct Matched { + slf: Weak, + windows: CopyHashMap, +} + +impl Matched { + fn request_frame(&self) { + if let Some(slf) = self.slf.upgrade() { + slf.window.request_redraw(); + } + } +} + +impl ControlCenterInner { + pub fn create_window_search_pane(self: &Rc) -> WindowSearchPane { + let mut pane = WindowSearchPane { + state: self.state.clone(), + criterion: Default::default(), + matched: Rc::new(Matched { + slf: Rc::downgrade(self), + windows: Default::default(), + }), + leaf: Default::default(), + }; + pane.update_matcher(); + pane + } +} + +impl WindowSearchPane { + pub fn title(&self, res: &mut String) { + res.push_str("Window Search"); + } + + pub fn show(&mut self, behavior: &mut CcBehavior<'_>, ui: &mut Ui) { + let mut clear = false; + if self.criterion.show(ui) { + clear = self.update_matcher(); + } + ui.separator(); + let mut windows: Vec<_> = self.matched.windows.lock().keys().copied().collect(); + windows.sort(); + for id in windows { + let Some(window) = self.state.toplevels.get(&id).and_then(|v| v.upgrade()) else { + continue; + }; + show_window_collapsible(behavior, ui, &window); + } + if clear { + self.matched.windows.clear(); + } + } + + fn update_matcher(&mut self) -> bool { + let mut clear = false; + let state = &self.state; + if let Some(new) = self.criterion.to_crit(state) { + clear = true; + let matched = self.matched.clone(); + let leaf = state.tl_matcher_manager.leaf(&new, move |data| { + matched.windows.set(data, ()); + matched.request_frame(); + Box::new({ + let matched = matched.clone(); + move || { + matched.windows.remove(&data); + matched.request_frame(); + } + }) + }); + state.tl_matcher_manager.rematch_all(state); + if self.criterion.any(|c| matches!(c, WindowClit::Client(_))) { + state.cl_matcher_manager.rematch_all(state); + } + self.leaf = Some(leaf); + } + clear + } +} + +pub struct WindowPane { + window: Rc, +} + +impl ControlCenterInner { + pub fn create_window_pane(self: &Rc, window: &Rc) -> WindowPane { + WindowPane { + window: window.clone(), + } + } +} + +impl WindowPane { + pub fn title(&self, res: &mut String) { + res.push_str("Window"); + } + + pub fn show(&mut self, behavior: &mut CcBehavior<'_>, ui: &mut Ui) { + show_window(behavior, ui, &*self.window) + } +} + +pub fn show_window_collapsible( + behavior: &mut CcBehavior, + ui: &mut Ui, + window: &Rc, +) { + let data = window.tl_data(); + let mut layout_job = LayoutJob::default(); + layout_job.append( + "Window", + 0.0, + TextFormat { + color: ui.style().visuals.widgets.inactive.text_color(), + ..Default::default() + }, + ); + layout_job.append( + &data.title.borrow(), + 10.0, + TextFormat { + color: ui.style().visuals.widgets.active.text_color(), + ..Default::default() + }, + ); + let closed = CollapsingHeader::new(layout_job) + .id_salt(("window", data.identifier.get())) + .show(ui, |ui| { + if icon_label(ICON_OPEN_IN_NEW) + .sense(Sense::CLICK) + .ui(ui) + .clicked() + { + behavior.open = Some(PaneType::Window(behavior.cc.create_window_pane(window))); + } + show_window(behavior, ui, &**window) + }) + .fully_closed(); + if closed { + ensure_listener(ui, behavior, data); + } +} + +pub fn show_window(behavior: &mut CcBehavior<'_>, ui: &mut Ui, window: &dyn ToplevelNode) { + let data = window.tl_data(); + ensure_listener(ui, behavior, data); + grid(ui, ("window", data.identifier.get()), |ui| { + label(ui, "ID", &*data.identifier.get().to_string()); + label(ui, "Title", &*data.title.borrow()); + if let Some(w) = data.workspace.get() { + label(ui, "Workspace", &w.name); + } + match &data.kind { + ToplevelType::Container => { + label(ui, "Type", "Container"); + } + ToplevelType::Placeholder(_) => { + label(ui, "Type", "Placeholder"); + } + ToplevelType::XdgToplevel(t) => { + label(ui, "Type", "xdg_toplevel"); + let tag = &*t.tag.borrow(); + if tag.is_not_empty() { + label(ui, "Tag", tag); + } + } + ToplevelType::XWindow(t) => { + label(ui, "Type", "X Window"); + if let Some(class) = &*t.info.class.borrow() { + label(ui, "Class", class); + } + if let Some(instance) = &*t.info.instance.borrow() { + label(ui, "Instance", instance); + } + if let Some(role) = &*t.info.role.borrow() { + label(ui, "Role", role); + } + } + } + let app_id = &*data.app_id.borrow(); + if app_id.is_not_empty() { + label(ui, "App ID", app_id); + } + read_only_bool(ui, "Floating", data.parent_is_float.get()); + read_only_bool(ui, "Visible", data.visible.get()); + read_only_bool(ui, "Urgent", data.wants_attention.get()); + read_only_bool(ui, "Fullscreen", data.is_fullscreen.get()); + if let Some(ct) = data.content_type.get() { + label(ui, "Content Type", ct.text()); + } + }); + if let Some(client) = &data.client { + show_client_collapsible(behavior, ui, client); + } +} + +fn ensure_listener(ui: &mut Ui, behavior: &CcBehavior<'_>, data: &ToplevelData) { + ui.memory_mut(|m| { + m.caches + .cache::() + .ensure(behavior.cc, data); + }); +} + +#[derive(Default)] +struct WindowPropertyListeners { + generation: u64, + listeners: AHashMap, +} + +struct WindowPropertyListener { + _listener: EventListener, + generation: u64, +} + +impl WindowPropertyListeners { + fn ensure(&mut self, cc: &Rc, data: &ToplevelData) { + let listener = self.listeners.entry(data.node_id).or_insert_with(|| { + let listener = + EventListener::new(Rc::downgrade(cc) as Weak); + listener.attach(data.property_changed_source()); + WindowPropertyListener { + _listener: listener, + generation: 0, + } + }); + listener.generation = self.generation; + } +} + +unsafe impl Sync for WindowPropertyListeners {} +unsafe impl Send for WindowPropertyListeners {} + +impl CacheTrait for WindowPropertyListeners { + fn update(&mut self) { + self.listeners + .retain(|_, m| m.generation == self.generation); + self.generation += 1; + } + + fn len(&self) -> usize { + self.listeners.len() + } + + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } +} + +fn show_content_types(ui: &mut Ui, ct: &mut ContentType) -> bool { + let mut v = *ct; + let mut photo = (v & PHOTO_CONTENT).0 != 0; + let mut video = (v & VIDEO_CONTENT).0 != 0; + let mut game = (v & GAME_CONTENT).0 != 0; + ui.checkbox(&mut photo, "Photo"); + ui.checkbox(&mut video, "Video"); + ui.checkbox(&mut game, "Game"); + v = NO_CONTENT_TYPE; + if photo { + v |= PHOTO_CONTENT; + } + if video { + v |= VIDEO_CONTENT; + } + if game { + v |= GAME_CONTENT; + } + mem::replace(ct, v) != v +} diff --git a/src/criteria/clm.rs b/src/criteria/clm.rs index f9e1c595..7a3175cb 100644 --- a/src/criteria/clm.rs +++ b/src/criteria/clm.rs @@ -234,7 +234,6 @@ impl ClMatcherManager { self.root(ClmMatchTag::new(string)) } - #[expect(dead_code)] pub fn id(&self, id: ClientId) -> Rc { self.root(ClmMatchId(id)) } diff --git a/src/tree/toplevel.rs b/src/tree/toplevel.rs index fdf4f1b0..ec609e37 100644 --- a/src/tree/toplevel.rs +++ b/src/tree/toplevel.rs @@ -936,7 +936,6 @@ impl ToplevelData { parent.node_is_workspace() } - #[expect(dead_code)] pub fn property_changed_source(&self) -> &Rc { self.property_changed_source .get_or_init(|| self.state.lazy_event_sources.create_source()) diff --git a/src/utils/opaque.rs b/src/utils/opaque.rs index 5e54001c..209e77a9 100644 --- a/src/utils/opaque.rs +++ b/src/utils/opaque.rs @@ -10,7 +10,7 @@ use { thiserror::Error, }; -#[derive(Copy, Clone, Eq, PartialEq, Hash)] +#[derive(Copy, Clone, Eq, PartialEq, Hash, Ord, PartialOrd)] pub struct Opaque { lo: u64, hi: u64, diff --git a/src/utils/toplevel_identifier.rs b/src/utils/toplevel_identifier.rs index 09d894ee..e9b1f30b 100644 --- a/src/utils/toplevel_identifier.rs +++ b/src/utils/toplevel_identifier.rs @@ -10,7 +10,7 @@ use { }, }; -#[derive(Debug, Eq, PartialEq, Copy, Clone, Hash)] +#[derive(Debug, Eq, PartialEq, Copy, Clone, Hash, Ord, PartialOrd)] pub struct ToplevelIdentifier(Opaque); unsafe impl UnsafeCellCloneSafe for ToplevelIdentifier {}