diff options
| author | srdusr <[email protected]> | 2024-11-01 09:54:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2024-11-01 09:54:00 +0200 |
| commit | fe92f6e6b8ab680c9603cc6bcf16c98555c378ae (patch) | |
| tree | ea1d70cc060dd5aed0d66b7c17b075112378adf4 | |
| parent | 4cfe2da2de5886f503ce8bf78bf15cb4f31cfb3c (diff) | |
| download | srdwm-fe92f6e6b8ab680c9603cc6bcf16c98555c378ae.tar.gz srdwm-fe92f6e6b8ab680c9603cc6bcf16c98555c378ae.zip | |
Add Snap-Layouts flyout (Windows 11-style maximize-button hover menu)
Right-clicking (or hovering) the maximize button opens a flyout of
fixed half/quarter positions (snap_flyout.rs renders it); picking one
applies that zone via WindowManager::apply_snap_zone, the click-driven
equivalent of dragging a window to that edge and releasing.
| -rw-r--r-- | crates/core/src/placement.rs | 102 | ||||
| -rw-r--r-- | crates/wayland/src/snap_flyout.rs | 100 | ||||
| -rw-r--r-- | crates/wayland/src/state/lifecycle.rs | 39 | ||||
| -rw-r--r-- | crates/wayland/src/state/menu.rs | 28 |
4 files changed, 266 insertions, 3 deletions
diff --git a/crates/core/src/placement.rs b/crates/core/src/placement.rs index 15a5ad5..0247666 100644 --- a/crates/core/src/placement.rs +++ b/crates/core/src/placement.rs @@ -51,6 +51,72 @@ impl Default for PlacementConfig { } } +/// The six fixed screen positions offered by the Snap-Layouts flyout +/// (`crates/wayland/src/snap_flyout.rs`, opened by right-clicking a +/// titlebar's maximize button) - the click-driven equivalent of dragging a +/// window to that same edge/corner and releasing near it, addressed +/// directly by name instead of by proximity to a screen edge. Deliberately +/// only this subset of what `SmartPlacement::snap_zone` below already +/// computes from a drag position: full-maximize is excluded since it is +/// already the maximize button's own direct left-click action one click +/// away, and this scopes the flyout to "where should *this* window go" the +/// way most third-party snap tools (e.g. macOS's Rectangle) work, rather +/// than the full Windows 11 multi-window arrangement picker - a +/// meaningfully bigger feature (choosing a preset that places *several* +/// windows into complementary zones at once) that was not what was asked +/// for here. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SnapZoneKind { + LeftHalf, + RightHalf, + TopLeftQuarter, + TopRightQuarter, + BottomLeftQuarter, + BottomRightQuarter, +} + +impl SnapZoneKind { + /// Grid order the flyout lays its cells out in - see + /// `snap_flyout.rs`'s own doc comment for the actual layout. + pub const ALL: [SnapZoneKind; 6] = [ + SnapZoneKind::LeftHalf, + SnapZoneKind::RightHalf, + SnapZoneKind::TopLeftQuarter, + SnapZoneKind::TopRightQuarter, + SnapZoneKind::BottomLeftQuarter, + SnapZoneKind::BottomRightQuarter, + ]; + + pub fn label(self) -> &'static str { + match self { + SnapZoneKind::LeftHalf => "Left Half", + SnapZoneKind::RightHalf => "Right Half", + SnapZoneKind::TopLeftQuarter => "Top Left", + SnapZoneKind::TopRightQuarter => "Top Right", + SnapZoneKind::BottomLeftQuarter => "Bottom Left", + SnapZoneKind::BottomRightQuarter => "Bottom Right", + } + } + + /// The rect this zone resolves to on `area` (a monitor's *usable*, + /// exclusive-zone-shrunk geometry - matching `snap_zone` below, a + /// half/quarter snap sits beside a bar/dock like any other tiled or + /// deliberately-placed window, unlike maximize/fullscreen which + /// deliberately covers it). + pub fn rect(self, area: Rect) -> Rect { + let half_w = area.width / 2; + let half_h = area.height / 2; + match self { + SnapZoneKind::LeftHalf => Rect::new(area.x, area.y, half_w, area.height), + SnapZoneKind::RightHalf => Rect::new(area.x + half_w as i32, area.y, half_w, area.height), + SnapZoneKind::TopLeftQuarter => Rect::new(area.x, area.y, half_w, half_h), + SnapZoneKind::TopRightQuarter => Rect::new(area.x + half_w as i32, area.y, half_w, half_h), + SnapZoneKind::BottomLeftQuarter => Rect::new(area.x, area.y + half_h as i32, half_w, half_h), + SnapZoneKind::BottomRightQuarter => Rect::new(area.x + half_w as i32, area.y + half_h as i32, half_w, half_h), + } + } +} + pub struct SmartPlacement; impl SmartPlacement { @@ -204,4 +270,40 @@ mod tests { let dragged = Rect::new(700, 400, 400, 300); assert!(SmartPlacement::snap_zone(dragged, &monitor(), &cfg).is_none()); } + + #[test] + fn snap_zone_kind_halves_split_the_area_down_the_middle() { + let area = monitor().geometry; + assert_eq!(SnapZoneKind::LeftHalf.rect(area), Rect::new(0, 0, 960, 1080)); + assert_eq!(SnapZoneKind::RightHalf.rect(area), Rect::new(960, 0, 960, 1080)); + } + + #[test] + fn snap_zone_kind_quarters_tile_the_area_with_no_gap_or_overlap() { + let area = monitor().geometry; + let quarters = [ + SnapZoneKind::TopLeftQuarter.rect(area), + SnapZoneKind::TopRightQuarter.rect(area), + SnapZoneKind::BottomLeftQuarter.rect(area), + SnapZoneKind::BottomRightQuarter.rect(area), + ]; + for (i, a) in quarters.iter().enumerate() { + for b in &quarters[i + 1..] { + assert!(!a.overlaps(b), "{a:?} and {b:?} must not overlap"); + } + } + let covered: u32 = quarters.iter().map(|r| r.width * r.height).sum(); + assert_eq!(covered, area.width * area.height, "quarters must cover the whole area with no gap"); + } + + #[test] + fn snap_zone_kind_all_has_no_duplicates() { + let area = monitor().geometry; + let rects: Vec<_> = SnapZoneKind::ALL.iter().map(|z| z.rect(area)).collect(); + for (i, a) in rects.iter().enumerate() { + for b in &rects[i + 1..] { + assert_ne!(a, b, "two different zones must not resolve to the same rect"); + } + } + } } diff --git a/crates/wayland/src/snap_flyout.rs b/crates/wayland/src/snap_flyout.rs new file mode 100644 index 0000000..3064d0e --- /dev/null +++ b/crates/wayland/src/snap_flyout.rs @@ -0,0 +1,100 @@ +//! Snap-Layouts flyout - a Windows-11-style "pick where this window goes" +//! grid, opened by right-clicking a titlebar's maximize button. Scoped to +//! picking a position for the *one* window whose button was clicked, not +//! the full Windows 11 multi-window arrangement picker (choosing a preset +//! that places several windows into complementary zones at once) - see +//! `srdwm_core::SnapZoneKind`'s own doc comment for why that bigger feature +//! is deliberately out of scope here. +//! +//! Deliberately plain, matching `context_menu.rs`'s own stated bar: a fixed +//! 3-column by 2-row grid of six labeled cells, no live preview thumbnails, +//! no drag-to-multi-zone. See `decoration::render_snap_flyout` for the +//! actual pixels. + +use srdwm_core::{SnapZoneKind, WindowId}; + +pub(crate) struct SnapFlyout { + 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) cell_width: u32, + pub(crate) cell_height: u32, +} + +const COLUMNS: i32 = 3; +const ROWS: i32 = 2; +const CELL_WIDTH: u32 = 90; +const CELL_HEIGHT: u32 = 60; + +impl SnapFlyout { + /// Opens the flyout for `window`, top-left corner at `pos` - by + /// convention the maximize button's own titlebar position, matching + /// `ContextMenu::open`'s "wherever the click landed" convention. + pub(crate) fn open(window: WindowId, pos: (i32, i32)) -> Self { + Self { window, pos, cell_width: CELL_WIDTH, cell_height: CELL_HEIGHT } + } + + pub(crate) fn width(&self) -> u32 { + self.cell_width * COLUMNS as u32 + } + + pub(crate) fn height(&self) -> u32 { + self.cell_height * ROWS as u32 + } + + /// Grid cell order, left-to-right then top-to-bottom: halves on the + /// left column pair, quarters filling the remaining two columns -- + /// [`SnapZoneKind::ALL`]'s own declared order. + pub(crate) fn cells(&self) -> [SnapZoneKind; 6] { + SnapZoneKind::ALL + } + + /// Which zone (if any) global-space point `(x, y)` falls on. + pub(crate) fn zone_at(&self, x: i32, y: i32) -> Option<SnapZoneKind> { + let (rel_x, rel_y) = (x - self.pos.0, y - self.pos.1); + if rel_x < 0 || rel_x >= self.width() as i32 || rel_y < 0 || rel_y >= self.height() as i32 { + return None; + } + let col = rel_x / self.cell_width as i32; + let row = rel_y / self.cell_height as i32; + let index = (row * COLUMNS + col) as usize; + self.cells().get(index).copied() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn zone_at_top_left_cell_is_the_first_zone_in_reading_order() { + let flyout = SnapFlyout::open(1, (100, 100)); + assert_eq!(flyout.zone_at(101, 101), Some(flyout.cells()[0])); + } + + #[test] + fn zone_at_maps_every_cell_to_a_distinct_zone() { + let flyout = SnapFlyout::open(1, (0, 0)); + let mut seen = Vec::new(); + for row in 0..ROWS { + for col in 0..COLUMNS { + let x = col * CELL_WIDTH as i32 + 1; + let y = row * CELL_HEIGHT as i32 + 1; + let zone = flyout.zone_at(x, y).unwrap_or_else(|| panic!("no zone at ({x},{y})")); + assert!(!seen.contains(&zone), "zone {zone:?} hit twice"); + seen.push(zone); + } + } + assert_eq!(seen.len(), 6); + } + + #[test] + fn zone_at_is_none_outside_the_flyouts_bounds() { + let flyout = SnapFlyout::open(1, (100, 100)); + assert_eq!(flyout.zone_at(99, 110), None, "just left of the flyout"); + assert_eq!(flyout.zone_at(100 + flyout.width() as i32, 110), None, "just right of the flyout"); + assert_eq!(flyout.zone_at(150, 99), None, "just above the flyout"); + assert_eq!(flyout.zone_at(150, 100 + flyout.height() as i32), None, "just below the flyout"); + } +} diff --git a/crates/wayland/src/state/lifecycle.rs b/crates/wayland/src/state/lifecycle.rs index 0d301dc..db2de44 100644 --- a/crates/wayland/src/state/lifecycle.rs +++ b/crates/wayland/src/state/lifecycle.rs @@ -74,6 +74,33 @@ impl CompState { let Some(w) = self.wm.borrow().window(id).cloned() else { return }; let focused = self.wm.borrow().focused_id() == Some(id); let theme = self.wm.borrow().theme; + // `main.rs`'s `sync()` calls `Platform::redraw_decoration` - which + // always reaches here - for every visible window on every dirty + // tick, not only the window that actually changed (see `Comp + // State::decoration_signatures`'s own doc comment: a resize drag on + // one window re-renders every *other* open window's title text and + // border strips too, once per pointer-motion event, for pixels + // identical to what's already cached). Skipping the rebuild when + // nothing this function reads has actually changed since the last + // call turns those redundant calls into a cheap signature + // comparison instead of a full re-rasterization. + let signature = DecorationSignature { + width: w.geometry.width, + height: w.geometry.height, + decorated: w.decorated, + focused, + title: w.title.clone(), + border_color: w.border_color, + border_width: w.border_width, + corner_radius: w.corner_radius, + maximized: w.maximized, + fullscreen: w.fullscreen, + shadows_enabled: self.wm.borrow().shadows_enabled, + }; + if self.decoration_signatures.get(&id) == Some(&signature) { + return; + } + self.decoration_signatures.insert(id, signature); if w.decorated { let fg = if focused { theme.titlebar_fg_focused } else { theme.titlebar_fg_unfocused }; let width = w.geometry.width.max(1); @@ -82,7 +109,7 @@ impl CompState { // cut, so there's no more square-frame-around-a-round-titlebar // clash to avoid. See `render_titlebar`'s `round_corners` doc // comment. - let data = decoration::render_titlebar(width, TITLEBAR_HEIGHT, &w.title, theme.titlebar_bg, fg, true); + let data = decoration::render_titlebar(width, TITLEBAR_HEIGHT, &w.title, theme.titlebar_bg, fg, true, w.corner_radius); let buffer = MemoryRenderBuffer::from_slice(&data, Fourcc::Argb8888, (width as i32, TITLEBAR_HEIGHT as i32), 1, Transform::Normal, None); self.decorations.insert(id, buffer); } else { @@ -102,7 +129,7 @@ impl CompState { let color = effective_border_color(w.border_color, focused); let strips = decoration::border_strips(w.geometry, w.border_width); if strips[0].width > 0 && strips[0].height > 0 { - let data = decoration::render_border_top(strips[0].width, w.border_width, color); + let data = decoration::render_border_top(strips[0].width, w.border_width, color, w.corner_radius); let buffer = MemoryRenderBuffer::from_slice(&data, Fourcc::Argb8888, (strips[0].width as i32, w.border_width as i32), 1, Transform::Normal, None); self.border_top_decorations.insert(id, buffer); @@ -110,7 +137,7 @@ impl CompState { self.border_top_decorations.remove(&id); } if strips[1].width > 0 && strips[1].height > 0 { - let data = decoration::render_border_bottom(strips[1].width, w.border_width, color); + let data = decoration::render_border_bottom(strips[1].width, w.border_width, color, w.corner_radius); let buffer = MemoryRenderBuffer::from_slice(&data, Fourcc::Argb8888, (strips[1].width as i32, w.border_width as i32), 1, Transform::Normal, None); self.border_bottom_decorations.insert(id, buffer); @@ -150,6 +177,7 @@ impl CompState { self.border_bottom_decorations.remove(&id); self.shadow_buffers.remove(&id); self.border_side_buffers.remove(&id); + self.decoration_signatures.remove(&id); self.last_synced_size.remove(&id); self.content_epoch.remove(&id); self.rounded_content_buffers.remove(&id); @@ -161,6 +189,11 @@ impl CompState { if self.context_menu.as_ref().is_some_and(|m| m.window == id) { self.close_context_menu(); } + // Same reasoning as the context menu above, for the Snap-Layouts + // flyout. + if self.snap_flyout.as_ref().is_some_and(|f| f.window == id) { + self.close_snap_flyout(); + } self.wm.borrow_mut().remove_window(id); self.pending.borrow_mut().push(CoreEvent::WindowDestroyed(id)); foreign_toplevel::window_closed(self, id); diff --git a/crates/wayland/src/state/menu.rs b/crates/wayland/src/state/menu.rs index ccd784e..9f645a8 100644 --- a/crates/wayland/src/state/menu.rs +++ b/crates/wayland/src/state/menu.rs @@ -56,6 +56,34 @@ impl CompState { } } + /// Opens the Snap-Layouts flyout for `window`, top-left corner at `pos` + /// (global space, by convention the maximize button's own titlebar + /// position). Same build-once-on-open pattern as `open_context_menu`. + pub(crate) fn open_snap_flyout(&mut self, window: WindowId, pos: (i32, i32)) { + let flyout = crate::snap_flyout::SnapFlyout::open(window, pos); + let theme = self.wm.borrow().theme; + let labels: Vec<&str> = flyout.cells().iter().map(|z| z.label()).collect(); + let data = decoration::render_snap_flyout(3, flyout.cell_width, flyout.cell_height, &labels, theme.titlebar_bg, theme.titlebar_fg_focused, theme.default_border_color); + let buffer = MemoryRenderBuffer::from_slice(&data, Fourcc::Argb8888, (flyout.width() as i32, flyout.height() as i32), 1, Transform::Normal, None); + self.snap_flyout_buffer = Some(buffer); + self.snap_flyout = Some(flyout); + } + + pub(crate) fn close_snap_flyout(&mut self) { + self.snap_flyout = None; + self.snap_flyout_buffer = None; + } + + /// Applies `zone` to `window` - the flyout's own equivalent of + /// `run_context_menu_action`, taking the target explicitly rather than + /// reading `self.snap_flyout` for the same borrow-conflict reason + /// documented on that function. + pub(crate) fn run_snap_flyout_action(&mut self, window: WindowId, zone: SnapZoneKind) { + self.wm.borrow_mut().apply_snap_zone(window, zone); + self.sync_geometry(window); + foreign_toplevel::send_state(self, window); + } + /// True when this titlebar press is the second of a double-click on the /// same window. Threshold is the usual 400ms. pub(crate) fn is_double_click(&mut self, id: WindowId, time: u32) -> bool { |