srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/crates/x11/src/lib.rs
diff options
context:
space:
mode:
Diffstat (limited to 'crates/x11/src/lib.rs')
-rw-r--r--crates/x11/src/lib.rs263
1 files changed, 241 insertions, 22 deletions
diff --git a/crates/x11/src/lib.rs b/crates/x11/src/lib.rs
index d025efa..ad52daa 100644
--- a/crates/x11/src/lib.rs
+++ b/crates/x11/src/lib.rs
@@ -29,6 +29,7 @@ use srdwm_core::{Monitor, Rect, WindowManager};
use srdwm_platform::{Platform, PlatformError, PlatformKind, Result as PlatformResult};
use std::cell::RefCell;
use std::collections::HashMap;
+use std::os::unix::io::AsRawFd;
use std::rc::Rc;
use x11rb::connection::Connection;
use x11rb::protocol::randr::ConnectionExt as _;
@@ -67,6 +68,35 @@ fn err(e: impl std::fmt::Display) -> PlatformError {
PlatformError::Other(e.to_string())
}
+/// Finds which of `ModMask::M1`..`M5` a keycode is bound to, given a
+/// `GetModifierMappingReply`'s flattened `keycodes` list (8 fixed slots --
+/// Shift, Lock, Control, Mod1..Mod5 - each `keycodes_per_modifier` long,
+/// zero-padded). Only scans the Mod1..Mod5 slots (indices 3..8): Shift/
+/// Lock/Control are never where Num Lock lands in practice, and this is
+/// only ever called looking for it. Returns an empty mask if the keycode
+/// isn't bound to any modifier at all (a keyboard with no Num Lock key, or
+/// a keycode of `0` from a lookup that found nothing).
+fn modmask_for_keycode_in_mod_slots(keycode: u8, keycodes_per_modifier: usize, keycodes: &[u8]) -> ModMask {
+ if keycode == 0 || keycodes_per_modifier == 0 {
+ return ModMask::from(0u16);
+ }
+ (3..8usize)
+ .find(|&slot| {
+ let start = slot * keycodes_per_modifier;
+ keycodes.get(start..start + keycodes_per_modifier).is_some_and(|ks| ks.contains(&keycode))
+ })
+ .map(|slot| ModMask::from(1u16 << slot))
+ .unwrap_or(ModMask::from(0u16))
+}
+
+/// Packs an RGB triple into the `0x00RRGGBB` pixel value X11's
+/// `border_pixel`/GC `foreground` etc. expect on a TrueColor visual --
+/// matching the format the hardcoded titlebar colour constants in
+/// `redraw_decoration` already use.
+fn rgb_to_pixel((r, g, b): (u8, u8, u8)) -> u32 {
+ ((r as u32) << 16) | ((g as u32) << 8) | (b as u32)
+}
+
pub struct X11Platform {
conn: RustConnection,
root: XWindow,
@@ -80,6 +110,17 @@ pub struct X11Platform {
max_keycode: u8,
keysyms_per_keycode: u8,
keyboard_mapping: Vec<u32>,
+ /// Whichever of `ModMask::M1`..`M5` the server has Num Lock bound to --
+ /// see `grab_keybindings`'s doc comment for why this needs grabbing
+ /// alongside every binding, not just the modifiers a config actually
+ /// asked for.
+ numlock_mask: ModMask,
+ /// `srd`'s control socket - see `srdwm_platform::IpcServer`'s module
+ /// doc comment. `None` if binding it failed (a stale socket from a
+ /// still-running instance, an unwritable runtime dir): the compositor
+ /// itself still starts either way, matching how the Wayland backends
+ /// already treat this as non-fatal.
+ ipc: Option<srdwm_platform::IpcServer>,
}
impl X11Platform {
@@ -121,8 +162,47 @@ impl X11Platform {
let keysyms_per_keycode = mapping.keysyms_per_keycode;
let keyboard_mapping = mapping.keysyms;
+ // Num Lock's modifier bit is not fixed by the X11 spec (unlike Caps
+ // Lock, which is always `ModMask::LOCK`) - it's whichever of
+ // Mod1..Mod5 the server happens to have bound it to, keyboard- and
+ // OS-dependent. Found the same way every other X11 WM does: look up
+ // Num Lock's keycode (keysym `0xff7f`, XK_Num_Lock) in the keyboard
+ // mapping just queried above, then find which modifier slot's
+ // keycode list contains it. See `grab_keybindings`'s doc comment
+ // for why this is needed at all.
+ let numlock_mask = {
+ const XK_NUM_LOCK: u32 = 0xff7f;
+ let numlock_keycode = (min_keycode..=max_keycode).find(|&kc| {
+ let idx = (kc - min_keycode) as usize * keysyms_per_keycode as usize;
+ keyboard_mapping.get(idx).copied() == Some(XK_NUM_LOCK)
+ });
+ match numlock_keycode {
+ Some(kc) => {
+ let modmap = conn.get_modifier_mapping().map_err(err)?.reply().map_err(err)?;
+ let per = modmap.keycodes_per_modifier() as usize;
+ modmask_for_keycode_in_mod_slots(kc, per, &modmap.keycodes)
+ }
+ None => ModMask::from(0u16),
+ }
+ };
+
conn.flush().map_err(err)?;
+ // Same socket name convention as the Wayland backends
+ // (`srdwm-<display>.sock`) - there, `<display>` is the Wayland
+ // socket's own name; here, the only display identity X11 has is
+ // `$DISPLAY` itself (e.g. `:0`), which is exactly what every X
+ // client - including a nested Xephyr/Xnest session used for
+ // testing - already keys off to tell one server from another.
+ let display_name = std::env::var("DISPLAY").unwrap_or_else(|_| "x11".to_string());
+ let ipc = match srdwm_platform::IpcServer::bind(&display_name) {
+ Ok(ipc) => Some(ipc),
+ Err(e) => {
+ log::warn!("failed to bind srd IPC socket for display '{display_name}': {e}");
+ None
+ }
+ };
+
Ok(Self {
conn,
root,
@@ -136,6 +216,8 @@ impl X11Platform {
max_keycode,
keysyms_per_keycode,
keyboard_mapping,
+ numlock_mask,
+ ipc,
})
}
@@ -194,20 +276,26 @@ impl X11Platform {
/// Grabs the given `"Mod4+Shift+Return"`-style key combos on the root
/// window so their KeyPress events reach us even when a client has
/// input focus. Call after loading config (once bindings are known).
+ ///
+ /// A `KeyPress`'s modifier state includes whichever lock modifiers
+ /// happen to be toggled on (Num Lock, Caps Lock) in addition to
+ /// whatever the binding actually asked for - `XGrabKey` matches state
+ /// *exactly*, not as a subset, so a grab registered only for e.g.
+ /// `Mod4` never fires the moment Num Lock is on, since the real event's
+ /// state is `Mod4 | numlock_mask` instead. Every real X11 WM (i3,
+ /// bspwm, dwm) grabs each binding once per combination of the lock
+ /// modifiers for exactly this reason; this one previously didn't,
+ /// which meant every keybinding silently stopped firing the instant
+ /// Num Lock was toggled on - not a missing feature, a basic X11
+ /// correctness requirement that was simply never implemented.
pub fn grab_keybindings(&mut self, combos: &[String]) -> PlatformResult<()> {
+ // The four combinations of "Num Lock toggled or not" x "Caps Lock
+ // toggled or not" - Scroll Lock is deliberately not covered here,
+ // matching the convention every WM referenced above also follows
+ // (rarely present on modern keyboards, rarely toggled when it is).
+ let lock_variants = [ModMask::from(0u16), self.numlock_mask, ModMask::LOCK, self.numlock_mask | ModMask::LOCK];
for combo in combos {
- let parts: Vec<&str> = combo.split('+').collect();
- let Some((key_name, mod_parts)) = parts.split_last() else { continue };
- let mut modifiers = Modifiers::empty();
- for m in mod_parts {
- modifiers |= match *m {
- "Ctrl" => Modifiers::CTRL,
- "Shift" => Modifiers::SHIFT,
- "Alt" => Modifiers::ALT,
- "Mod4" | "Super" => Modifiers::SUPER,
- _ => Modifiers::empty(),
- };
- }
+ let Some((modifiers, key_name)) = srdwm_core::parse_key_combo(combo) else { continue };
let Some(keysym) = keysyms::name_to_keysym(key_name) else {
log::warn!("cannot grab '{combo}': unknown key name '{key_name}'");
continue;
@@ -217,9 +305,11 @@ impl X11Platform {
continue;
};
let mask = Self::modmask_for(modifiers);
- self.conn
- .grab_key(true, self.root, mask, keycode, GrabMode::ASYNC, GrabMode::ASYNC)
- .map_err(err)?;
+ for lock in lock_variants {
+ self.conn
+ .grab_key(true, self.root, mask | lock, keycode, GrabMode::ASYNC, GrabMode::ASYNC)
+ .map_err(err)?;
+ }
}
self.conn.flush().map_err(err)?;
Ok(())
@@ -228,12 +318,15 @@ impl X11Platform {
fn manage_new_window(&mut self, client: XWindow) -> PlatformResult<Option<Event>> {
let geom = self.conn.get_geometry(client).map_err(err)?.reply().map_err(err)?;
let title = self.window_title(client).unwrap_or_default();
+ let (instance, class) = self.window_class(client);
let supports_delete = self.supports_wm_delete(client);
let id = {
let mut wm = self.wm.borrow_mut();
let id = wm.alloc_window_id();
let mut w = CoreWindow::new(id, title);
+ w.app_id = class;
+ w.instance = instance;
w.geometry = Rect::new(geom.x as i32, geom.y as i32, geom.width as u32, geom.height as u32 + TITLEBAR_HEIGHT);
wm.add_window(w);
id
@@ -251,6 +344,18 @@ impl X11Platform {
| EventMask::EXPOSURE,
)
.background_pixel(self.conn.setup().roots[0].white_pixel);
+ // `Window.border_color`/`border_width` were tracked in
+ // `srdwm_core::Window` and settable via `srd.window.set_border_*`,
+ // but nothing ever actually drew a border with them on this
+ // backend - `set_border_color`/`set_border_width` below only
+ // updated the stored struct field. X11 windows have a native
+ // server-drawn border (`border_pixel`/the `create_window`
+ // `border-width` parameter, both unconditionally 0 here before),
+ // so this uses that rather than hand-rendering one - the X server
+ // draws it, no extra composite work needed.
+ let border_color = self.wm.borrow().window(id).map(|w| w.border_color).unwrap_or((0x31, 0x32, 0x44));
+ let border_width = self.wm.borrow().window(id).map(|w| w.border_width).unwrap_or(0);
+ let aux = aux.border_pixel(rgb_to_pixel(border_color));
self.conn
.create_window(
COPY_DEPTH_FROM_PARENT,
@@ -260,7 +365,7 @@ impl X11Platform {
placed.y as i16,
placed.width as u16,
placed.height as u16,
- 0,
+ border_width as u16,
WindowClass::INPUT_OUTPUT,
0,
&aux,
@@ -326,6 +431,28 @@ impl X11Platform {
String::from_utf8(reply.value).ok()
}
+ /// Reads `WM_CLASS` and splits it into `(instance, class)` - the
+ /// property is two NUL-terminated strings back to back, instance first
+ /// (ICCCM 4.1.2.5). Was never read at all before this: `manage_new_window`
+ /// only ever set `Window::title`, leaving `app_id` permanently empty on
+ /// every X11 window - meaning every `srd.rule({ class = ... }, ...)`
+ /// silently failed to match anything on this backend, the same root
+ /// cause `with_toplevel_app_id`'s doc comment describes already having
+ /// been found and fixed for native Wayland windows earlier. Returns
+ /// `("", "")` if the property is missing or malformed rather than an
+ /// `Option`, since both halves are used unconditionally either way.
+ fn window_class(&self, client: XWindow) -> (String, String) {
+ let Ok(cookie) = self.conn.get_property(false, client, x11rb::protocol::xproto::AtomEnum::WM_CLASS, x11rb::protocol::xproto::AtomEnum::STRING, 0, 1024)
+ else {
+ return (String::new(), String::new());
+ };
+ let Ok(reply) = cookie.reply() else { return (String::new(), String::new()) };
+ let mut parts = reply.value.split(|&b| b == 0).map(|s| String::from_utf8_lossy(s).into_owned());
+ let instance = parts.next().unwrap_or_default();
+ let class = parts.next().unwrap_or_default();
+ (instance, class)
+ }
+
fn supports_wm_delete(&self, client: XWindow) -> bool {
let Ok(cookie) = self.conn.get_property(false, client, self.atoms.WM_PROTOCOLS, x11rb::protocol::xproto::AtomEnum::ATOM, 0, 32) else {
return false;
@@ -531,18 +658,40 @@ impl Platform for X11Platform {
PlatformKind::X11
}
+ /// Was `wait_for_event()` (blocks indefinitely for the first event,
+ /// only draining any backlog after that), which left `srd`'s IPC socket
+ /// - polled at the end of this method - unresponsive for as long as
+ /// nothing happened on the X11 connection at all: no keypress, no mouse
+ /// motion, nothing. A script sitting on `srd clients` while the user's
+ /// hands were off the keyboard for a few seconds would just hang for
+ /// exactly that long. Replaced with a bounded `poll(2)` on the
+ /// connection's own fd (`~16ms`, matching the Wayland backends' own
+ /// frame-ish cadence) so this method always returns roughly that often
+ /// regardless of X11 activity, draining whatever's actually arrived
+ /// (zero or more events) each time rather than requiring at least one.
fn poll_events(&mut self) -> PlatformResult<Vec<Event>> {
self.conn.flush().map_err(err)?;
- let mut out = Vec::new();
- let first = self.conn.wait_for_event().map_err(err)?;
- if let Some(e) = self.handle_event(first)? {
- out.push(e);
+ let fd = self.conn.stream().as_raw_fd();
+ let mut pfd = libc::pollfd { fd, events: libc::POLLIN, revents: 0 };
+ // Safety: `pfd` is a valid, live `pollfd` for the duration of this
+ // call, and `poll` writes only into `revents`, which is never read
+ // here - the return value alone (ready vs. timed out) is what
+ // matters, so a spurious wake or a timeout are both fine outcomes.
+ unsafe {
+ libc::poll(&mut pfd, 1, 16);
}
+
+ let mut out = Vec::new();
while let Some(ev) = self.conn.poll_for_event().map_err(err)? {
if let Some(e) = self.handle_event(ev)? {
out.push(e);
}
}
+ if let Some(ipc) = self.ipc.as_mut() {
+ if ipc.poll(&self.wm) {
+ out.push(Event::WorkspaceChanged);
+ }
+ }
Ok(out)
}
@@ -577,6 +726,14 @@ impl Platform for X11Platform {
fn apply_geometry(&mut self, window: WindowId, geometry: Rect) -> PlatformResult<()> {
let Some(frame) = self.frames.get(&window) else { return Ok(()) };
let (frame_id, client_id) = (frame.frame, frame.client);
+ // The titlebar band is only actually reserved when the window is
+ // decorated - e.g. a `srd.rule(...)` that sets `decorated = false`
+ // - otherwise the client keeps getting offset down by, and
+ // shrunk by, a titlebar that `redraw_decoration` (below) is
+ // correctly not drawing at all, leaving a blank strip and the
+ // frame visibly not matching what's inside it.
+ let decorated = self.wm.borrow().window(window).map(|w| w.decorated).unwrap_or(true);
+ let band = if decorated { TITLEBAR_HEIGHT } else { 0 };
self.conn
.configure_window(
frame_id,
@@ -586,7 +743,7 @@ impl Platform for X11Platform {
self.conn
.configure_window(
client_id,
- &ConfigureWindowAux::new().x(0).y(TITLEBAR_HEIGHT as i32).width(geometry.width).height(geometry.height.saturating_sub(TITLEBAR_HEIGHT)),
+ &ConfigureWindowAux::new().x(0).y(band as i32).width(geometry.width).height(geometry.height.saturating_sub(band)),
)
.map_err(err)?;
self.conn.flush().map_err(err)?;
@@ -640,6 +797,10 @@ impl Platform for X11Platform {
if let Some(w) = self.wm.borrow_mut().window_mut(window) {
w.border_color = rgb;
}
+ if let Some(frame) = self.frame_for(window) {
+ self.conn.change_window_attributes(frame, &ChangeWindowAttributesAux::new().border_pixel(rgb_to_pixel(rgb))).map_err(err)?;
+ self.conn.flush().map_err(err)?;
+ }
Ok(())
}
@@ -647,6 +808,10 @@ impl Platform for X11Platform {
if let Some(w) = self.wm.borrow_mut().window_mut(window) {
w.border_width = width;
}
+ if let Some(frame) = self.frame_for(window) {
+ self.conn.configure_window(frame, &ConfigureWindowAux::new().border_width(width)).map_err(err)?;
+ self.conn.flush().map_err(err)?;
+ }
Ok(())
}
@@ -655,7 +820,9 @@ impl Platform for X11Platform {
return Ok(());
}
let Some(frame) = self.frame_for(window) else { return Ok(()) };
- let (bg, fg) = if focused { (0x2e3440u32, 0x88c0d0u32) } else { (0x2e3440u32, 0x4c566a) };
+ let theme = self.wm.borrow().theme;
+ let bg = rgb_to_pixel(theme.titlebar_bg);
+ let fg = rgb_to_pixel(if focused { theme.titlebar_fg_focused } else { theme.titlebar_fg_unfocused });
self.conn.change_gc(self.gc, &x11rb::protocol::xproto::ChangeGCAux::new().foreground(bg)).map_err(err)?;
self.conn
@@ -704,3 +871,55 @@ impl Platform for X11Platform {
Ok(())
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// Builds a flattened `GetModifierMappingReply.keycodes`-shaped slice:
+ /// 8 slots (Shift, Lock, Control, Mod1..Mod5) of `per` keycodes each,
+ /// zero-padded, with `assignments` placing one real keycode into
+ /// specific slots.
+ fn modmap(per: usize, assignments: &[(usize, u8)]) -> Vec<u8> {
+ let mut v = vec![0u8; per * 8];
+ for &(slot, kc) in assignments {
+ v[slot * per] = kc;
+ }
+ v
+ }
+
+ #[test]
+ fn finds_numlock_on_mod2_the_common_case() {
+ let keycodes = modmap(2, &[(4, 77)]); // slot 4 == Mod2
+ assert_eq!(modmask_for_keycode_in_mod_slots(77, 2, &keycodes), ModMask::M2);
+ }
+
+ #[test]
+ fn finds_numlock_on_mod5_an_uncommon_but_real_layout() {
+ let keycodes = modmap(2, &[(7, 90)]); // slot 7 == Mod5
+ assert_eq!(modmask_for_keycode_in_mod_slots(90, 2, &keycodes), ModMask::M5);
+ }
+
+ #[test]
+ fn ignores_the_keycode_if_it_only_appears_in_shift_lock_or_control() {
+ // A keycode bound to Lock (e.g. Caps Lock's own keycode) must never
+ // be mistaken for Num Lock - only slots 3..8 (Mod1..Mod5) count.
+ let keycodes = modmap(2, &[(1, 66)]); // slot 1 == Lock
+ assert_eq!(modmask_for_keycode_in_mod_slots(66, 2, &keycodes), ModMask::from(0u16));
+ }
+
+ #[test]
+ fn keycode_zero_never_matches_even_if_a_slot_is_unpadded_zero() {
+ // Unused modifier slots are zero-padded, so keycode 0 must never
+ // resolve to a mask - otherwise a keyboard with no Num Lock key at
+ // all would spuriously "find" it in the first empty slot.
+ let keycodes = modmap(2, &[]);
+ assert_eq!(modmask_for_keycode_in_mod_slots(0, 2, &keycodes), ModMask::from(0u16));
+ }
+
+ #[test]
+ fn no_match_anywhere_returns_empty_mask() {
+ let keycodes = modmap(2, &[(3, 50)]);
+ assert_eq!(modmask_for_keycode_in_mod_slots(99, 2, &keycodes), ModMask::from(0u16));
+ }
+}