srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/crates/core/src/window.rs
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2024-05-30 16:10:00 +0200
committersrdusr <[email protected]>2024-05-30 16:10:00 +0200
commit1c175642d073689ca11b9252411ea5f8446007d0 (patch)
tree00b8f9062cf1261d2d2642c059a14ff5790522e5 /crates/core/src/window.rs
parent3d3057ae384ef7389284af8988410889e99c6bb9 (diff)
downloadsrdwm-1c175642d073689ca11b9252411ea5f8446007d0.tar.gz
srdwm-1c175642d073689ca11b9252411ea5f8446007d0.zip
Fix decoration drift during animated transitions; checkpoint IPC/global-menu/output-management work
Border/titlebar decoration was built from Window.geometry (the animation's final target) in both wayland backends' render loops, while sync_geometry already draws a window's actual content at window_anims' interpolated rect during any maximize/fullscreen/open-slide tween. Border and content read two different rectangles for the whole transition, so the border visibly detached from the window it was outlining - reported as "borders aren't flush." Both udev.rs and winit.rs now read the same animated rect for titlebar placement, border-strip placement, and the occlusion test against later windows in stacking order. Verified: cargo build --workspace, cargo clippy (0 new warnings), cargo test -p srdwm-core (111/111). Also checkpoints substantial protocol/IPC work from prior sessions that had accumulated uncommitted: gtk-shell1 support (gtk_shell.rs, the vendored gtk-shell.xml, xwayland.rs's X11-side mirror) backing the global app menu; zwlr_foreign_toplevel_manager_v1 (foreign_toplevel.rs) broadcasting distinct maximized/minimized/fullscreen/activated state per window; output_management (ext-output-management + layer-shell exclusive-zone reservation tracking); workspace.rs and context_menu.rs; a Unix-socket IPC crate (platform/src/ ipc.rs) and a `srd` control-CLI crate (crates/ctl); xkb_config.rs; and a theme module (core/src/theme.rs). A peer session working the AGS shell concurrently verified several of these live against a running srdwm: the global menu rendering a real app's File/Edit menu over gtk-shell1, and foreign-toplevel correctly reporting maximized and fullscreen as independent, non-simultaneous states with the geometry each implies (maximize stops at a reserved top bar and past a dock; fullscreen reaches the true monitor edge).
Diffstat (limited to 'crates/core/src/window.rs')
-rw-r--r--crates/core/src/window.rs236
1 files changed, 225 insertions, 11 deletions
diff --git a/crates/core/src/window.rs b/crates/core/src/window.rs
index 9484709..1a4cab5 100644
--- a/crates/core/src/window.rs
+++ b/crates/core/src/window.rs
@@ -2,6 +2,61 @@ use crate::geometry::Rect;
pub type WindowId = u64;
+/// A window's exported application/window menu, as a D-Bus *address* --
+/// bus name plus object paths - never the menu's actual content.
+///
+/// The content is a `GMenuModel` already exported over `org.gtk.Menus`/
+/// `org.gtk.Actions`, which GTK4 consumes natively (`Gio.DBusMenuModel`,
+/// `Gtk.PopoverMenuBar.new_from_model()`) with full submenus, toggles,
+/// accelerators and icons - carrying the model itself over a Wayland
+/// protocol instead would mean hand-marshalling and hand-rendering it for
+/// strictly worse fidelity. These four strings are the only part a
+/// compositor can supply that a client-side global-menu shell can't get
+/// any other way: on XWayland this is `_GTK_UNIQUE_BUS_NAME`/
+/// `_GTK_MENUBAR_OBJECT_PATH`/`_GTK_APPLICATION_OBJECT_PATH`/
+/// `_GTK_WINDOW_OBJECT_PATH`; on Wayland-native surfaces it's GTK's own
+/// private `gtk_shell1` protocol's `gtk_surface1.set_dbus_properties`
+/// request, which carries the identical four fields under different
+/// names. See `crates/wayland/src/xwayland.rs` and `gtk_shell.rs` for
+/// where each backend actually populates this.
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
+pub struct GlobalMenu {
+ pub bus_name: String,
+ /// The app or window's menu bar, whichever the client exported --
+ /// `menubar_path` (a full menu bar) if set, else `app_menu_path` (just
+ /// the single app-level menu older/simpler clients export instead).
+ /// Which of the two (or the pre-`_GTK_*` Unity path) actually won is
+ /// [`Self::source`] - load-bearing, not cosmetic: see its own doc
+ /// comment.
+ pub menu_path: Option<String>,
+ pub app_path: Option<String>,
+ pub window_path: Option<String>,
+ pub source: MenuSource,
+}
+
+/// Which export flavour [`GlobalMenu::menu_path`] actually came from --
+/// the two address their actions under different D-Bus action-group
+/// prefixes, and getting this wrong doesn't fail loudly: the menu still
+/// renders, every item just comes up permanently insensitive, which reads
+/// exactly like an app that exported a broken menu rather than a
+/// consumer that resolved the wrong prefix.
+///
+/// - [`MenuSource::Gtk`]: a real `GMenuModel`. Items reference actions as
+/// `app.xxx`/`win.xxx`; a consumer must insert two action groups, under
+/// prefixes `"app"` and `"win"`, from [`GlobalMenu::app_path`]/
+/// [`GlobalMenu::window_path`] respectively.
+/// - [`MenuSource::Unity`]: the older Ubuntu Unity-era export
+/// (`_UNITY_OBJECT_PATH`, still relevant for some Qt platform-theme
+/// builds). Items reference actions as `unity.xxx`, all against one
+/// group at the menu's own path - a consumer inserts a single group
+/// under prefix `"unity"` instead.
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
+pub enum MenuSource {
+ #[default]
+ Gtk,
+ Unity,
+}
+
/// State of a single managed window. This is platform-independent: backends
/// (X11, Wayland, ...) own the real surface/client handle and keep a `Window`
/// in sync with it via `srdwm_core::WindowManager`.
@@ -10,12 +65,26 @@ pub struct Window {
pub id: WindowId,
pub title: String,
pub app_id: String,
+ /// X11 `WM_CLASS`'s *instance* half (`WM_CLASS` is `"instance\0class\0"`;
+ /// `app_id` above holds the class half, matching Wayland's `app_id`
+ /// concept). Always empty on Wayland/XWayland, which has no equivalent.
+ pub instance: String,
pub geometry: Rect,
/// Geometry to restore to when un-maximizing.
pub restore_geometry: Option<Rect>,
pub decorated: bool,
+ /// `decorated`'s value from just before entering fullscreen, restored
+ /// on exit - see `WindowManager::toggle_fullscreen`'s doc comment on
+ /// why this can't just hardcode `true` back.
+ pub restore_decorated: Option<bool>,
pub floating: bool,
pub minimized: bool,
+ /// Whether this window belongs to the scratchpad pool - see
+ /// `WindowManager::scratchpad_add`/`scratchpad_show`'s doc comments.
+ /// Persists across show/hide toggles (`minimized` is what actually
+ /// gates visibility); a window never sets this itself, only `srd.window.
+ /// scratchpad()`/the equivalent keybinding does.
+ pub scratchpad: bool,
pub maximized: bool,
pub fullscreen: bool,
pub always_on_top: bool,
@@ -23,6 +92,28 @@ pub struct Window {
pub border_width: u32,
pub workspace: usize,
pub monitor: u32,
+ /// Whether `WindowManager`'s class/title-matched rules have already
+ /// been evaluated (and, if matched, applied) for this window.
+ ///
+ /// `add_window` matches rules once, at creation, but a native Wayland
+ /// client's `title`/`app_id` are still empty at that moment (they
+ /// arrive on a later commit - see the Wayland backend's
+ /// `sync_toplevel_metadata` doc comment); matching then would silently
+ /// fail every class-based rule. Left `false` so a backend can retry
+ /// the match once real identity is known, without ever re-matching
+ /// after that (rule actions apply once, not on every subsequent title
+ /// change).
+ pub rules_applied: bool,
+ /// Set by `WindowManager::toggle_maximize`/`toggle_fullscreen` to the
+ /// geometry `self.geometry` just moved *from*, whenever that move
+ /// should be animated. A backend's `sync_geometry` takes (reads and
+ /// clears) this once per change to start a tween toward the new
+ /// `geometry`; left `None` for changes that must track 1:1 instead
+ /// (interactive drag/resize), which never set it.
+ pub anim_from: Option<Rect>,
+ /// This window's global-menu D-Bus address, if the client has exported
+ /// one. See [`GlobalMenu`]'s own doc comment.
+ pub global_menu: Option<GlobalMenu>,
}
impl Window {
@@ -31,11 +122,14 @@ impl Window {
id,
title: title.into(),
app_id: String::new(),
+ instance: String::new(),
geometry: Rect::new(0, 0, 640, 480),
restore_geometry: None,
decorated: true,
+ restore_decorated: None,
floating: false,
minimized: false,
+ scratchpad: false,
maximized: false,
fullscreen: false,
always_on_top: false,
@@ -43,6 +137,9 @@ impl Window {
border_width: 2,
workspace: 0,
monitor: 0,
+ rules_applied: false,
+ anim_from: None,
+ global_menu: None,
}
}
}
@@ -59,6 +156,25 @@ pub const TITLEBAR_HEIGHT: u32 = 30;
/// costs a few pixels of client edge; that is the right trade for making
/// resize reliably grabbable without a keyboard.
pub const RESIZE_MARGIN: i32 = 10;
+/// Top-edge resize margin for an *undecorated* window specifically --
+/// narrower than [`RESIZE_MARGIN`] on purpose.
+///
+/// An undecorated (client-side-decorated) window has no titlebar band for
+/// srdwm to treat as a drag handle - Firefox's own tab strip, concretely --
+/// so the client's own header area sits directly at `frame.y` with nothing
+/// srdwm-drawn to grab. The client detects a drag on its own header and
+/// asks to be moved via `xdg_toplevel.move`, but only for clicks that
+/// actually reach it as a normal button press; the full 10px `RESIZE_MARGIN`
+/// swallowed every click within the first 10 rows of the window - including
+/// most of a typical natural grab point near the top of a tab strip - as a
+/// top-edge resize instead, so the client's own move request never fired.
+/// Reported live as "can't drag-move Firefox from its own top bar."
+/// Resize-from-the-top-edge still works (a deliberate earlier trade-off --
+/// see `undecorated_window_still_resizes_from_every_edge_including_top`'s
+/// own comment - since an undecorated window is still a window), just from
+/// a much narrower band that a click meant to grab the tab strip is very
+/// unlikely to land in by accident.
+pub const UNDECORATED_TOP_RESIZE_MARGIN: i32 = 3;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResizeEdge {
@@ -75,11 +191,38 @@ pub enum ResizeEdge {
impl ResizeEdge {
/// Determine which titlebar button (if any) a point within the titlebar
/// band falls on. Buttons are laid out right-aligned: close, maximize, minimize.
- pub fn hit_test(frame: Rect, x: i32, y: i32) -> Option<TitlebarHit> {
- if !frame.contains_point(x, y) {
+ ///
+ /// `decorated` must reflect the window's *actual* current state, not
+ /// just whether it usually draws one: `frame` always reserves
+ /// `TITLEBAR_HEIGHT` at the top regardless of whether anything is drawn
+ /// there (placement never shrinks a window's allocated geometry just
+ /// because a rule or CSD negotiation later turns decoration off - see
+ /// `sync_geometry`'s own doc comment on that split). Applying the
+ /// titlebar-band/button logic unconditionally meant an *undecorated*
+ /// window's own content in that top band - Firefox's tab strip and URL
+ /// bar, concretely, once `decorated = false` actually started applying
+ /// to it - silently ate every click there as a phantom
+ /// drag/close/maximize/minimize hit instead of ever reaching the
+ /// client. Resize-from-edge still applies either way: an undecorated
+ /// window is still a window, and dragging its (invisible) edge to
+ /// resize is still expected to work.
+ pub fn hit_test(frame: Rect, x: i32, y: i32, decorated: bool, border_width: u32) -> Option<TitlebarHit> {
+ // Border strips render *outside* `frame` (`decoration::
+ // border_strips`, `border_width` pixels past each edge) - without
+ // widening the containment check to match, those visible pixels
+ // were a dead zone: `frame.contains_point` rejected them outright,
+ // so hovering the border itself (not just just inside it) showed no
+ // resize cursor and couldn't be grabbed, even though it's what
+ // visually reads as the window's actual edge. `resize_edge_at`
+ // itself needs no matching change - its margin comparisons
+ // (`x <= frame.x + m`, etc.) already treat anything at or outside
+ // `frame`'s own edge as maximally "near", border pixels included.
+ let bw = border_width as i32;
+ let outer = Rect::new(frame.x - bw, frame.y - bw, frame.width + 2 * border_width, frame.height + 2 * border_width);
+ if !outer.contains_point(x, y) {
return None;
}
- if y < frame.y + TITLEBAR_HEIGHT as i32 {
+ if decorated && y < frame.y + TITLEBAR_HEIGHT as i32 {
const BUTTON: i32 = TITLEBAR_HEIGHT as i32;
let right = frame.right();
if x >= right - BUTTON {
@@ -93,15 +236,16 @@ impl ResizeEdge {
}
return Some(TitlebarHit::Drag);
}
- let edge = Self::resize_edge_at(frame, x, y)?;
+ let edge = Self::resize_edge_at(frame, x, y, decorated)?;
Some(TitlebarHit::Resize(edge))
}
- fn resize_edge_at(frame: Rect, x: i32, y: i32) -> Option<ResizeEdge> {
+ fn resize_edge_at(frame: Rect, x: i32, y: i32, decorated: bool) -> Option<ResizeEdge> {
let m = RESIZE_MARGIN;
+ let top_m = if decorated { m } else { UNDECORATED_TOP_RESIZE_MARGIN };
let near_left = x <= frame.x + m;
let near_right = x >= frame.right() - m;
- let near_top = y <= frame.y + m;
+ let near_top = y <= frame.y + top_m;
let near_bottom = y >= frame.bottom() - m;
Some(match (near_left, near_right, near_top, near_bottom) {
(true, _, true, _) => ResizeEdge::TopLeft,
@@ -110,6 +254,7 @@ impl ResizeEdge {
(_, true, _, true) => ResizeEdge::BottomRight,
(true, false, false, false) => ResizeEdge::Left,
(false, true, false, false) => ResizeEdge::Right,
+ (false, false, true, false) => ResizeEdge::Top,
(false, false, false, true) => ResizeEdge::Bottom,
_ => return None,
})
@@ -173,14 +318,14 @@ mod tests {
#[test]
fn close_button_is_top_right_corner_of_titlebar() {
let f = frame();
- let hit = ResizeEdge::hit_test(f, f.right() - 5, f.y + 5);
+ let hit = ResizeEdge::hit_test(f, f.right() - 5, f.y + 5, true, 0);
assert_eq!(hit, Some(TitlebarHit::Close));
}
#[test]
fn maximize_is_left_of_close() {
let f = frame();
- let hit = ResizeEdge::hit_test(f, f.right() - TITLEBAR_HEIGHT as i32 - 5, f.y + 5);
+ let hit = ResizeEdge::hit_test(f, f.right() - TITLEBAR_HEIGHT as i32 - 5, f.y + 5, true, 0);
assert_eq!(hit, Some(TitlebarHit::Maximize));
}
@@ -188,21 +333,90 @@ mod tests {
fn middle_of_titlebar_is_drag() {
let f = frame();
let (cx, _) = f.center();
- let hit = ResizeEdge::hit_test(f, cx, f.y + 5);
+ let hit = ResizeEdge::hit_test(f, cx, f.y + 5, true, 0);
assert_eq!(hit, Some(TitlebarHit::Drag));
}
#[test]
fn bottom_right_corner_is_resize() {
let f = frame();
- let hit = ResizeEdge::hit_test(f, f.right() - 1, f.bottom() - 1);
+ let hit = ResizeEdge::hit_test(f, f.right() - 1, f.bottom() - 1, true, 0);
assert_eq!(hit, Some(TitlebarHit::Resize(ResizeEdge::BottomRight)));
}
+ /// The bug this guards against: an undecorated window's own content in
+ /// its top `TITLEBAR_HEIGHT` band (Firefox's tab strip/URL bar, once
+ /// `decorated = false` actually applies to it) was silently swallowed
+ /// as a phantom drag hit instead of ever reaching the client, since the
+ /// titlebar-band check used to run unconditionally.
+ #[test]
+ fn undecorated_window_has_no_titlebar_band() {
+ let f = frame();
+ let (cx, _) = f.center();
+ // Inside the old phantom titlebar band (< TITLEBAR_HEIGHT) but
+ // outside RESIZE_MARGIN, so a real resize edge can't also explain a
+ // `None` here - undecorated, this must not be treated as
+ // decoration (or a resize edge) at all, just plain content.
+ let hit = ResizeEdge::hit_test(f, cx, f.y + 20, false, 0);
+ assert_eq!(hit, None);
+ }
+
+ #[test]
+ fn undecorated_window_still_resizes_from_every_edge_including_top() {
+ let f = frame();
+ let (cx, _) = f.center();
+ let hit = ResizeEdge::hit_test(f, cx, f.y + 1, false, 0);
+ assert_eq!(hit, Some(TitlebarHit::Resize(ResizeEdge::Top)));
+ }
+
+ #[test]
+ fn undecorated_top_resize_band_is_much_narrower_than_decorated() {
+ // Regression test: an undecorated window's own header (Firefox's
+ // tab strip, concretely) has no srdwm-drawn titlebar to grab, so a
+ // click meant to drag-move it via the client's own `xdg_toplevel.
+ // move` has to actually reach the client - the full `RESIZE_MARGIN`
+ // (10px) swallowed most of a natural grab point near the top as a
+ // resize instead. A *decorated* window's top band is unaffected --
+ // it already has TITLEBAR_HEIGHT worth of unambiguous drag space
+ // above where `RESIZE_MARGIN` even starts to matter.
+ let f = frame();
+ let (cx, _) = f.center();
+ assert_eq!(ResizeEdge::hit_test(f, cx, f.y + 5, false, 0), None, "5px in: past the narrow undecorated band, must reach the client");
+ assert_eq!(
+ ResizeEdge::hit_test(f, cx, f.y + 5, true, 0),
+ Some(TitlebarHit::Drag),
+ "decorated: 5px in is still well inside the titlebar band, not a resize edge"
+ );
+ }
+
#[test]
fn outside_frame_is_none() {
let f = frame();
- assert_eq!(ResizeEdge::hit_test(f, 0, 0), None);
+ assert_eq!(ResizeEdge::hit_test(f, 0, 0, true, 0), None);
+ }
+
+ #[test]
+ fn border_pixels_are_hoverable_not_a_dead_zone() {
+ // Regression test: `decoration::border_strips` draws the border
+ // `border_width` pixels *outside* `frame`, but hit-testing only
+ // checked `frame` itself - so the visible border was a dead zone
+ // that showed no resize cursor and couldn't be grabbed, even
+ // though it's what visually reads as the window's edge.
+ let f = frame();
+ let (_, cy) = f.center();
+ let border_width = 2;
+ // One pixel into the border strip, past the left edge.
+ let x = f.x - 1;
+ assert_eq!(ResizeEdge::hit_test(f, x, cy, true, 0), None, "sanity check: with no border, this point really is outside the window");
+ assert_eq!(
+ ResizeEdge::hit_test(f, x, cy, true, border_width),
+ Some(TitlebarHit::Resize(ResizeEdge::Left)),
+ "one pixel into the actual drawn border must still register as the left edge"
+ );
+ // Just past the border entirely (border_width + 1 outside frame) is
+ // still nothing - the fix widens the dead zone's boundary, it
+ // doesn't remove it.
+ assert_eq!(ResizeEdge::hit_test(f, f.x - border_width as i32 - 1, cy, true, border_width), None);
}
#[test]