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));