srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/crates/x11/src/platform/context_menu.rs
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2025-08-25 11:47:00 +0200
committersrdusr <[email protected]>2025-08-25 11:47:00 +0200
commitbd7141718901c6f41511137e4b34a3bd9e2705b1 (patch)
tree672811318065eb97a822c1f7cec97b001a336900 /crates/x11/src/platform/context_menu.rs
parent72e787a706d372c9a92e424e9b13ece929cb2bce (diff)
downloadsrdwm-bd7141718901c6f41511137e4b34a3bd9e2705b1.tar.gz
srdwm-bd7141718901c6f41511137e4b34a3bd9e2705b1.zip
X11 backend: right-click titlebar window menu, matching Wayland's own
Closes the one real gap an X11/Wayland feature-parity audit found this session (desktop icons, window-position memory, and static exclusive-zone reservation were already shared or Wayland-only by nature - see docs/TODO.md's own audit entry for the full breakdown). MenuAction/ContextMenu (row set, labels, row_at hit-testing) move from crates/wayland/src/context_menu.rs into crates/core/src/context_menu.rs -- pure state and geometry with nothing Wayland-specific in it, so X11 needing the same rows is shared data, not duplicated logic. The Wayland crate's own context_menu.rs is now a one-line re-export so every existing crate::context_menu::... call site keeps working unchanged. X11 has no compositor-level input dispatch to intercept every click the way Wayland's input/pointer.rs does, so the X11 side (crates/x11/src/platform/context_menu.rs, new) draws the menu into its own small override-redirect popup window and grabs the pointer for the duration so a click anywhere dismisses it, matching the Wayland backend's own convention. events.rs's ButtonPress handler now reads the real button number instead of hardcoding every press as a left click - a real latent bug (right-clicking a titlebar button would have silently performed its left-click action). Live-verified end to end in an isolated Xvfb + srdwm --x11 instance: full row set including the workspace picker, Minimize runs and closes the menu, a second window's menu dismisses cleanly on outside click, normal focus/click behaviour continues working afterward. See docs/TODO.md for the full investigation and verification narrative.
Diffstat (limited to 'crates/x11/src/platform/context_menu.rs')
-rw-r--r--crates/x11/src/platform/context_menu.rs153
1 files changed, 153 insertions, 0 deletions
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(())
+ }
+}