srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--crates/core/src/context_menu.rs195
-rw-r--r--crates/core/src/lib.rs2
-rw-r--r--crates/wayland/src/context_menu.rs139
-rw-r--r--crates/x11/src/platform/connect.rs1
-rw-r--r--crates/x11/src/platform/context_menu.rs153
-rw-r--r--crates/x11/src/platform/events.rs61
-rw-r--r--crates/x11/src/platform/mod.rs7
-rw-r--r--crates/x11/src/platform/window.rs7
8 files changed, 427 insertions, 138 deletions
diff --git a/crates/core/src/context_menu.rs b/crates/core/src/context_menu.rs
new file mode 100644
index 0000000..30b1e8f
--- /dev/null
+++ b/crates/core/src/context_menu.rs
@@ -0,0 +1,195 @@
+//! Right-click titlebar window menu - the one titlebar interaction
+//! virtually every desktop WM has always offered. Backend-agnostic: this
+//! is pure state and geometry (which rows exist, which one a point falls
+//! on), not pixels. The Wayland backend renders it via
+//! `decoration::render_context_menu`; the X11 backend draws it with raw
+//! XCB calls the same way it already draws its own titlebar.
+//!
+//! Originally lived only in the Wayland crate; moved here once the X11
+//! backend needed the same row set and hit-testing rather than a second,
+//! drifting copy of the same logic - this data has no Wayland-specific
+//! content at all, so duplicating it would just be two copies of the same
+//! bug waiting to happen.
+
+use crate::{WindowManager, WindowId, WorkspaceId, TITLEBAR_HEIGHT};
+
+#[derive(Clone, Copy)]
+pub enum MenuAction {
+ Minimize,
+ ToggleMaximize,
+ ToggleFullscreen,
+ ToggleFloating,
+ ToggleAlwaysOnTop,
+ MoveToWorkspace(WorkspaceId),
+ Close,
+ /// Not a real action - a purely visual divider row. `row_at` still
+ /// resolves a click on one to `Some(index)` (it occupies a real row,
+ /// same as any other), so the dispatch site is what actually no-ops
+ /// on it, same "the row exists but does nothing" contract a real
+ /// desktop's own menu separators have.
+ Separator,
+}
+
+pub struct ContextMenu {
+ pub window: WindowId,
+ /// Top-left corner, in global (output-independent) space - same frame
+ /// `Window.geometry` and every other rendered element's position uses.
+ pub pos: (i32, i32),
+ pub width: u32,
+ pub row_height: u32,
+ pub items: Vec<(&'static str, MenuAction)>,
+}
+
+const MENU_WIDTH: u32 = 170;
+
+impl ContextMenu {
+ /// Builds the menu for `window`, opening with its top-left corner at
+ /// `pos` (wherever the right-click landed). Labels reflect the
+ /// window's *current* state - "Maximize" flips to "Restore", "Always
+ /// on Top" gets a checkmark prefix once pinned - same convention
+ /// every native window menu uses, rather than a static label that
+ /// silently means the opposite of what it says half the time.
+ pub fn open(wm: &WindowManager, window: WindowId, pos: (i32, i32)) -> Option<Self> {
+ let w = wm.window(window)?;
+ let maximize_label = if w.maximized { "Restore" } else { "Maximize" };
+ let fullscreen_label = if w.fullscreen { "Exit Fullscreen" } else { "Fullscreen" };
+ let floating_label = if w.floating { "\u{2713} Floating" } else { "Floating" };
+ let pin_label = if w.always_on_top { "\u{2713} Always on Top" } else { "Always on Top" };
+ let mut items = vec![
+ ("Minimize", MenuAction::Minimize),
+ (maximize_label, MenuAction::ToggleMaximize),
+ (fullscreen_label, MenuAction::ToggleFullscreen),
+ (floating_label, MenuAction::ToggleFloating),
+ (pin_label, MenuAction::ToggleAlwaysOnTop),
+ ];
+ // One row per *other* workspace - skips the window's own current
+ // one, since "move to the workspace it's already on" isn't a real
+ // action. Flattened rather than a real submenu - `workspace.count`
+ // is small in practice (this project's own default config uses 6),
+ // so the menu stays a reasonable height without one.
+ let others: Vec<&crate::Workspace> = wm.workspaces().iter().filter(|ws| ws.id != w.workspace).collect();
+ if !others.is_empty() {
+ items.push(("\u{2500}\u{2500}\u{2500} Move to Workspace \u{2500}\u{2500}\u{2500}", MenuAction::Separator));
+ for ws in others {
+ // `Workspace.name` is `&'static`-incompatible (a real
+ // `String`, user-configurable via `workspace.names`) --
+ // `Box::leak` turns it into the `&'static str` this
+ // struct's own `items` field is typed for.
+ let label: &'static str = Box::leak(ws.name.clone().into_boxed_str());
+ items.push((label, MenuAction::MoveToWorkspace(ws.id)));
+ }
+ }
+ items.push(("\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}", MenuAction::Separator));
+ items.push(("Close", MenuAction::Close));
+ Some(Self { window, pos, width: MENU_WIDTH, row_height: TITLEBAR_HEIGHT, items })
+ }
+
+ pub fn height(&self) -> i32 {
+ self.row_height as i32 * self.items.len() as i32
+ }
+
+ /// Which row (if any) global-space point `(x, y)` falls on.
+ pub fn row_at(&self, x: i32, y: i32) -> Option<usize> {
+ if x < self.pos.0 || x >= self.pos.0 + self.width as i32 {
+ return None;
+ }
+ let rel_y = y - self.pos.1;
+ if rel_y < 0 || rel_y >= self.height() {
+ return None;
+ }
+ Some((rel_y / self.row_height as i32) as usize)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::Window;
+
+ fn wm_with_window() -> (WindowManager, WindowId) {
+ let mut wm = WindowManager::new();
+ wm.set_monitors(vec![crate::Monitor::new(0, "primary", crate::Rect::new(0, 0, 1920, 1080))]);
+ let id = wm.alloc_window_id();
+ wm.add_window(Window::new(id, "a"));
+ (wm, id)
+ }
+
+ #[test]
+ fn open_labels_maximize_action_by_current_state() {
+ let (mut wm, id) = wm_with_window();
+ let menu = ContextMenu::open(&wm, id, (100, 100)).unwrap();
+ assert_eq!(menu.items[1].0, "Maximize");
+
+ wm.toggle_maximize(id);
+ let menu = ContextMenu::open(&wm, id, (100, 100)).unwrap();
+ assert_eq!(menu.items[1].0, "Restore");
+ }
+
+ #[test]
+ fn open_marks_pinned_state_on_the_always_on_top_row() {
+ let (mut wm, id) = wm_with_window();
+ let menu = ContextMenu::open(&wm, id, (100, 100)).unwrap();
+ // Minimize, Maximize, Fullscreen, Floating, then Always on Top.
+ assert_eq!(menu.items[4].0, "Always on Top");
+
+ wm.toggle_always_on_top(id);
+ let menu = ContextMenu::open(&wm, id, (100, 100)).unwrap();
+ assert!(menu.items[4].0.starts_with('\u{2713}'), "pinned state must be visible on the label itself");
+ }
+
+ #[test]
+ fn open_returns_none_for_a_window_that_no_longer_exists() {
+ let (wm, id) = wm_with_window();
+ assert!(ContextMenu::open(&wm, id + 999, (0, 0)).is_none());
+ }
+
+ #[test]
+ fn single_workspace_gets_no_move_to_workspace_section() {
+ let (wm, id) = wm_with_window();
+ let menu = ContextMenu::open(&wm, id, (100, 100)).unwrap();
+ assert!(!menu.items.iter().any(|(label, _)| label.contains("Workspace")));
+ assert_eq!(menu.items.len(), 7, "Minimize, Maximize, Fullscreen, Floating, Always on Top, one separator, Close");
+ }
+
+ #[test]
+ fn a_second_workspace_adds_a_move_row_but_not_for_its_own_workspace() {
+ let (mut wm, id) = wm_with_window();
+ wm.add_workspace("2", "dynamic");
+ let menu = ContextMenu::open(&wm, id, (100, 100)).unwrap();
+ let workspace_rows: Vec<&str> = menu
+ .items
+ .iter()
+ .filter_map(|(label, action)| matches!(action, MenuAction::MoveToWorkspace(_)).then_some(*label))
+ .collect();
+ assert_eq!(workspace_rows, vec!["2"], "only the OTHER workspace gets a row, not the window's own");
+ }
+
+ #[test]
+ fn clicking_a_separator_row_is_distinguishable_from_a_real_action() {
+ let (mut wm, id) = wm_with_window();
+ wm.add_workspace("2", "dynamic");
+ let menu = ContextMenu::open(&wm, id, (100, 100)).unwrap();
+ let separator_count = menu.items.iter().filter(|(_, a)| matches!(a, MenuAction::Separator)).count();
+ assert_eq!(separator_count, 2, "one before the workspace section, one before Close");
+ }
+
+ #[test]
+ fn row_at_maps_a_point_to_the_right_row() {
+ let (wm, id) = wm_with_window();
+ let menu = ContextMenu::open(&wm, id, (100, 100)).unwrap();
+ assert_eq!(menu.row_at(150, 100), Some(0), "top of the first row");
+ assert_eq!(menu.row_at(150, 100 + TITLEBAR_HEIGHT as i32 - 1), Some(0), "bottom of the first row");
+ assert_eq!(menu.row_at(150, 100 + TITLEBAR_HEIGHT as i32), Some(1), "top of the second row");
+ assert_eq!(menu.row_at(150, 100 + menu.height() - 1), Some(menu.items.len() - 1), "last row, last pixel");
+ }
+
+ #[test]
+ fn row_at_is_none_outside_the_menus_bounds() {
+ let (wm, id) = wm_with_window();
+ let menu = ContextMenu::open(&wm, id, (100, 100)).unwrap();
+ assert_eq!(menu.row_at(99, 110), None, "just left of the menu");
+ assert_eq!(menu.row_at(100 + MENU_WIDTH as i32, 110), None, "just right of the menu");
+ assert_eq!(menu.row_at(150, 99), None, "just above the menu");
+ assert_eq!(menu.row_at(150, 100 + menu.height()), None, "just below the menu");
+ }
+}
diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs
index 7905504..066d6d2 100644
--- a/crates/core/src/lib.rs
+++ b/crates/core/src/lib.rs
@@ -1,3 +1,4 @@
+pub mod context_menu;
pub mod event;
pub mod geometry;
pub mod keysyms;
@@ -11,6 +12,7 @@ pub mod theme;
pub mod window;
pub mod workspace;
+pub use context_menu::{ContextMenu, MenuAction};
pub use event::{canonicalize_key_combo, key_combo_string, parse_key_combo, Event, MouseButton, Modifiers};
pub use geometry::Rect;
pub use layout::{Layout, MasterStackLayout, NoOpLayout, TilingConfig};
diff --git a/crates/wayland/src/context_menu.rs b/crates/wayland/src/context_menu.rs
index f11e41b..be2afe5 100644
--- a/crates/wayland/src/context_menu.rs
+++ b/crates/wayland/src/context_menu.rs
@@ -1,133 +1,10 @@
-//! Right-click titlebar window menu - the one titlebar interaction
-//! virtually every desktop WM has always offered that srdwm never did.
-//! Right-click on a titlebar previously did nothing at all (the only
-//! right-button behaviour anywhere was the SUPER+right-drag resize
-//! gesture, which needs the modifier held); this gives plain right-click
-//! a real, discoverable action.
+//! Right-click titlebar window menu - the row set and hit-testing now
+//! live in `srdwm_core::context_menu` (shared with the X11 backend, which
+//! needs the exact same rows). This re-export keeps every existing
+//! `crate::context_menu::...` call site in this crate unchanged.
//!
-//! Deliberately minimal: four fixed actions, no submenus, no live hover
-//! highlight (a nice-to-have that would need the render buffer rebuilt on
-//! every pointer-motion event over the menu - not worth the extra
-//! per-frame cost for a first pass). See `decoration::render_context_menu`
-//! for the actual pixels.
+//! `decoration::render_context_menu` still owns the actual pixels; this
+//! crate has no rendering-specific state of its own to add on top of the
+//! shared struct.
-use srdwm_core::{WindowId, WindowManager, TITLEBAR_HEIGHT};
-
-#[derive(Clone, Copy)]
-pub(crate) enum MenuAction {
- Minimize,
- ToggleMaximize,
- ToggleAlwaysOnTop,
- Close,
-}
-
-pub(crate) struct ContextMenu {
- pub(crate) window: WindowId,
- /// Top-left corner, in global (output-independent) space - same frame
- /// `Window.geometry` and every other `custom_elements` position uses.
- pub(crate) pos: (i32, i32),
- pub(crate) width: u32,
- pub(crate) row_height: u32,
- pub(crate) items: Vec<(&'static str, MenuAction)>,
-}
-
-const MENU_WIDTH: u32 = 170;
-
-impl ContextMenu {
- /// Builds the menu for `window`, opening with its top-left corner at
- /// `pos` (wherever the right-click landed). Labels reflect the
- /// window's *current* state - "Maximize" flips to "Restore", "Always
- /// on Top" gets a checkmark prefix once pinned - same convention
- /// every native window menu uses, rather than a static label that
- /// silently means the opposite of what it says half the time.
- pub(crate) fn open(wm: &WindowManager, window: WindowId, pos: (i32, i32)) -> Option<Self> {
- let w = wm.window(window)?;
- let maximize_label = if w.maximized { "Restore" } else { "Maximize" };
- let pin_label = if w.always_on_top { "\u{2713} Always on Top" } else { "Always on Top" };
- let items = vec![
- ("Minimize", MenuAction::Minimize),
- (maximize_label, MenuAction::ToggleMaximize),
- (pin_label, MenuAction::ToggleAlwaysOnTop),
- ("Close", MenuAction::Close),
- ];
- Some(Self { window, pos, width: MENU_WIDTH, row_height: TITLEBAR_HEIGHT, items })
- }
-
- pub(crate) fn height(&self) -> i32 {
- self.row_height as i32 * self.items.len() as i32
- }
-
- /// Which row (if any) global-space point `(x, y)` falls on.
- pub(crate) fn row_at(&self, x: i32, y: i32) -> Option<usize> {
- if x < self.pos.0 || x >= self.pos.0 + self.width as i32 {
- return None;
- }
- let rel_y = y - self.pos.1;
- if rel_y < 0 || rel_y >= self.height() {
- return None;
- }
- Some((rel_y / self.row_height as i32) as usize)
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use srdwm_core::Window;
-
- fn wm_with_window() -> (WindowManager, WindowId) {
- let mut wm = WindowManager::new();
- wm.set_monitors(vec![srdwm_core::Monitor::new(0, "primary", srdwm_core::Rect::new(0, 0, 1920, 1080))]);
- let id = wm.alloc_window_id();
- wm.add_window(Window::new(id, "a"));
- (wm, id)
- }
-
- #[test]
- fn open_labels_maximize_action_by_current_state() {
- let (mut wm, id) = wm_with_window();
- let menu = ContextMenu::open(&wm, id, (100, 100)).unwrap();
- assert_eq!(menu.items[1].0, "Maximize");
-
- wm.toggle_maximize(id);
- let menu = ContextMenu::open(&wm, id, (100, 100)).unwrap();
- assert_eq!(menu.items[1].0, "Restore");
- }
-
- #[test]
- fn open_marks_pinned_state_on_the_always_on_top_row() {
- let (mut wm, id) = wm_with_window();
- let menu = ContextMenu::open(&wm, id, (100, 100)).unwrap();
- assert_eq!(menu.items[2].0, "Always on Top");
-
- wm.toggle_always_on_top(id);
- let menu = ContextMenu::open(&wm, id, (100, 100)).unwrap();
- assert!(menu.items[2].0.starts_with('\u{2713}'), "pinned state must be visible on the label itself");
- }
-
- #[test]
- fn open_returns_none_for_a_window_that_no_longer_exists() {
- let (wm, id) = wm_with_window();
- assert!(ContextMenu::open(&wm, id + 999, (0, 0)).is_none());
- }
-
- #[test]
- fn row_at_maps_a_point_to_the_right_row() {
- let (wm, id) = wm_with_window();
- let menu = ContextMenu::open(&wm, id, (100, 100)).unwrap();
- assert_eq!(menu.row_at(150, 100), Some(0), "top of the first row");
- assert_eq!(menu.row_at(150, 100 + TITLEBAR_HEIGHT as i32 - 1), Some(0), "bottom of the first row");
- assert_eq!(menu.row_at(150, 100 + TITLEBAR_HEIGHT as i32), Some(1), "top of the second row");
- assert_eq!(menu.row_at(150, 100 + menu.height() - 1), Some(3), "last row, last pixel");
- }
-
- #[test]
- fn row_at_is_none_outside_the_menus_bounds() {
- let (wm, id) = wm_with_window();
- let menu = ContextMenu::open(&wm, id, (100, 100)).unwrap();
- assert_eq!(menu.row_at(99, 110), None, "just left of the menu");
- assert_eq!(menu.row_at(100 + MENU_WIDTH as i32, 110), None, "just right of the menu");
- assert_eq!(menu.row_at(150, 99), None, "just above the menu");
- assert_eq!(menu.row_at(150, 100 + menu.height()), None, "just below the menu");
- }
-}
+pub(crate) use srdwm_core::context_menu::{ContextMenu, MenuAction};
diff --git a/crates/x11/src/platform/connect.rs b/crates/x11/src/platform/connect.rs
index 060b3d2..ee8906d 100644
--- a/crates/x11/src/platform/connect.rs
+++ b/crates/x11/src/platform/connect.rs
@@ -98,6 +98,7 @@ impl X11Platform {
ipc,
appmenu_registrar: Some(srdwm_platform::AppmenuRegistrarState::new()),
struts: HashMap::new(),
+ context_menu: None,
})
}
diff --git a/crates/x11/src/platform/context_menu.rs b/crates/x11/src/platform/context_menu.rs
new file mode 100644
index 0000000..9238f6a
--- /dev/null
+++ b/crates/x11/src/platform/context_menu.rs
@@ -0,0 +1,153 @@
+//! Right-click titlebar window menu - the X11 half of `srdwm_core::
+//! context_menu`. The Wayland backend renders this into a shared
+//! compositor buffer; X11 has no equivalent (there is no single "every
+//! surface" compositor here, just ordinary X windows), so this draws it
+//! into its own small override-redirect popup window with the same GC/
+//! font `redraw_decoration` already uses for the titlebar itself.
+//!
+//! Row set and hit-testing come from `srdwm_core::context_menu`, unchanged
+//! from the Wayland backend - see that module's own doc comment for why
+//! it moved there. Foreign-toplevel state broadcasting
+//! (`foreign_toplevel::send_state` on the Wayland side) has no X11
+//! equivalent - that protocol is Wayland-only - so `run_context_menu_
+//! action` below is otherwise the same action set with that one line
+//! dropped.
+
+use super::*;
+use srdwm_core::context_menu::{ContextMenu, MenuAction};
+use x11rb::protocol::xproto::{ChangeGCAux, CoordMode, Point};
+
+impl X11Platform {
+ /// Opens the menu for `window` with its top-left corner at `pos`
+ /// (root-window pixels, wherever the right-click landed), clamped so
+ /// it never opens off the right/bottom edge of the screen. Grabs the
+ /// pointer for the duration: X11 has no single compositor-level input
+ /// dispatch to intercept every click the way the Wayland backend's
+ /// `input/pointer.rs` does, so an active grab on the root window is
+ /// what makes "click anywhere else dismisses the menu" work at all --
+ /// without it, a click over some other client's window would go
+ /// straight to that client and never reach us.
+ pub(super) fn open_context_menu(&mut self, window: WindowId, pos: (i32, i32)) -> PlatformResult<()> {
+ let Some(mut menu) = ({
+ let wm = self.wm.borrow();
+ ContextMenu::open(&wm, window, pos)
+ }) else {
+ return Ok(());
+ };
+
+ let screen = &self.conn.setup().roots[0];
+ let (sw, sh) = (screen.width_in_pixels as i32, screen.height_in_pixels as i32);
+ let x = menu.pos.0.max(0).min((sw - menu.width as i32).max(0));
+ let y = menu.pos.1.max(0).min((sh - menu.height()).max(0));
+ menu.pos = (x, y);
+
+ let popup = self.conn.generate_id().map_err(err)?;
+ let aux = CreateWindowAux::new()
+ .override_redirect(1)
+ .event_mask(EventMask::EXPOSURE)
+ .background_pixel(screen.white_pixel);
+ self.conn
+ .create_window(COPY_DEPTH_FROM_PARENT, popup, self.root, x as i16, y as i16, menu.width as u16, menu.height() as u16, 0, WindowClass::INPUT_OUTPUT, 0, &aux)
+ .map_err(err)?;
+ self.conn.map_window(popup).map_err(err)?;
+ self.conn.configure_window(popup, &ConfigureWindowAux::new().stack_mode(StackMode::ABOVE)).map_err(err)?;
+
+ // `owner_events: false` - every button event while this grab is
+ // active is reported against `grab_window` (root) regardless of
+ // which window the pointer is physically over, carrying the same
+ // absolute `root_x`/`root_y` the normal per-window path already
+ // uses for hit-testing. Best-effort: a failed grab (another
+ // client already holds one, vanishingly rare in practice) still
+ // leaves the menu visible and closeable by re-clicking its own
+ // rows, just without the "click elsewhere" dismissal.
+ let _ = self
+ .conn
+ .grab_pointer(false, self.root, EventMask::BUTTON_PRESS, GrabMode::ASYNC, GrabMode::ASYNC, x11rb::NONE, x11rb::NONE, x11rb::CURRENT_TIME)
+ .map_err(err)?
+ .reply();
+ self.conn.flush().map_err(err)?;
+
+ self.context_menu = Some((menu, popup));
+ self.redraw_context_menu()
+ }
+
+ /// Repaints every row into the popup window - called once on open
+ /// (there's no live hover highlight here either, same as the Wayland
+ /// backend's own `render_context_menu`) and again on `Expose` (the
+ /// popup has no backing store, so anything that uncovers it needs a
+ /// real repaint, unlike the Wayland side where the buffer is already
+ /// composited).
+ pub(super) fn redraw_context_menu(&mut self) -> PlatformResult<()> {
+ let Some((menu, popup)) = &self.context_menu else { return Ok(()) };
+ let popup = *popup;
+ let (width, row_height, total_height) = (menu.width, menu.row_height, menu.height());
+ let theme = self.wm.borrow().theme;
+ let bg = rgb_to_pixel(theme.titlebar_bg);
+ let fg = rgb_to_pixel(theme.titlebar_fg_focused);
+
+ self.conn.change_gc(self.gc, &ChangeGCAux::new().foreground(bg)).map_err(err)?;
+ self.conn.poly_fill_rectangle(popup, self.gc, &[Rectangle { x: 0, y: 0, width: width as u16, height: total_height as u16 }]).map_err(err)?;
+ self.conn.change_gc(self.gc, &ChangeGCAux::new().foreground(fg).font(self.font)).map_err(err)?;
+
+ for (i, (label, action)) in menu.items.iter().enumerate() {
+ let row_y = i as i32 * row_height as i32;
+ if matches!(action, MenuAction::Separator) {
+ let mid = row_y + row_height as i32 / 2;
+ self.conn.poly_line(CoordMode::ORIGIN, popup, self.gc, &[Point { x: 8, y: mid as i16 }, Point { x: width as i16 - 8, y: mid as i16 }]).map_err(err)?;
+ continue;
+ }
+ self.conn.image_text8(popup, self.gc, 10, (row_y + 20) as i16, label.as_bytes()).map_err(err)?;
+ }
+ self.conn.flush().map_err(err)?;
+ Ok(())
+ }
+
+ /// Closes the currently-open menu, if any - a no-op otherwise, so
+ /// every dismissal path (a row picked, a click outside, the window it
+ /// belongs to closing underneath it) can call this unconditionally.
+ pub(super) fn close_context_menu(&mut self) -> PlatformResult<()> {
+ if let Some((_, popup)) = self.context_menu.take() {
+ self.conn.ungrab_pointer(x11rb::CURRENT_TIME).map_err(err)?;
+ self.conn.destroy_window(popup).map_err(err)?;
+ self.conn.flush().map_err(err)?;
+ }
+ Ok(())
+ }
+
+ /// Runs whichever action a click on `row` selected - the X11
+ /// equivalent of the Wayland backend's `state/menu.rs::run_context_
+ /// menu_action`, same action set minus the Wayland-only foreign-
+ /// toplevel broadcast.
+ pub(super) fn run_context_menu_action(&mut self, window: WindowId, action: MenuAction) -> PlatformResult<()> {
+ match action {
+ MenuAction::Minimize => {
+ self.wm.borrow_mut().minimize_window(window);
+ if let Some(frame) = self.frame_for(window) {
+ self.conn.unmap_window(frame).map_err(err)?;
+ }
+ }
+ MenuAction::ToggleMaximize => {
+ self.wm.borrow_mut().toggle_maximize(window);
+ self.sync_geometry(window)?;
+ }
+ MenuAction::ToggleFullscreen => {
+ self.wm.borrow_mut().toggle_fullscreen(window);
+ self.sync_geometry(window)?;
+ }
+ MenuAction::ToggleFloating => {
+ self.wm.borrow_mut().toggle_floating(window);
+ self.sync_geometry(window)?;
+ }
+ MenuAction::ToggleAlwaysOnTop => {
+ self.wm.borrow_mut().toggle_always_on_top(window);
+ }
+ MenuAction::MoveToWorkspace(workspace) => {
+ self.wm.borrow_mut().move_window_to_workspace(window, workspace);
+ }
+ MenuAction::Close => self.request_close(window)?,
+ MenuAction::Separator => {}
+ }
+ self.conn.flush().map_err(err)?;
+ Ok(())
+ }
+}
diff --git a/crates/x11/src/platform/events.rs b/crates/x11/src/platform/events.rs
index 30e3f8d..10f96a9 100644
--- a/crates/x11/src/platform/events.rs
+++ b/crates/x11/src/platform/events.rs
@@ -70,30 +70,70 @@ impl X11Platform {
}
XEvent::ButtonPress(ev) => {
let (x, y) = (ev.root_x as i32, ev.root_y as i32);
+ let button = match ev.detail {
+ 1 => MouseButton::Left,
+ 2 => MouseButton::Middle,
+ 3 => MouseButton::Right,
+ other => MouseButton::Other(other),
+ };
+
+ // The context menu, if open, captures every press: a click
+ // inside resolves whichever row it landed on, a click
+ // anywhere else just dismisses it - same "one click, one
+ // action" rule the Wayland backend's own input/pointer.rs
+ // follows, and why `open_context_menu` grabs the pointer
+ // (see that method's own doc comment for why X11 needs to).
+ if self.context_menu.is_some() {
+ let (row_action, menu_window) = {
+ let (menu, _) = self.context_menu.as_ref().unwrap();
+ (menu.row_at(x, y).map(|r| menu.items[r].1), menu.window)
+ };
+ match row_action {
+ Some(srdwm_core::context_menu::MenuAction::Separator) => {}
+ Some(action) => {
+ self.close_context_menu()?;
+ self.run_context_menu_action(menu_window, action)?;
+ }
+ None => self.close_context_menu()?,
+ }
+ return Ok(Some(Event::MouseButtonPress { button, x, y }));
+ }
+
let hit = self.wm.borrow().hit_test(x, y);
if let Some((id, hit)) = hit {
self.raise_and_focus(id)?;
- match hit {
- TitlebarHit::Drag => self.wm.borrow_mut().start_drag(id, x, y),
- TitlebarHit::Close => self.request_close(id)?,
- TitlebarHit::Maximize => {
+ match (button, hit) {
+ (MouseButton::Left, TitlebarHit::Drag) => self.wm.borrow_mut().start_drag(id, x, y),
+ (MouseButton::Left, TitlebarHit::Close) => self.request_close(id)?,
+ (MouseButton::Left, TitlebarHit::Maximize) => {
self.wm.borrow_mut().toggle_maximize(id);
self.sync_geometry(id)?;
}
- TitlebarHit::Minimize => {
+ (MouseButton::Left, TitlebarHit::Minimize) => {
self.wm.borrow_mut().minimize_window(id);
if let Some(frame) = self.frame_for(id) {
self.conn.unmap_window(frame).map_err(err)?;
}
}
- TitlebarHit::Resize(edge) => self.wm.borrow_mut().start_resize(id, edge, x, y),
+ (MouseButton::Left, TitlebarHit::Resize(edge)) => self.wm.borrow_mut().start_resize(id, edge, x, y),
+ // Right-click on the titlebar's own drag area: the
+ // window menu (minimize/maximize/fullscreen/
+ // floating/always-on-top/move-to-workspace/close).
+ // Any other button/hit combination - right-click
+ // on a button, middle-click anywhere - just
+ // raises/focuses (already done above) and does
+ // nothing further, matching real desktops (a
+ // right-click on a titlebar button is not itself
+ // a button action).
+ (MouseButton::Right, TitlebarHit::Drag) => self.open_context_menu(id, (x, y))?,
+ _ => {}
}
self.conn.flush().map_err(err)?;
}
// Let the click through to the client (we grabbed it SYNC).
self.conn.allow_events(x11rb::protocol::xproto::Allow::REPLAY_POINTER, ev.time).map_err(err)?;
self.conn.flush().map_err(err)?;
- Ok(Some(Event::MouseButtonPress { button: MouseButton::Left, x, y }))
+ Ok(Some(Event::MouseButtonPress { button, x, y }))
}
XEvent::ButtonRelease(ev) => {
let mut wm = self.wm.borrow_mut();
@@ -141,6 +181,13 @@ impl X11Platform {
Ok(Some(Event::KeyRelease { key_name, modifiers: Self::modifiers_from_state(ev.state.into()) }))
}
XEvent::Expose(ev) => {
+ // The popup has no backing store, unlike the compositor-
+ // side Wayland menu - anything that uncovers it needs a
+ // real repaint.
+ if self.context_menu.as_ref().is_some_and(|(_, popup)| *popup == ev.window) {
+ let _ = self.redraw_context_menu();
+ return Ok(None);
+ }
let target = self.frames.iter().find(|(_, f)| f.frame == ev.window).map(|(&id, _)| id);
if let Some(id) = target {
let w = self.wm.borrow().window(id).cloned_for_render();
diff --git a/crates/x11/src/platform/mod.rs b/crates/x11/src/platform/mod.rs
index 8ca2299..e22b3f7 100644
--- a/crates/x11/src/platform/mod.rs
+++ b/crates/x11/src/platform/mod.rs
@@ -198,6 +198,12 @@ pub struct X11Platform {
/// window) - so this can't be folded into the existing managed-
/// window bookkeeping.
struts: HashMap<XWindow, Strut>,
+ /// The currently-open right-click titlebar window menu, if any --
+ /// the built row set/geometry alongside the override-redirect popup
+ /// window it's drawn into. See `context_menu.rs`'s module doc comment
+ /// for why X11 needs its own popup window and pointer grab where the
+ /// Wayland backend just reads a compositor-space struct.
+ context_menu: Option<(srdwm_core::context_menu::ContextMenu, XWindow)>,
}
@@ -215,6 +221,7 @@ impl ClonedForRender for Option<&CoreWindow> {
mod actions;
mod connect;
+mod context_menu;
mod events;
mod global_menu;
mod struts;
diff --git a/crates/x11/src/platform/window.rs b/crates/x11/src/platform/window.rs
index 997981f..bce71ca 100644
--- a/crates/x11/src/platform/window.rs
+++ b/crates/x11/src/platform/window.rs
@@ -156,6 +156,13 @@ impl X11Platform {
if let Some(frame) = self.frames.remove(&id) {
let _ = self.conn.destroy_window(frame.frame);
}
+ // A closed window's own context menu (opened right before, say, a
+ // client that immediately quits) would otherwise dangle - its
+ // `MenuAction::Close`/etc. would target a `WindowId` `remove_
+ // window` below has already forgotten.
+ if self.context_menu.as_ref().is_some_and(|(menu, _)| menu.window == id) {
+ let _ = self.close_context_menu();
+ }
self.wm.borrow_mut().remove_window(id);
let _ = self.conn.flush();
Some(Event::WindowDestroyed(id))