diff options
| author | srdusr <[email protected]> | 2024-04-02 01:07:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2024-04-02 01:07:00 +0200 |
| commit | 43175c78a5b450eb108738187b72e80d36a7bf5d (patch) | |
| tree | dbbadbcc18162f766f5af653d81882aaaa6f5e41 /crates/core/src | |
| parent | 8110bb2773b6c841029a51eca7971f42a36f480c (diff) | |
| download | srdwm-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.rs | 112 | ||||
| -rw-r--r-- | crates/core/src/lib.rs | 5 | ||||
| -rw-r--r-- | crates/core/src/manager.rs | 81 | ||||
| -rw-r--r-- | crates/core/src/rules.rs | 85 |
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)); + } +} |