From 7976e3ad3716cdbcbee052c2b019fe67accab76b Mon Sep 17 00:00:00 2001 From: Supreeeme Date: Sat, 11 May 2024 00:37:11 -0400 Subject: [PATCH] Add initial support for toplevel titles and app ids Should work with most app titles, but for some reason some app ids have the first letter capitalized (Remmina) and some windows don't get the class/title set at all (xterm) Part of #9 --- satellite/src/server/mod.rs | 81 +++++++++++-- satellite/src/server/tests.rs | 89 +++++++++++++- satellite/src/xstate.rs | 208 +++++++++++++++++++-------------- satellite/tests/integration.rs | 39 ++++++- testwl/src/lib.rs | 19 +++ 5 files changed, 337 insertions(+), 99 deletions(-) diff --git a/satellite/src/server/mod.rs b/satellite/src/server/mod.rs index e0fa963..5b2d8df 100644 --- a/satellite/src/server/mod.rs +++ b/satellite/src/server/mod.rs @@ -7,7 +7,7 @@ mod tests; use self::event::*; use super::FromServerState; use crate::clientside::*; -use crate::xstate::{Atoms, WindowDims, WmNormalHints}; +use crate::xstate::{Atoms, WindowDims, WmHints, WmName, WmNormalHints}; use crate::XConnection; use log::{debug, warn}; use rustix::event::{poll, PollFd, PollFlags}; @@ -76,8 +76,11 @@ struct WindowData { surface_id: u32, popup_for: Option, dims: WindowDims, - hints: Option, + size_hints: Option, override_redirect: bool, + title: Option, + class: Option, + group: Option, } impl WindowData { @@ -94,8 +97,11 @@ impl WindowData { popup_for: parent, surface_id: 0, dims, - hints: None, + size_hints: None, override_redirect, + title: None, + class: None, + group: None } } } @@ -482,10 +488,54 @@ impl ServerState { ); } - pub fn set_win_hints(&mut self, window: x::Window, hints: WmNormalHints) { + pub fn set_win_title(&mut self, window: x::Window, name: WmName) { let win = self.windows.get_mut(&window).unwrap(); - if win.hints.is_none() || *win.hints.as_ref().unwrap() != hints { + let new_title = match &mut win.title { + Some(w) => { + if matches!(w, WmName::NetWmName(_)) && matches!(name, WmName::WmName(_)) { + debug!("skipping setting window name to {name:?} because a _NET_WM_NAME title is already set"); + None + } else { + *w = name; + Some(w) + } + } + None => Some(win.title.insert(name)), + }; + + let Some(title) = new_title else { + return; + }; + if let Some(key) = win.surface_key { + let surface: &SurfaceData = self.objects[key].as_ref(); + if let Some(SurfaceRole::Toplevel(Some(data))) = &surface.role { + data.toplevel.set_title(title.name().to_string()); + } + } + } + + pub fn set_win_class(&mut self, window: x::Window, class: String) { + let win = self.windows.get_mut(&window).unwrap(); + + let class = win.class.insert(class); + if let Some(key) = win.surface_key { + let surface: &SurfaceData = self.objects[key].as_ref(); + if let Some(SurfaceRole::Toplevel(Some(data))) = &surface.role { + data.toplevel.set_app_id(class.to_string()); + } + } + } + + pub fn set_win_hints(&mut self, window: x::Window, hints: WmHints) { + let win = self.windows.get_mut(&window).unwrap(); + win.group = hints.window_group; + } + + pub fn set_size_hints(&mut self, window: x::Window, hints: WmNormalHints) { + let win = self.windows.get_mut(&window).unwrap(); + + if win.size_hints.is_none() || *win.size_hints.as_ref().unwrap() != hints { debug!("setting {window:?} hints {hints:?}"); if let Some(surface) = win.surface_key { let surface: &SurfaceData = self.objects[surface].as_ref(); @@ -498,7 +548,7 @@ impl ServerState { } } } - win.hints = Some(hints); + win.size_hints = Some(hints); } } @@ -720,8 +770,9 @@ impl ServerState { xdg: XdgSurface, ) -> ToplevelData { debug!("creating toplevel for {:?}", window.window); + let toplevel = xdg.get_toplevel(&self.qh, surface_key); - if let Some(hints) = &window.hints { + if let Some(hints) = &window.size_hints { if let Some(min) = &hints.min_size { toplevel.set_min_size(min.width, min.height); } @@ -730,6 +781,22 @@ impl ServerState { } } + let group = window.group.and_then(|win| self.windows.get(&win)); + if let Some(class) = window + .class + .as_ref() + .or(group.and_then(|g| g.class.as_ref())) + { + toplevel.set_app_id(class.to_string()); + } + if let Some(title) = window + .title + .as_ref() + .or(group.and_then(|g| g.title.as_ref())) + { + toplevel.set_title(title.name().to_string()); + } + ToplevelData { xdg: XdgSurfaceData { surface: xdg, diff --git a/satellite/src/server/tests.rs b/satellite/src/server/tests.rs index 7970777..e9c4612 100644 --- a/satellite/src/server/tests.rs +++ b/satellite/src/server/tests.rs @@ -1,5 +1,5 @@ use super::{ServerState, WindowDims}; -use crate::xstate::SetState; +use crate::xstate::{SetState, WmName}; use paste::paste; use rustix::event::{poll, PollFd, PollFlags}; use std::collections::HashMap; @@ -354,7 +354,7 @@ impl TestFixture { width: 50, height: 50, }, - fullscreen: false + fullscreen: false, }; let dims = data.dims; @@ -393,7 +393,7 @@ impl TestFixture { width: 50, height: 50, }, - fullscreen: false + fullscreen: false, }; let dims = data.dims; @@ -468,7 +468,7 @@ impl TestFixture { width: 50, height: 50, }, - fullscreen: false + fullscreen: false, }; let dims = data.dims; self.register_window(window, data); @@ -861,6 +861,87 @@ fn fullscreen() { .contains(&xdg_toplevel::State::Fullscreen)); } +#[test] +fn window_title_and_class() { + let (mut f, comp) = TestFixture::new_with_compositor(); + let win = unsafe { Window::new(1) }; + let (_, id) = f.create_toplevel(&comp, win); + + f.exwayland + .set_win_title(win, WmName::WmName("window".into())); + f.exwayland.set_win_class(win, "class".into()); + f.run(); + + let data = f.testwl.get_surface_data(id).unwrap(); + assert_eq!(data.toplevel().title, Some("window".into())); + assert_eq!(data.toplevel().app_id, Some("class".into())); + + f.exwayland + .set_win_title(win, WmName::NetWmName("superwindow".into())); + f.run(); + + let data = f.testwl.get_surface_data(id).unwrap(); + assert_eq!(data.toplevel().title, Some("superwindow".into())); + + f.exwayland + .set_win_title(win, WmName::WmName("shwindow".into())); + f.run(); + + let data = f.testwl.get_surface_data(id).unwrap(); + assert_eq!(data.toplevel().title, Some("superwindow".into())); +} + +#[test] +fn window_group_properties() { + let (mut f, comp) = TestFixture::new_with_compositor(); + let prop_win = unsafe { Window::new(1) }; + f.exwayland.new_window( + prop_win, + false, + super::WindowDims { + width: 1, + height: 1, + ..Default::default() + }, + None, + ); + f.exwayland + .set_win_title(prop_win, WmName::WmName("window".into())); + f.exwayland.set_win_class(prop_win, "class".into()); + + let win = unsafe { Window::new(2) }; + let data = WindowData { + mapped: true, + dims: WindowDims { + width: 50, + height: 50, + ..Default::default() + }, + fullscreen: false, + }; + + let (_, surface) = comp.create_surface(); + let dims = data.dims; + f.register_window(win, data); + f.exwayland.new_window(win, false, dims, None); + f.exwayland.set_win_hints( + win, + super::WmHints { + window_group: Some(prop_win), + ..Default::default() + }, + ); + f.exwayland.map_window(win); + f.exwayland + .associate_window(win, surface.id().protocol_id()); + f.run(); + + let id = f.testwl.last_created_surface_id().unwrap(); + let data = f.testwl.get_surface_data(id).unwrap(); + + assert_eq!(data.toplevel().title, Some("window".into())); + assert_eq!(data.toplevel().app_id, Some("class".into())); +} /// See Pointer::handle_event for an explanation. #[test] fn popup_pointer_motion_workaround() {} diff --git a/satellite/src/xstate.rs b/satellite/src/xstate.rs index 86003e6..6af4a30 100644 --- a/satellite/src/xstate.rs +++ b/satellite/src/xstate.rs @@ -1,5 +1,6 @@ use bitflags::bitflags; use log::{debug, trace, warn}; +use std::ffi::CString; use std::os::fd::{AsRawFd, BorrowedFd}; use std::sync::Arc; use xcb::{x, Xid, XidNew}; @@ -9,7 +10,21 @@ pub struct XState { pub connection: Arc, root: x::Window, pub atoms: Atoms, - window_types: WindowTypes, +} + +#[derive(Debug)] +pub enum WmName { + WmName(String), + NetWmName(String), +} + +impl WmName { + pub fn name(&self) -> &str { + match self { + Self::WmName(n) => n, + Self::NetWmName(n) => n, + } + } } impl XState { @@ -32,7 +47,6 @@ impl XState { let atoms = Atoms::intern_all(&connection).unwrap(); trace!("atoms: {atoms:#?}"); - let window_types = WindowTypes::new(&connection); // This makes Xwayland spit out damage tracking connection @@ -56,7 +70,6 @@ impl XState { connection, root, atoms, - window_types, }; r.create_ewmh_window(); r @@ -97,7 +110,7 @@ impl XState { self.set_root_property( self.atoms.supported, x::ATOM_ATOM, - &[self.atoms.active_win, self.atoms.client_list], + &[self.atoms.active_win], ); self.connection @@ -114,7 +127,7 @@ impl XState { .send_and_check_request(&x::ChangeProperty { mode: x::PropMode::Replace, window, - property: self.atoms.wm_name, + property: self.atoms.net_wm_name, r#type: x::ATOM_STRING, data: b"exwayland wm", }) @@ -244,10 +257,16 @@ impl XState { event: x::PropertyNotifyEvent, server_state: &mut super::RealServerState, ) { + if event.state() != x::Property::NewValue { + println!("ignoring non newvalue for property {:?}", event.atom()); + return; + } + + let window = event.window(); let get_prop = |r#type, long_length| { self.connection .wait_for_reply(self.connection.send_request(&x::GetProperty { - window: event.window(), + window, property: event.atom(), r#type, long_offset: 0, @@ -255,23 +274,14 @@ impl XState { delete: false, })) }; - if event.state() != x::Property::NewValue { - return; - } match event.atom() { - x if x == self.atoms.wm_window_type => { - let Ok(prop) = get_prop(x::ATOM_ATOM, 8) else { - return; - }; - let types: &[x::Atom] = prop.value(); - let win_type = types.iter().find_map(|a| self.window_types.get_type(*a)); - debug!( - "set {:?} type to {} ({})", - event.window(), - win_type.unwrap_or("[Unknown/Unrecognized]".to_string()), - types.len() - ); + x if x == self.atoms.wm_hints => { + let prop = get_prop(self.atoms.wm_hints, 9).unwrap(); + let data: &[u32] = prop.value(); + let hints = WmHints::from(data); + debug!("wm hints: {hints:?}"); + server_state.set_win_hints(event.window(), hints); } x if x == x::ATOM_WM_NORMAL_HINTS => { let Ok(prop) = get_prop(x::ATOM_WM_SIZE_HINTS, 9) else { @@ -279,22 +289,47 @@ impl XState { }; let data: &[u32] = prop.value(); let hints = WmNormalHints::from(data); - server_state.set_win_hints(event.window(), hints); + server_state.set_size_hints(window, hints); + } + x if x == x::ATOM_WM_NAME || x == self.atoms.net_wm_name => { + let ty = if x == x::ATOM_WM_NAME { + x::ATOM_STRING + } else { + self.atoms.utf8_string + }; + let prop = get_prop(ty, 256).unwrap(); + let data: &[u8] = prop.value(); + let name = String::from_utf8(data.to_vec()).unwrap(); + debug!("{:?} named: {name}", window); + let name = if x == x::ATOM_WM_NAME { + WmName::WmName(name) + } else { + WmName::NetWmName(name) + }; + server_state.set_win_title(window, name); + } + x if x == x::ATOM_WM_CLASS => { + let prop = get_prop(x::ATOM_STRING, 256).unwrap(); + let data: &[u8] = prop.value(); + // wm class is instance + class - ignore instance + let class_start = data.iter().copied().position(|b| b == 0u8).unwrap() + 1; + let data = data[class_start..].to_vec(); + let class = CString::from_vec_with_nul(data).unwrap(); + debug!("{:?} class: {class:?}", window); + server_state.set_win_class(window, class.to_string_lossy().to_string()); } _ => { - let prop = self - .connection - .wait_for_reply( - self.connection - .send_request(&x::GetAtomName { atom: event.atom() }), - ) - .unwrap(); + if log::log_enabled!(log::Level::Debug) { + let prop = self + .connection + .wait_for_reply( + self.connection + .send_request(&x::GetAtomName { atom: event.atom() }), + ) + .unwrap(); - debug!( - "changed property {:?} for {:?}", - prop.name(), - event.window() - ); + debug!("changed property {:?} for {:?}", prop.name(), window); + } } } } @@ -309,14 +344,14 @@ xcb::atoms_struct! { pub wm_transient_for => b"WM_TRANSIENT_FOR" only_if_exists = false, pub wm_hints => b"WM_HINTS" only_if_exists = false, pub wm_check => b"_NET_SUPPORTING_WM_CHECK" only_if_exists = false, - pub wm_name => b"_NET_WM_NAME" only_if_exists = false, - pub wm_window_type => b"_NET_WM_WINDOW_TYPE" only_if_exists = false, + pub net_wm_name => b"_NET_WM_NAME" only_if_exists = false, pub wm_pid => b"_NET_WM_PID" only_if_exists = false, pub net_wm_state => b"_NET_WM_STATE" only_if_exists = false, pub wm_fullscreen => b"_NET_WM_STATE_FULLSCREEN" only_if_exists = false, pub active_win => b"_NET_ACTIVE_WINDOW" only_if_exists = false, pub client_list => b"_NET_CLIENT_LIST" only_if_exists = false, pub supported => b"_NET_SUPPORTED" only_if_exists = false, + pub utf8_string => b"UTF8_STRING" only_if_exists = false, } } @@ -330,25 +365,6 @@ xcb::atoms_struct! { } } -impl WindowTypes { - pub fn new(connection: &xcb::Connection) -> Self { - let r = Self::intern_all(connection).unwrap(); - assert_ne!(r.normal, x::ATOM_NONE); - assert_ne!(r.dialog, x::ATOM_NONE); - assert_ne!(r.utility, x::ATOM_NONE); - r - } - pub fn get_type(&self, atom: x::Atom) -> Option { - match atom { - x if x == self.normal => Some("Normal".to_string()), - x if x == self.dialog => Some("Dialog".to_string()), - x if x == self.utility => Some("Utility".to_string()), - x if x == self.menu => Some("Menu".to_string()), - _ => None, - } - } -} - #[derive(Clone, Copy, Debug, Default)] pub struct WindowDims { pub x: i16, @@ -361,16 +377,16 @@ bitflags! { /// From ICCCM spec. /// https://tronche.com/gui/x/icccm/sec-4.html#s-4.1.2.3 pub struct WmSizeHintsFlags: u32 { - const UserPosition = 1; - const UserSize = 2; - const ProgramPosition = 4; - const ProgramSize = 8; const ProgramMinSize = 16; const ProgramMaxSize = 32; - const ProgramResizeIncrement = 64; - const ProgramAspect = 128; - const ProgramBaseSize = 256; - const ProgramWinGravity = 512; + } +} + +bitflags! { + /// https://tronche.com/gui/x/icccm/sec-4.html#s-4.1.2.4 + pub struct WmHintsFlags: u32 { + const Input = 1; + const WindowGroup = 64; } } @@ -386,6 +402,49 @@ pub struct WmNormalHints { pub max_size: Option, } +impl From<&[u32]> for WmNormalHints { + fn from(value: &[u32]) -> Self { + let mut ret = Self::default(); + let flags = WmSizeHintsFlags::from_bits_truncate(value[0]); + + if flags.contains(WmSizeHintsFlags::ProgramMinSize) { + ret.min_size = Some(WinSize { + width: value[5] as _, + height: value[6] as _, + }); + } + + if flags.contains(WmSizeHintsFlags::ProgramMaxSize) { + ret.max_size = Some(WinSize { + width: value[7] as _, + height: value[8] as _, + }); + } + + ret + } +} + +#[derive(Default, Debug, PartialEq, Eq)] +pub struct WmHints { + pub input: Option, + pub window_group: Option, +} + +impl From<&[u32]> for WmHints { + fn from(value: &[u32]) -> Self { + let mut ret = Self::default(); + let flags = WmHintsFlags::from_bits_truncate(value[0]); + + if flags.contains(WmHintsFlags::WindowGroup) { + let window = unsafe { x::Window::new(value[8]) }; + ret.window_group = Some(window); + } + + ret + } +} + #[derive(Debug, Clone, Copy)] pub enum SetState { Remove, @@ -405,29 +464,6 @@ impl TryFrom for SetState { } } -impl From<&[u32]> for WmNormalHints { - fn from(value: &[u32]) -> Self { - let mut ret = Self::default(); - let flags = WmSizeHintsFlags::from_bits(value[0]).unwrap(); - - if flags.contains(WmSizeHintsFlags::ProgramMinSize) { - ret.min_size = Some(WinSize { - width: value[5] as _, - height: value[6] as _, - }); - } - - if flags.contains(WmSizeHintsFlags::ProgramMaxSize) { - ret.max_size = Some(WinSize { - width: value[7] as _, - height: value[8] as _, - }); - } - - ret - } -} - impl super::XConnection for Arc { type ExtraData = Atoms; diff --git a/satellite/tests/integration.rs b/satellite/tests/integration.rs index aa30407..6f2cdd7 100644 --- a/satellite/tests/integration.rs +++ b/satellite/tests/integration.rs @@ -30,6 +30,8 @@ xcb::atoms_struct! { struct Atoms { wm_protocols => b"WM_PROTOCOLS", wm_delete_window => b"WM_DELETE_WINDOW", + wm_class => b"WM_CLASS", + wm_name => b"WM_NAME", } } @@ -108,7 +110,7 @@ impl Fixture { } } - fn create_window( + fn create_and_map_window( &mut self, connection: &xcb::Connection, override_redirect: bool, @@ -155,7 +157,7 @@ impl Fixture { width: u16, height: u16, ) -> (x::Window, testwl::SurfaceId) { - let (window, surface) = self.create_window(connection, false, 0, 0, width, height); + let (window, surface) = self.create_and_map_window(connection, false, 0, 0, width, height); let data = self .testwl .get_surface_data(surface) @@ -258,6 +260,39 @@ fn toplevel_flow() { let mut f = Fixture::new(); let mut connection = Connection::new(); let (window, surface) = f.create_toplevel(&connection.inner, 200, 200); + + connection + .inner + .send_and_check_request(&x::ChangeProperty { + mode: x::PropMode::Replace, + window, + r#type: x::ATOM_STRING, + property: connection.atoms.wm_name, + data: c"window".to_bytes(), + }) + .unwrap(); + + connection + .inner + .send_and_check_request(&x::ChangeProperty { + mode: x::PropMode::Replace, + window, + r#type: x::ATOM_STRING, + property: connection.atoms.wm_class, + data: &[ + c"instance".to_bytes_with_nul(), + c"class".to_bytes_with_nul(), + ] + .concat(), + }) + .unwrap(); + + f.wait_and_dispatch(); + + let data = f.testwl.get_surface_data(surface).unwrap(); + assert_eq!(data.toplevel().title, Some("window".into())); + assert_eq!(data.toplevel().app_id, Some("class".into())); + f.close_toplevel(&mut connection, window, surface); // Simulate killing client diff --git a/testwl/src/lib.rs b/testwl/src/lib.rs index bc5da9a..14aaa6d 100644 --- a/testwl/src/lib.rs +++ b/testwl/src/lib.rs @@ -91,6 +91,8 @@ pub struct Toplevel { pub max_size: Option, pub states: Vec, pub closed: bool, + pub title: Option, + pub app_id: Option } #[derive(Debug, PartialEq, Eq)] @@ -486,6 +488,21 @@ impl Dispatch for State { state.configure_toplevel(*surface_id, 100, 100, states); } xdg_toplevel::Request::Destroy => {} + xdg_toplevel::Request::SetTitle { title } => { + let data = state.surfaces.get_mut(surface_id).unwrap(); + let Some(SurfaceRole::Toplevel(toplevel)) = &mut data.role else { + unreachable!(); + }; + toplevel.title = title.into(); + } + xdg_toplevel::Request::SetAppId { app_id } => { + let data = state.surfaces.get_mut(surface_id).unwrap(); + let Some(SurfaceRole::Toplevel(toplevel)) = &mut data.role else { + unreachable!(); + }; + toplevel.app_id = app_id.into(); + + } other => todo!("unhandled request {other:?}"), } } @@ -513,6 +530,8 @@ impl Dispatch for State { max_size: None, states: Vec::new(), closed: false, + title: None, + app_id: None }; let data = state.surfaces.get_mut(surface_id).unwrap(); data.role = Some(SurfaceRole::Toplevel(t));