srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/crates/core/src
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2024-04-02 01:07:00 +0200
committersrdusr <[email protected]>2024-04-02 01:07:00 +0200
commit43175c78a5b450eb108738187b72e80d36a7bf5d (patch)
treedbbadbcc18162f766f5af653d81882aaaa6f5e41 /crates/core/src
parent8110bb2773b6c841029a51eca7971f42a36f480c (diff)
downloadsrdwm-43175c78a5b450eb108738187b72e80d36a7bf5d.tar.gz
srdwm-43175c78a5b450eb108738187b72e80d36a7bf5d.zip
Add window rules, real config validation, and a Wayland DRM/udev backend
- srd.rule(): match windows by title/class, apply floating/maximized/ workspace/geometry/decoration actions on creation (crates/core/src/rules.rs) - srd.validate_config()/srd.debug.*: real range/format checks and status/profiling helpers, replacing the always-true stub - Wayland titlebar text rendering via fontdue, unit-tested without a display (crates/wayland/src/decoration.rs) - Wayland precise keybinding matching, replacing the "any Super-held key" heuristic, sharing the keysym table with X11 (moved to crates/core/src/keysyms.rs) - Wayland DRM/udev backend (crates/wayland/src/udev.rs): runs as the real compositor on a bare TTY via libseat/libinput/KMS, software rendering via Pixman + dumb buffers (no GBM/EGL required) - srdwm_platform::detect() fix, found via VM testing: a bare TTY with no DISPLAY/WAYLAND_DISPLAY now correctly resolves to Wayland instead of an X11 backend that can never work there - XWayland integration groundwork (crates/wayland/src/xwayland.rs): spawn, X11Wm, and full XwmHandler event routing into the same WindowManager/ Space pipeline as native clients. Windows don't render yet - a real glamor-vs-software-renderer conflict in XWayland's own fallback path, root-caused via WAYLAND_DEBUG tracing and documented in docs/IMPLEMENTATION_STATUS.md rather than worked around blind. All verified live in an isolated QEMU VM: X11 backend shows two decorated, correctly-tiled xterms with real title text; the DRM/udev Wayland backend opens the GPU, initializes input, and scans out a rendered frame via KMS page-flip.
Diffstat (limited to 'crates/core/src')
-rw-r--r--crates/core/src/keysyms.rs112
-rw-r--r--crates/core/src/lib.rs5
-rw-r--r--crates/core/src/manager.rs81
-rw-r--r--crates/core/src/rules.rs85
4 files changed, 279 insertions, 4 deletions
diff --git a/crates/core/src/keysyms.rs b/crates/core/src/keysyms.rs
new file mode 100644
index 0000000..0baa6bc
--- /dev/null
+++ b/crates/core/src/keysyms.rs
@@ -0,0 +1,112 @@
+//! A small, hand-maintained keysym <-> name table covering the keys used in
+//! practice by window-manager keybindings (letters, digits, navigation,
+//! function keys). Not a full xkbcommon-level keymap - see module docs in
+//! `lib.rs` for why that trade-off was made for this pass.
+
+pub fn keysym_to_name(keysym: u32) -> Option<String> {
+ Some(match keysym {
+ 0x0020 => "Space".to_string(),
+ 0x0061..=0x007a => (((keysym - 0x0061) as u8 + b'a') as char).to_string(),
+ 0x0030..=0x0039 => (((keysym - 0x0030) as u8 + b'0') as char).to_string(),
+ 0xff0d => "Return".to_string(),
+ 0xff1b => "Escape".to_string(),
+ 0xff09 => "Tab".to_string(),
+ 0xff08 => "BackSpace".to_string(),
+ 0xffff => "Delete".to_string(),
+ 0xff51 => "Left".to_string(),
+ 0xff52 => "Up".to_string(),
+ 0xff53 => "Right".to_string(),
+ 0xff54 => "Down".to_string(),
+ 0xff55 => "Prior".to_string(),
+ 0xff56 => "Next".to_string(),
+ 0xff50 => "Home".to_string(),
+ 0xff57 => "End".to_string(),
+ 0xffbe..=0xffc9 => format!("F{}", keysym - 0xffbe + 1),
+ 0x1008ff13 => "XF86AudioRaiseVolume".to_string(),
+ 0x1008ff11 => "XF86AudioLowerVolume".to_string(),
+ 0x1008ff12 => "XF86AudioMute".to_string(),
+ 0x1008ff02 => "XF86MonBrightnessUp".to_string(),
+ 0x1008ff03 => "XF86MonBrightnessDown".to_string(),
+ _ => return None,
+ })
+}
+
+/// Case-insensitive: config authors reasonably write both `"Space"` and
+/// `"space"`, and X11 key names aren't consistently capitalized in the wild.
+pub fn name_to_keysym(name: &str) -> Option<u32> {
+ match name.to_ascii_lowercase().as_str() {
+ "space" => return Some(0x0020),
+ "return" | "enter" => return Some(0xff0d),
+ "escape" => return Some(0xff1b),
+ "tab" => return Some(0xff09),
+ "backspace" => return Some(0xff08),
+ "delete" => return Some(0xffff),
+ "left" => return Some(0xff51),
+ "up" => return Some(0xff52),
+ "right" => return Some(0xff53),
+ "down" => return Some(0xff54),
+ "prior" | "pageup" => return Some(0xff55),
+ "next" | "pagedown" => return Some(0xff56),
+ "home" => return Some(0xff50),
+ "end" => return Some(0xff57),
+ "xf86audioraisevolume" => return Some(0x1008ff13),
+ "xf86audiolowervolume" => return Some(0x1008ff11),
+ "xf86audiomute" => return Some(0x1008ff12),
+ "xf86monbrightnessup" => return Some(0x1008ff02),
+ "xf86monbrightnessdown" => return Some(0x1008ff03),
+ _ => {}
+ }
+ if name.len() == 1 {
+ let c = name.chars().next().unwrap().to_ascii_lowercase();
+ if c.is_ascii_lowercase() {
+ return Some(0x0061 + (c as u32 - 'a' as u32));
+ }
+ if c.is_ascii_digit() {
+ return Some(0x0030 + (c as u32 - '0' as u32));
+ }
+ }
+ if let Some(rest) = name.to_ascii_lowercase().strip_prefix('f') {
+ if let Ok(n) = rest.parse::<u32>() {
+ if (1..=12).contains(&n) {
+ return Some(0xffbe + n - 1);
+ }
+ }
+ }
+ None
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn letters_and_digits_roundtrip() {
+ for c in 'a'..='z' {
+ let name = c.to_string();
+ let ks = name_to_keysym(&name).unwrap();
+ assert_eq!(keysym_to_name(ks), Some(name));
+ }
+ for c in '0'..='9' {
+ let name = c.to_string();
+ let ks = name_to_keysym(&name).unwrap();
+ assert_eq!(keysym_to_name(ks), Some(name));
+ }
+ }
+
+ #[test]
+ fn lowercase_names_resolve_case_insensitively() {
+ // Config authors reasonably write "Mod4+Shift+space"; this must
+ // resolve to the same keysym as "Space".
+ assert_eq!(name_to_keysym("space"), name_to_keysym("Space"));
+ assert_eq!(name_to_keysym("return"), name_to_keysym("Return"));
+ assert_eq!(name_to_keysym("f5"), name_to_keysym("F5"));
+ }
+
+ #[test]
+ fn named_keys_roundtrip() {
+ for name in ["Return", "Escape", "Tab", "Left", "Right", "Up", "Down", "F1", "F12"] {
+ let ks = name_to_keysym(name).unwrap();
+ assert_eq!(keysym_to_name(ks), Some(name.to_string()));
+ }
+ }
+}
diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs
index a23e7d6..0fc2fad 100644
--- a/crates/core/src/lib.rs
+++ b/crates/core/src/lib.rs
@@ -1,17 +1,20 @@
pub mod event;
pub mod geometry;
+pub mod keysyms;
pub mod layout;
pub mod manager;
pub mod monitor;
pub mod placement;
+pub mod rules;
pub mod window;
pub mod workspace;
-pub use event::{Event, MouseButton, Modifiers};
+pub use event::{key_combo_string, Event, MouseButton, Modifiers};
pub use geometry::Rect;
pub use layout::{Layout, MasterStackLayout, NoOpLayout, TilingConfig};
pub use manager::{Direction, WindowManager};
pub use monitor::{Monitor, MonitorId};
pub use placement::{PlacementConfig, SmartPlacement};
+pub use rules::{WindowMatch, WindowRule, WindowRuleActions};
pub use window::{ResizeEdge, TitlebarHit, Window, WindowId, RESIZE_MARGIN, TITLEBAR_HEIGHT};
pub use workspace::{Workspace, WorkspaceId};
diff --git a/crates/core/src/manager.rs b/crates/core/src/manager.rs
index 1fdd7eb..1f69413 100644
--- a/crates/core/src/manager.rs
+++ b/crates/core/src/manager.rs
@@ -2,6 +2,7 @@ use crate::geometry::Rect;
use crate::layout::{Layout, MasterStackLayout, NoOpLayout, TilingConfig};
use crate::monitor::{Monitor, MonitorId};
use crate::placement::{PlacementConfig, SmartPlacement, MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH};
+use crate::rules::WindowRule;
use crate::window::{ResizeEdge, TitlebarHit, Window, WindowId};
use crate::workspace::{Workspace, WorkspaceId};
use std::collections::HashMap;
@@ -47,6 +48,7 @@ pub struct WindowManager {
pub placement: PlacementConfig,
drag: Option<DragState>,
resize: Option<ResizeState>,
+ rules: Vec<WindowRule>,
}
impl Default for WindowManager {
@@ -76,9 +78,16 @@ impl WindowManager {
placement: PlacementConfig::default(),
drag: None,
resize: None,
+ rules: Vec::new(),
}
}
+ /// Registers a window rule; on every subsequent `add_window`, the first
+ /// rule whose matcher matches the new window has its actions applied.
+ pub fn add_rule(&mut self, rule: WindowRule) {
+ self.rules.push(rule);
+ }
+
pub fn register_layout(&mut self, name: impl Into<String>, layout: Box<dyn Layout>) {
self.layouts.insert(name.into(), layout);
}
@@ -119,19 +128,45 @@ impl WindowManager {
/// it's left for the next `arrange_workspace` call to place.
pub fn add_window(&mut self, mut window: Window) -> WindowId {
let id = window.id;
- window.workspace = self.current_workspace;
+ let actions = self.rules.iter().find(|r| r.matcher.matches(&window)).map(|r| r.actions.clone());
+
+ let workspace = actions.as_ref().and_then(|a| a.workspace).unwrap_or(self.current_workspace);
+ window.workspace = workspace;
+ if let Some(a) = &actions {
+ if let Some(floating) = a.floating {
+ window.floating = floating;
+ }
+ if let Some(decorated) = a.decorated {
+ window.decorated = decorated;
+ }
+ if let Some(color) = a.border_color {
+ window.border_color = color;
+ }
+ if let Some(width) = a.border_width {
+ window.border_width = width;
+ }
+ }
+
if let Some(monitor) = self.primary_monitor() {
window.monitor = monitor.id;
- let layout_name = self.workspace(self.current_workspace).map(|w| w.layout.clone()).unwrap_or_default();
+ let layout_name = self.workspace(workspace).map(|w| w.layout.clone()).unwrap_or_default();
if layout_name != "tiling" {
- let existing: Vec<Rect> = self.windows_on_workspace(self.current_workspace).map(|w| w.geometry).collect();
+ let existing: Vec<Rect> = self.windows_on_workspace(workspace).map(|w| w.geometry).collect();
let size = (window.geometry.width, window.geometry.height);
window.geometry = SmartPlacement::place(monitor, &existing, size, &self.placement);
}
}
+ if let Some(geometry) = actions.as_ref().and_then(|a| a.geometry) {
+ window.geometry = geometry;
+ }
+ let maximize = actions.as_ref().and_then(|a| a.maximized).unwrap_or(false);
+
self.windows.insert(id, window);
self.order.push(id);
self.focused = Some(id);
+ if maximize {
+ self.toggle_maximize(id);
+ }
id
}
@@ -711,6 +746,46 @@ mod tests {
}
#[test]
+ fn matching_rule_floats_new_window_on_add() {
+ let mut wm = wm_with_monitor();
+ wm.set_layout(wm.current_workspace(), "tiling");
+ wm.add_rule(WindowRule {
+ matcher: crate::rules::WindowMatch { title_contains: Some("calculator".into()), class: None },
+ actions: crate::rules::WindowRuleActions { floating: Some(true), ..Default::default() },
+ });
+ let id = wm.alloc_window_id();
+ wm.add_window(Window::new(id, "Calculator"));
+ assert!(wm.is_floating(id));
+ }
+
+ #[test]
+ fn non_matching_rule_leaves_window_untouched() {
+ let mut wm = wm_with_monitor();
+ wm.add_rule(WindowRule {
+ matcher: crate::rules::WindowMatch { title_contains: Some("calculator".into()), class: None },
+ actions: crate::rules::WindowRuleActions { floating: Some(true), ..Default::default() },
+ });
+ let id = wm.alloc_window_id();
+ wm.add_window(Window::new(id, "Terminal"));
+ assert!(!wm.is_floating(id));
+ }
+
+ #[test]
+ fn rule_assigns_window_to_target_workspace() {
+ let mut wm = wm_with_monitor();
+ let target = wm.add_workspace("scratch", "dynamic");
+ wm.add_rule(WindowRule {
+ matcher: crate::rules::WindowMatch { title_contains: None, class: Some("scratchpad".into()) },
+ actions: crate::rules::WindowRuleActions { workspace: Some(target), ..Default::default() },
+ });
+ let id = wm.alloc_window_id();
+ let mut w = Window::new(id, "notes");
+ w.app_id = "scratchpad".into();
+ wm.add_window(w);
+ assert_eq!(wm.window(id).unwrap().workspace, target);
+ }
+
+ #[test]
fn removing_a_workspace_reassigns_its_windows() {
let mut wm = wm_with_monitor();
let ws2 = wm.add_workspace("2", "dynamic");
diff --git a/crates/core/src/rules.rs b/crates/core/src/rules.rs
new file mode 100644
index 0000000..4d881a5
--- /dev/null
+++ b/crates/core/src/rules.rs
@@ -0,0 +1,85 @@
+use crate::geometry::Rect;
+use crate::window::Window;
+use crate::workspace::WorkspaceId;
+
+/// Match criteria for a [`WindowRule`]. A matcher with every field `None`
+/// matches nothing (an accidental `srd.rule({}, {...})` in config should be a
+/// silent no-op, not "apply to every window").
+#[derive(Debug, Clone, Default)]
+pub struct WindowMatch {
+ /// Case-insensitive substring match against `Window::title`.
+ pub title_contains: Option<String>,
+ /// Case-insensitive exact match against `Window::app_id` (X11 `WM_CLASS`
+ /// / Wayland `app_id`).
+ pub class: Option<String>,
+}
+
+impl WindowMatch {
+ pub fn is_empty(&self) -> bool {
+ self.title_contains.is_none() && self.class.is_none()
+ }
+
+ pub fn matches(&self, window: &Window) -> bool {
+ if self.is_empty() {
+ return false;
+ }
+ if let Some(t) = &self.title_contains {
+ if !window.title.to_lowercase().contains(&t.to_lowercase()) {
+ return false;
+ }
+ }
+ if let Some(c) = &self.class {
+ if !window.app_id.eq_ignore_ascii_case(c) {
+ return false;
+ }
+ }
+ true
+ }
+}
+
+/// Actions applied once, when a matching window is first added.
+#[derive(Debug, Clone, Default)]
+pub struct WindowRuleActions {
+ pub floating: Option<bool>,
+ pub maximized: Option<bool>,
+ pub workspace: Option<WorkspaceId>,
+ pub geometry: Option<Rect>,
+ pub decorated: Option<bool>,
+ pub border_color: Option<(u8, u8, u8)>,
+ pub border_width: Option<u32>,
+}
+
+#[derive(Debug, Clone, Default)]
+pub struct WindowRule {
+ pub matcher: WindowMatch,
+ pub actions: WindowRuleActions,
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn empty_matcher_matches_nothing() {
+ let w = Window::new(1, "anything");
+ assert!(!WindowMatch::default().matches(&w));
+ }
+
+ #[test]
+ fn title_match_is_case_insensitive_substring() {
+ let w = Window::new(1, "Mozilla Firefox");
+ let m = WindowMatch { title_contains: Some("firefox".into()), class: None };
+ assert!(m.matches(&w));
+ }
+
+ #[test]
+ fn class_match_is_case_insensitive_exact() {
+ let mut w = Window::new(1, "");
+ w.app_id = "Firefox".into();
+ let m = WindowMatch { title_contains: None, class: Some("firefox".into()) };
+ assert!(m.matches(&w));
+ let mut w2 = Window::new(2, "");
+ w2.app_id = "firefoxx".into();
+ assert!(!m.matches(&w2));
+ }
+}