diff options
| author | srdusr <[email protected]> | 2026-04-27 00:18:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2026-04-27 00:18:00 +0200 |
| commit | c4dc99cc3211c4dc1a461f228402be1593b883c9 (patch) | |
| tree | 10a85d60acb863ca98b7e70118dec5cda357efa6 /crates/wayland/src | |
| parent | 436d42da6ef61a5ea20d5102c4baed7bf0993606 (diff) | |
| download | srdwm-c4dc99cc3211c4dc1a461f228402be1593b883c9.tar.gz srdwm-c4dc99cc3211c4dc1a461f228402be1593b883c9.zip | |
Build the eight asks recovered from the previous session's transcript
The punch list was not the whole record. These are the owner's own typed
requests, read back out of the previous session's transcript rather than
guessed at, then each checked against the code before being treated as open.
Three they suspected were already done really were: dialogs already had a
Close-only titlebar, inactive dimming already existed, and the corner resize
hitbox had already been tuned.
Snap layouts on drag, asked for twice. Edge snapping worked but committed
silently on release with nothing shown first, so there was no way to know it
would happen or where. A translucent drop-target preview now follows the
drag, and throwing the pointer at a monitor's top edge drops down the
existing six-cell grid to aim at. The preview calls the same snap_zone that
end_drag does, so the two cannot disagree. Two defects found by screenshot
before landing: moving down onto the flyout closed it, and its labels
overflowed at a fixed cell width - the same "text goes out of view" fault
already fixed once for the context menu.
New File now offers real types, chosen by extension, with the de-duplication
counter placed before the extension so the file stays what it says it is.
Refresh re-reads init.lua and fires a new srd.on("refresh") handler instead
of only re-scanning the icon grid. What refresh means beyond srdwm's own
config stays the config's decision.
general.config_reload_on_write (default on) applies an edited config on save,
via an mtime sweep rather than an inotify watch: no new dependency, same
behaviour on every target, and unaffected by editors that write through a
temp file.
A real bug behind "what happens when our config fails": you lost every
keybinding. do_reload cleared the binding, handler and repeat tables before
re-executing and never restored them, so a syntax error left neither the old
config nor the new one, and the only key still working was the reload combo
nobody thinks to press. The tables are now restored on any failure and
config errors reach notify-send, not just the log.
srd.lock() and a default Mod4+Ctrl+l binding: the built-in lock screen could
not be reached from Lua at all. Native rather than shelling out, because a
lock key that shells out fails silently when the binary is not on PATH.
Default bindings added for srd.window.move and a dynamic/tiling toggle, both
of which existed with no way to reach them, plus srd.layout.get() so the
toggle reads the live workspace rather than the configured default.
Dialogs open centred, and are excluded from remembered geometry in both
directions - that table is keyed by app_id, which a dialog shares with the
window that spawned it, so dialogs inherited an unrelated position and size
and then overwrote it with their own.
theme.decorations.title_bar.button_mode (dynamic by default, or fixed) drops
the Maximize button on a window whose client pinned min == max size, where
pressing it can do nothing. Maximize is removed from the slot list rather
than skipped in place on both the render and hit-test sides, so the remaining
buttons close the gap identically; three tests pin that agreement, which is
what fails silently when it drifts.
Also: the nested backend's screencopy pass now draws both menus, the flyout
and the drag preview. Four investigations in one day started from a
screenshot missing a tier, so that pass carries an explicit list of what it
still omits and the on-screen loop points at it.
512 tests pass, clippy clean.
Diffstat (limited to 'crates/wayland/src')
| -rw-r--r-- | crates/wayland/src/decoration/tests.rs | 36 | ||||
| -rw-r--r-- | crates/wayland/src/decoration/titlebar.rs | 24 | ||||
| -rw-r--r-- | crates/wayland/src/desktop_menu.rs | 82 | ||||
| -rw-r--r-- | crates/wayland/src/elements.rs | 57 | ||||
| -rw-r--r-- | crates/wayland/src/input/pointer.rs | 82 | ||||
| -rw-r--r-- | crates/wayland/src/state/desktop_icons.rs | 25 | ||||
| -rw-r--r-- | crates/wayland/src/state/lifecycle.rs | 29 | ||||
| -rw-r--r-- | crates/wayland/src/state/menu.rs | 14 | ||||
| -rw-r--r-- | crates/wayland/src/state/mod.rs | 7 | ||||
| -rw-r--r-- | crates/wayland/src/udev/platform.rs | 1 | ||||
| -rw-r--r-- | crates/wayland/src/udev/render.rs | 15 | ||||
| -rw-r--r-- | crates/wayland/src/winit/capture.rs | 48 | ||||
| -rw-r--r-- | crates/wayland/src/winit/connect.rs | 1 | ||||
| -rw-r--r-- | crates/wayland/src/winit/render.rs | 20 |
14 files changed, 411 insertions, 30 deletions
diff --git a/crates/wayland/src/decoration/tests.rs b/crates/wayland/src/decoration/tests.rs index f2a9ded..0d6609f 100644 --- a/crates/wayland/src/decoration/tests.rs +++ b/crates/wayland/src/decoration/tests.rs @@ -152,7 +152,7 @@ fn shadow_bitmap_corner_is_softer_than_a_square_windows_when_rounded() { #[test] fn fills_background_when_no_text() { - let buf = render_titlebar(40, 20, "", (0x2e, 0x34, 0x40), (0xec, 0xef, 0xf4), true, CORNER_RADIUS, 0, true, None, false, false, false, None, true, false); + let buf = render_titlebar(40, 20, "", (0x2e, 0x34, 0x40), (0xec, 0xef, 0xf4), true, CORNER_RADIUS, 0, true, None, false, false, false, None, true, false, true); assert_eq!(buf.len(), 40 * 20 * 4); // Center, not (0,0): the top-left pixel is inside the rounded // corner `round_top_corners` clips away, so it's transparent by @@ -175,7 +175,7 @@ fn button_icons_are_drawn_in_the_squares_hit_test_assigns_them() { let (width, height) = (300u32, srdwm_core::TITLEBAR_HEIGHT); let bg = (0x2e, 0x34, 0x40); let fg = (0xec, 0xef, 0xf4); - let buf = render_titlebar(width, height, "", bg, fg, true, CORNER_RADIUS, 0, true, None, false, false, false, None, true, false); + let buf = render_titlebar(width, height, "", bg, fg, true, CORNER_RADIUS, 0, true, None, false, false, false, None, true, false, true); let frame = srdwm_core::Rect::new(0, 0, width, height); let (width, height) = (width as usize, height as usize); @@ -189,7 +189,7 @@ fn button_icons_are_drawn_in_the_squares_hit_test_assigns_them() { let cx = (x0 + x1) / 2; let cy = (y0 + y1) / 2; assert_eq!( - srdwm_core::ResizeEdge::hit_test(frame, cx, cy, true, 0, srdwm_core::RESIZE_MARGIN, false, None, false), + srdwm_core::ResizeEdge::hit_test(frame, cx, cy, true, 0, srdwm_core::RESIZE_MARGIN, false, None, false, true), Some(expected), "icon drawn at right_offset={right_offset} does not land in the square hit_test assigns to {expected:?}" ); @@ -208,8 +208,8 @@ fn a_dialog_draws_only_close_and_never_a_coloured_traffic_light() { // must override it regardless of what the active theme otherwise // uses everywhere else. `glyph_always = true` so Close's own X is // visible without needing a live hover to check it landed. - let dialog = render_titlebar(width, height, "", bg, fg, true, CORNER_RADIUS, 0, true, None, false, false, true, None, true, true); - let normal = render_titlebar(width, height, "", bg, fg, true, CORNER_RADIUS, 0, true, None, false, false, true, None, true, false); + let dialog = render_titlebar(width, height, "", bg, fg, true, CORNER_RADIUS, 0, true, None, false, false, true, None, true, true, true); + let normal = render_titlebar(width, height, "", bg, fg, true, CORNER_RADIUS, 0, true, None, false, false, true, None, true, false, true); let (w, h) = (width as usize, height as usize); // Where a normal (non-dialog) titlebar draws Maximize (offset @@ -251,7 +251,7 @@ fn drawing_title_changes_some_pixels_when_font_available() { } let bg = (0x2e, 0x34, 0x40); let fg = (0xec, 0xef, 0xf4); - let buf = render_titlebar(200, 30, "Terminal", bg, fg, true, CORNER_RADIUS, 0, true, None, false, false, false, None, true, false); + let buf = render_titlebar(200, 30, "Terminal", bg, fg, true, CORNER_RADIUS, 0, true, None, false, false, false, None, true, false, true); let bg_bytes = rgb_to_bgra(bg, 255); let changed = buf.chunks_exact(4).any(|px| px != bg_bytes); assert!(changed, "expected at least one pixel to differ from the background once text is drawn"); @@ -282,8 +282,8 @@ fn centered_title_starts_further_right_than_left_aligned() { let leftmost_ink_column = |buf: &[u8]| -> Option<usize> { (0..width as usize).find(|&x| scan_rows.clone().any(|y| buf[(y * width as usize + x) * 4..(y * width as usize + x) * 4 + 4] != bg_bytes)) }; - let left = render_titlebar(width, height, "Hi", bg, fg, true, CORNER_RADIUS, 0, true, None, false, false, false, None, true, false); - let centered = render_titlebar(width, height, "Hi", bg, fg, true, CORNER_RADIUS, 0, true, None, true, false, false, None, true, false); + let left = render_titlebar(width, height, "Hi", bg, fg, true, CORNER_RADIUS, 0, true, None, false, false, false, None, true, false, true); + let centered = render_titlebar(width, height, "Hi", bg, fg, true, CORNER_RADIUS, 0, true, None, true, false, false, None, true, false, true); let left_start = leftmost_ink_column(&left).expect("left-aligned title must draw some ink"); let centered_start = leftmost_ink_column(¢ered).expect("centered title must draw some ink"); assert!(centered_start > left_start, "a short title centered in a wide titlebar must start well to the right of the left-aligned version (left starts at {left_start}, centered at {centered_start})"); @@ -315,7 +315,7 @@ fn centered_title_ignores_the_button_reservation_and_centers_on_the_whole_width( let ink_columns = |buf: &[u8]| -> Vec<usize> { (button_reservation..width as usize).filter(|&x| scan_rows.clone().any(|y| buf[(y * width as usize + x) * 4..(y * width as usize + x) * 4 + 4] != bg_bytes)).collect() }; - let buf = render_titlebar(width, height, "Hi", bg, fg, true, CORNER_RADIUS, 0, true, None, true, true, false, None, true, false); + let buf = render_titlebar(width, height, "Hi", bg, fg, true, CORNER_RADIUS, 0, true, None, true, true, false, None, true, false, true); let columns = ink_columns(&buf); let (first, last) = (*columns.first().expect("centered title must draw some ink"), *columns.last().unwrap()); let midpoint = (first + last) as f32 / 2.0; @@ -327,7 +327,7 @@ fn centered_title_ignores_the_button_reservation_and_centers_on_the_whole_width( fn empty_title_leaves_buffer_all_background_outside_the_rounded_corners() { let bg = (0x10, 0x20, 0x30); let (width, height) = (50, 24); - let buf = render_titlebar(width, height, "", bg, (0xff, 0xff, 0xff), true, CORNER_RADIUS, 0, true, None, false, false, false, None, true, false); + let buf = render_titlebar(width, height, "", bg, (0xff, 0xff, 0xff), true, CORNER_RADIUS, 0, true, None, false, false, false, None, true, false, true); let bg_bytes = rgb_to_bgra(bg, 255); for (i, px) in buf.chunks_exact(4).enumerate() { let (x, y) = (i % width as usize, i / width as usize); @@ -343,7 +343,7 @@ fn empty_title_leaves_buffer_all_background_outside_the_rounded_corners() { fn corners_are_clipped_but_the_middle_is_not() { let bg = (0x10, 0x20, 0x30); let (width, height) = (50, 24); - let buf = render_titlebar(width, height, "", bg, (0xff, 0xff, 0xff), true, CORNER_RADIUS, 0, true, None, false, false, false, None, true, false); + let buf = render_titlebar(width, height, "", bg, (0xff, 0xff, 0xff), true, CORNER_RADIUS, 0, true, None, false, false, false, None, true, false, true); let alpha_at = |x: usize, y: usize| buf[(y * width as usize + x) * 4 + 3]; // The very corner pixel is well outside the quarter-circle at any // sane radius - fully clipped. @@ -374,7 +374,7 @@ fn clipped_corner_pixels_are_fully_premultiplied_zero_not_just_alpha() { // is `(0, 0, 0, 0)` in every channel, not just alpha. let bg = (0x10, 0x20, 0x30); let (width, height) = (50, 24); - let buf = render_titlebar(width, height, "", bg, (0xff, 0xff, 0xff), true, CORNER_RADIUS, 0, true, None, false, false, false, None, true, false); + let buf = render_titlebar(width, height, "", bg, (0xff, 0xff, 0xff), true, CORNER_RADIUS, 0, true, None, false, false, false, None, true, false, true); let px_at = |x: usize, y: usize| &buf[(y * width as usize + x) * 4..(y * width as usize + x) * 4 + 4]; assert_eq!(px_at(0, 0), [0, 0, 0, 0], "top-left corner pixel must be fully zeroed (premultiplied transparent), not just alpha"); assert_eq!(px_at(width as usize - 1, 0), [0, 0, 0, 0], "top-right corner pixel must be fully zeroed (premultiplied transparent), not just alpha"); @@ -384,7 +384,7 @@ fn clipped_corner_pixels_are_fully_premultiplied_zero_not_just_alpha() { fn round_corners_false_leaves_the_top_corners_square() { let bg = (0x10, 0x20, 0x30); let (width, height) = (50, 24); - let buf = render_titlebar(width, height, "", bg, (0xff, 0xff, 0xff), false, CORNER_RADIUS, 0, true, None, false, false, false, None, true, false); + let buf = render_titlebar(width, height, "", bg, (0xff, 0xff, 0xff), false, CORNER_RADIUS, 0, true, None, false, false, false, None, true, false, true); let alpha_at = |x: usize, y: usize| buf[(y * width as usize + x) * 4 + 3]; assert_eq!(alpha_at(0, 0), 255, "top-left corner should stay square when round_corners is false"); assert_eq!(alpha_at(width as usize - 1, 0), 255, "top-right corner should stay square when round_corners is false"); @@ -406,8 +406,8 @@ fn hovering_the_close_button_brightens_only_that_dot() { let (width, height) = (200u32, srdwm_core::TITLEBAR_HEIGHT); let bg = (0x2e, 0x34, 0x40); let fg = (0xec, 0xef, 0xf4); - let plain = render_titlebar(width, height, "", bg, fg, true, CORNER_RADIUS, 0, true, None, false, false, false, None, true, false); - let close_hovered = render_titlebar(width, height, "", bg, fg, true, CORNER_RADIUS, 0, true, Some((srdwm_core::TitlebarHit::Close, 255)), false, false, false, None, true, false); + let plain = render_titlebar(width, height, "", bg, fg, true, CORNER_RADIUS, 0, true, None, false, false, false, None, true, false, true); + let close_hovered = render_titlebar(width, height, "", bg, fg, true, CORNER_RADIUS, 0, true, Some((srdwm_core::TitlebarHit::Close, 255)), false, false, false, None, true, false, true); let frame = srdwm_core::Rect::new(0, 0, width, height); let (w, h) = (width as usize, height as usize); let margin = srdwm_core::BUTTON_CLUSTER_MARGIN as usize; @@ -424,7 +424,7 @@ fn hovering_the_close_button_brightens_only_that_dot() { // point to Close, or this test would be checking a hover state // that a real pointer could never reach in the first place. let (cx, cy) = ((close_box.0 + close_box.2) / 2, (close_box.1 + close_box.3) / 2); - assert_eq!(srdwm_core::ResizeEdge::hit_test(frame, cx, cy, true, 0, srdwm_core::RESIZE_MARGIN, false, None, false), Some(srdwm_core::TitlebarHit::Close)); + assert_eq!(srdwm_core::ResizeEdge::hit_test(frame, cx, cy, true, 0, srdwm_core::RESIZE_MARGIN, false, None, false, true), Some(srdwm_core::TitlebarHit::Close)); } #[test] @@ -439,7 +439,7 @@ fn button_dot_has_a_glossy_highlight_toward_the_upper_left_and_shadow_toward_the let (width, height) = (200u32, srdwm_core::TITLEBAR_HEIGHT); let bg = (0x2e, 0x34, 0x40); let fg = (0xec, 0xef, 0xf4); - let buf = render_titlebar(width, height, "", bg, fg, true, CORNER_RADIUS, 0, true, None, false, false, false, None, true, false); + let buf = render_titlebar(width, height, "", bg, fg, true, CORNER_RADIUS, 0, true, None, false, false, false, None, true, false, true); let (w, h) = (width as usize, height as usize); let close_box = button_box(w, h, srdwm_core::BUTTON_CLUSTER_MARGIN as usize, false, BUTTON_MARGIN); let (cx, cy) = ((close_box.0 + close_box.2) / 2, (close_box.1 + close_box.3) / 2); @@ -501,7 +501,7 @@ fn border_top_and_titlebar_corners_meet_without_a_seam() { let color = (0x40, 0x50, 0x60); let (width, thickness, radius) = (60, 4, 6); let border = render_border_top(width, thickness, color, radius, true); - let titlebar = render_titlebar(width, 24, "", color, (0xff, 0xff, 0xff), true, radius, thickness, true, None, false, false, false, None, true, false); + let titlebar = render_titlebar(width, 24, "", color, (0xff, 0xff, 0xff), true, radius, thickness, true, None, false, false, false, None, true, false, true); let border_alpha_at = |x: usize| border[((thickness as usize - 1) * width as usize + x) * 4 + 3]; let titlebar_alpha_at = |xt: usize| titlebar[xt * 4 + 3]; // `x` below is the shared *global* column - distance from the true diff --git a/crates/wayland/src/decoration/titlebar.rs b/crates/wayland/src/decoration/titlebar.rs index 03a614a..846bd41 100644 --- a/crates/wayland/src/decoration/titlebar.rs +++ b/crates/wayland/src/decoration/titlebar.rs @@ -76,6 +76,13 @@ pub fn render_titlebar( // the same "renders on one side, hit-tests on the other" trap every // other button-geometry value here already has to avoid. is_dialog: bool, + // Whether a Maximize button is drawn at all - `WindowManager:: + // show_maximize`'s answer, which resolves `theme.dynamic_buttons` + // against the window's own `resizable`. Must stay in exact agreement + // with `ResizeEdge::hit_test`'s own `show_maximize` parameter, the + // same "renders on one side, hit-tests on the other" contract + // `is_dialog` directly above already carries. + show_maximize: bool, ) -> Vec<u8> { let (width, height) = (width.max(1) as usize, height.max(1) as usize); // Forced off, not just defaulted - a dialog never gets coloured @@ -98,7 +105,13 @@ pub fn render_titlebar( let cluster_margin = srdwm_core::BUTTON_CLUSTER_MARGIN as usize; // A dialog only ever gets one button (Close) - see this function's own // `is_dialog` doc comment. - let wanted_buttons = if is_dialog { 1 } else { 3 }; + let wanted_buttons = if is_dialog { + 1 + } else if show_maximize { + 3 + } else { + 2 + }; let button_count = if width >= cluster_margin + pitch * wanted_buttons { wanted_buttons } else { 0 }; // `BUTTON_CLUSTER_MARGIN` included, not just the buttons' own `pitch * // button_count` span - the cluster's own leading gap needs reserving @@ -245,6 +258,15 @@ pub fn render_titlebar( [srdwm_core::TitlebarButton::Close, srdwm_core::TitlebarButton::Maximize, srdwm_core::TitlebarButton::Minimize] }) }; + // Maximize removed from the list rather than skipped in the loop: + // skipping would leave an empty slot where it used to be, while + // `hit_test` closes the gap - so every later button would be drawn + // one pitch away from where its clicks actually land. + let order: Vec<srdwm_core::TitlebarButton> = if show_maximize { + order.to_vec() + } else { + order.iter().copied().filter(|b| *b != srdwm_core::TitlebarButton::Maximize).collect() + }; // `BUTTON_CLUSTER_MARGIN` first, then each button's own `pitch * i` // spacing after it - must stay in agreement with `ResizeEdge:: // hit_test`'s matching `left`/`right` base, the same "renders on diff --git a/crates/wayland/src/desktop_menu.rs b/crates/wayland/src/desktop_menu.rs index dbbf957..dfc3a6e 100644 --- a/crates/wayland/src/desktop_menu.rs +++ b/crates/wayland/src/desktop_menu.rs @@ -23,6 +23,21 @@ pub(crate) enum DesktopMenuAction { /// "not even new file" - see `CompState::new_desktop_text_file`'s own /// doc comment. NewTextFile, + /// Creates an empty file with this extension (no leading dot), named + /// "New <label>". The type is chosen by extension, which is what + /// actually decides how a desktop or file manager treats a file: + /// asked for as "in context menu say new file, user can choose what + /// file type is obviously by extension". + /// + /// A fixed, deliberately short list (see `NEW_FILE_TYPES`) rather than + /// a scan of the system's MIME database: the point is a couple of + /// clicks for the handful of files people actually create from a + /// desktop, not a type browser - a real file manager, one row further + /// down this same menu, is the right tool for anything beyond that. + NewFileOfType { + label: &'static str, + extension: &'static str, + }, /// Spawns a terminal with `~/Desktop` as its working directory -- /// `general.terminal`, or a common-binary fallback list if unset. OpenTerminalHere, @@ -52,6 +67,20 @@ pub(crate) struct DesktopMenu { const MENU_WIDTH: u32 = 170; const ROW_HEIGHT: u32 = 28; +/// The file types the bare-desktop menu offers to create, in order. +/// +/// Plain text stays its own separate `NewTextFile` action rather than +/// appearing here, because it is the overwhelmingly common one and was +/// already the menu's own established top-level entry - demoting it into +/// a list would make the ordinary case worse to reach. +const NEW_FILE_TYPES: [(&str, &str); 5] = [ + ("Markdown Document", "md"), + ("Shell Script", "sh"), + ("Python Script", "py"), + ("JSON File", "json"), + ("CSV Spreadsheet", "csv"), +]; + impl DesktopMenu { /// Right-click on `icon` itself - the action set depends on what kind /// of icon it is, not one fixed list: a real file/folder gets Open/ @@ -76,16 +105,21 @@ impl DesktopMenu { /// Right-click on bare desktop (no icon under the pointer). pub(crate) fn open_for_desktop(pos: (i32, i32)) -> Self { - let items = vec![ + let mut items = vec![ ("New Folder", DesktopMenuAction::NewFolder), ("New Text Document", DesktopMenuAction::NewTextFile), + ]; + for (label, extension) in NEW_FILE_TYPES { + items.push((label, DesktopMenuAction::NewFileOfType { label, extension })); + } + items.extend([ ("\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}", DesktopMenuAction::Separator), ("Open Terminal Here", DesktopMenuAction::OpenTerminalHere), ("Open in File Manager", DesktopMenuAction::OpenInFileManager), ("\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}", DesktopMenuAction::Separator), ("Select All", DesktopMenuAction::SelectAll), ("Refresh", DesktopMenuAction::Refresh), - ]; + ]); Self { pos, width: MENU_WIDTH, row_height: ROW_HEIGHT, items } } @@ -150,7 +184,49 @@ mod tests { let menu = DesktopMenu::open_for_desktop((10, 10)); let real_actions: Vec<&str> = menu.items.iter().filter(|(_, a)| !matches!(a, DesktopMenuAction::Separator)).map(|(l, _)| *l).collect(); - assert_eq!(real_actions, vec!["New Folder", "New Text Document", "Open Terminal Here", "Open in File Manager", "Select All", "Refresh"]); + assert_eq!( + real_actions, + vec![ + "New Folder", + "New Text Document", + "Markdown Document", + "Shell Script", + "Python Script", + "JSON File", + "CSV Spreadsheet", + "Open Terminal Here", + "Open in File Manager", + "Select All", + "Refresh", + ] + ); + } + + #[test] + fn every_offered_file_type_carries_a_real_extension() { + let menu = DesktopMenu::open_for_desktop((0, 0)); + let types: Vec<(&str, &str)> = menu + .items + .iter() + .filter_map(|(_, a)| match a { + DesktopMenuAction::NewFileOfType { label, extension } => Some((*label, *extension)), + _ => None, + }) + .collect(); + assert_eq!(types.len(), NEW_FILE_TYPES.len()); + for (label, extension) in types { + assert!(!extension.is_empty(), "{label} has no extension"); + assert!(!extension.starts_with('.'), "{label}: the dot is added when building the name, not stored"); + } + } + + #[test] + fn no_two_offered_file_types_share_an_extension() { + let mut seen: Vec<&str> = Vec::new(); + for (label, extension) in NEW_FILE_TYPES { + assert!(!seen.contains(&extension), "{label} repeats extension {extension}"); + seen.push(extension); + } } #[test] diff --git a/crates/wayland/src/elements.rs b/crates/wayland/src/elements.rs index b5be5a8..1fafcae 100644 --- a/crates/wayland/src/elements.rs +++ b/crates/wayland/src/elements.rs @@ -77,6 +77,63 @@ pub(crate) fn border_side_render_element(buf: &mut SolidColorBuffer, strip: srdw SolidColorRenderElement::from_buffer(buf, loc, 1.0, 1.0, Kind::Unspecified) } +/// How thick the snap preview's outline is, in logical pixels. +const SNAP_PREVIEW_OUTLINE: i32 = 3; + +/// Opacity of the snap preview's translucent interior fill. +/// +/// Low on purpose: this sits on top of whatever is already on screen, and +/// its job is to say "the window lands *here*" without hiding what is +/// underneath. The outline carries the shape; the fill only tints it. +const SNAP_PREVIEW_FILL_ALPHA: f32 = 0.22; + +/// The drop-target overlay drawn while a window is being dragged into a +/// snap zone - a translucent fill plus a solid outline over the rect the +/// window will occupy when the button comes up. +/// +/// `buffers` is a persistent pool (five entries: fill, then top/bottom/ +/// left/right outline) for exactly the reason [`border_side_render_element`] +/// documents at length - a fresh `SolidColorBuffer` per frame gets a fresh +/// `Id`, which makes smithay's damage tracker mark the element damaged on +/// every frame forever and stops the output ever going idle. +/// +/// Solid fills, not a rasterised bitmap: the preview rect is up to a whole +/// monitor in size and changes as the drag moves, so building an ARGB +/// buffer for it would mean allocating and filling megabytes per zone +/// change. `Frame::draw_solid` needs no texture at all. +pub(crate) fn snap_preview_elements(buffers: &mut Vec<SolidColorBuffer>, rect: srdwm_core::Rect, color: (u8, u8, u8), origin: (i32, i32)) -> Vec<SolidColorRenderElement> { + if rect.width == 0 || rect.height == 0 { + return Vec::new(); + } + let (r, g, b) = (color.0 as f32 / 255.0, color.1 as f32 / 255.0, color.2 as f32 / 255.0); + let t = SNAP_PREVIEW_OUTLINE.min(rect.width as i32 / 2).min(rect.height as i32 / 2).max(1); + let (w, h) = (rect.width as i32, rect.height as i32); + // Fill first so the outline draws over it, then the four edges. + let parts: [(srdwm_core::Rect, f32); 5] = [ + (rect, SNAP_PREVIEW_FILL_ALPHA), + (srdwm_core::Rect::new(rect.x, rect.y, rect.width, t as u32), 1.0), + (srdwm_core::Rect::new(rect.x, rect.y + h - t, rect.width, t as u32), 1.0), + (srdwm_core::Rect::new(rect.x, rect.y + t, t as u32, (h - 2 * t).max(0) as u32), 1.0), + (srdwm_core::Rect::new(rect.x + w - t, rect.y + t, t as u32, (h - 2 * t).max(0) as u32), 1.0), + ]; + let mut out = Vec::with_capacity(parts.len()); + for (index, (part, alpha)) in parts.into_iter().enumerate() { + if part.width == 0 || part.height == 0 { + continue; + } + let buf = border_fragment_buffer(buffers, index); + // Alpha lives in the element, not the buffer colour: `SolidColorBuffer:: + // update` only bumps its commit counter when size or colour change, + // so keeping the colour opaque here means a preview that merely + // moves does not also invalidate on a colour it never actually + // changed. + buf.update((part.width as i32, part.height as i32), Color32F::new(r, g, b, 1.0)); + let loc = Point::from((part.x - origin.0, part.y - origin.1)); + out.push(SolidColorRenderElement::from_buffer(buf, loc, 1.0, alpha, Kind::Unspecified)); + } + out +} + /// Splits a border strip into the sub-rectangles still visible after /// subtracting every window rect stacked in front of it. /// diff --git a/crates/wayland/src/input/pointer.rs b/crates/wayland/src/input/pointer.rs index c6ed5ba..e82b25f 100644 --- a/crates/wayland/src/input/pointer.rs +++ b/crates/wayland/src/input/pointer.rs @@ -23,6 +23,26 @@ use super::{notify_idle_activity, DRAG_MODIFIER}; /// mouse or touchpad can produce. pub(crate) const RESIZE_REDRAW_INTERVAL: std::time::Duration = std::time::Duration::from_millis(1000 / 60); +/// How far below a monitor's top edge the drag-triggered Snap-Layouts +/// flyout hangs. Enough to clear a typical bar, so the grid is not opening +/// underneath one. +const SNAP_FLYOUT_DROP: i32 = 36; + +/// What one pointer-motion tick decided about the drag-triggered +/// Snap-Layouts flyout, decided while `WindowManager` is borrowed and acted +/// on once that borrow is released (opening the flyout rasterises a buffer, +/// which needs `&mut CompState` as a whole). +/// +/// `NotDragging` is distinct from `Close` on purpose: a flyout opened the +/// other way - by right-clicking a maximize button - must survive an +/// unrelated pointer move, so a tick with no drag in progress has to leave +/// it alone rather than close it. +enum DragFlyout { + Open(WindowId, (i32, i32)), + Close, + NotDragging, +} + /// `WindowManager::hit_test`, but substituting each window's currently /// *animated* rect (if it has one active in `state.window_anims`) for its /// final `geometry` - see `hit_test_with`'s own doc comment in @@ -311,6 +331,9 @@ pub(crate) fn handle_pointer_position(state: &mut CompState, pos: Point<f64, Log update_cursor_shape(state, hit, over_layer_surface, over_content); + let mut drag_flyout = DragFlyout::NotDragging; + // Resolved before `wm` is borrowed below, since it reads `state`. + let over_open_flyout = state.snap_flyout.as_ref().is_some_and(|f| f.zone_at(pos.x as i32, pos.y as i32).is_some()); let mut wm = state.wm.borrow_mut(); let dragging_or_resizing = wm.is_dragging() || wm.is_resizing(); // Captured now, while `wm` is already borrowed, and acted on further @@ -320,6 +343,26 @@ pub(crate) fn handle_pointer_position(state: &mut CompState, pos: Point<f64, Log let resizing_id = wm.resizing_window(); if wm.is_dragging() { wm.update_drag(pos.x as i32, pos.y as i32); + // Windows-11-style: throw the window at the top of the screen and + // the Snap-Layouts grid drops down to be aimed at. Captured here + // while `wm` is borrowed and acted on after `drop(wm)` below -- + // opening the flyout rasterises a buffer, which needs `&mut state` + // as a whole. + drag_flyout = match (wm.drag_top_edge_monitor(), wm.dragged_window()) { + (Some(m), Some(id)) => { + let g = m.full_geometry; + DragFlyout::Open(id, (g.x + g.width as i32 / 2, g.y + SNAP_FLYOUT_DROP)) + } + // Leaving the trigger band does not dismiss an already-open + // flyout while the pointer is still on it - aiming at a cell + // means moving down, away from the band, which would otherwise + // close the grid on the way to using it. Confirmed by + // screenshot before this check existed: the flyout appeared at + // the top edge and had vanished by the time the pointer + // reached its first cell. + _ if over_open_flyout => DragFlyout::NotDragging, + _ => DragFlyout::Close, + }; } else if wm.is_resizing() { wm.update_resize(pos.x as i32, pos.y as i32); } @@ -348,6 +391,26 @@ pub(crate) fn handle_pointer_position(state: &mut CompState, pos: Point<f64, Log } } drop(wm); + // Open/close the drag-triggered Snap-Layouts flyout, now that `wm`'s + // borrow is released - see the capture site above. + match drag_flyout { + DragFlyout::Open(id, anchor) => { + let already_there = state.snap_flyout.as_ref().is_some_and(|f| f.window == id); + if !already_there { + state.open_snap_flyout(id, anchor); + // Centre it on the anchor, which is the monitor's own + // horizontal midpoint - `open_snap_flyout` places its + // top-left corner at whatever it is given (the maximize + // button's position, for the click-driven caller), and + // only this caller wants it centred. + if let Some(flyout) = state.snap_flyout.as_mut() { + flyout.pos.0 -= flyout.width() as i32 / 2; + } + } + } + DragFlyout::Close => state.close_snap_flyout(), + DragFlyout::NotDragging => {} + } if let Some(id) = focus_follow_target { focus_window(state, id); } @@ -745,6 +808,15 @@ pub(crate) fn handle_pointer_button(state: &mut CompState, pos: Point<f64, Logic // below, just for a desktop icon instead of a window. state.end_desktop_icon_drag(); state.end_desktop_marquee(); + // Releasing a drag over a cell of the drag-triggered Snap-Layouts + // flyout picks that cell's zone, in place of whatever edge snap + // `end_drag` would otherwise have computed from the drop point. + // Resolved before `end_drag` runs, because that consumes the drag. + let flyout_pick = state + .snap_flyout + .as_ref() + .filter(|f| state.wm.borrow().dragged_window() == Some(f.window)) + .and_then(|f| f.zone_at(pos.x as i32, pos.y as i32).map(|zone| (f.window, zone))); let mut wm = state.wm.borrow_mut(); let was_dragging = wm.is_dragging(); let was_resizing = wm.is_resizing(); @@ -757,6 +829,12 @@ pub(crate) fn handle_pointer_button(state: &mut CompState, pos: Point<f64, Logic let id = wm.focused_id(); if was_dragging { wm.end_drag(); + // After `end_drag`, so the flyout's own choice is the last + // word rather than being overwritten by the edge snap that + // dropping at the top of the screen would otherwise apply. + if let Some((window, zone)) = flyout_pick { + wm.apply_snap_zone(window, zone); + } } else if was_resizing { wm.end_resize(); } @@ -769,6 +847,10 @@ pub(crate) fn handle_pointer_button(state: &mut CompState, pos: Point<f64, Logic crate::window_memory::save_all(wm.all_remembered_geometry()); } drop(wm); + if was_dragging { + // Whether or not it was used, a drag ending closes it. + state.close_snap_flyout(); + } // `end_drag` can snap the geometry one more time (edge/top-of- // screen snapping, `SmartPlacement::snap_zone`) *after* the last // `update_drag` already moved the window - without this, that diff --git a/crates/wayland/src/state/desktop_icons.rs b/crates/wayland/src/state/desktop_icons.rs index 05bd85a..ad7796f 100644 --- a/crates/wayland/src/state/desktop_icons.rs +++ b/crates/wayland/src/state/desktop_icons.rs @@ -564,12 +564,23 @@ impl CompState { /// against this menu next to Windows'/macOS' own. Same collision- /// avoidance and refresh as `new_desktop_folder` just above. pub(crate) fn new_desktop_text_file(&mut self) { + self.new_desktop_file("New Text Document", "txt"); + } + + /// Creates an empty `~/Desktop/New <stem>.<extension>`, adding ` (2)`, + /// ` (3)` ... until the name is free, then refreshes the icon grid. + /// + /// The de-duplication counter goes before the extension, not after the + /// whole filename: `New Shell Script (2).sh` is still a shell script, + /// `New Shell Script.sh (2)` is not - and the extension is the entire + /// point of letting the type be chosen here. + pub(crate) fn new_desktop_file(&mut self, stem: &str, extension: &str) { let Ok(home) = std::env::var("HOME") else { return }; let desktop = std::path::PathBuf::from(home).join("Desktop"); - let mut name = "New Text Document.txt".to_string(); + let mut name = format!("{stem}.{extension}"); let mut n = 2; while desktop.join(&name).exists() { - name = format!("New Text Document ({n}).txt"); + name = format!("{stem} ({n}).{extension}"); n += 1; } if let Err(e) = std::fs::write(desktop.join(&name), "") { @@ -639,7 +650,15 @@ impl CompState { DesktopMenuAction::OpenTerminalHere => self.open_terminal_here(), DesktopMenuAction::OpenInFileManager => self.open_desktop_in_file_manager(), DesktopMenuAction::SelectAll => self.select_all_desktop_icons(), - DesktopMenuAction::Refresh => self.refresh_desktop_icons(), + DesktopMenuAction::NewFileOfType { label, extension } => self.new_desktop_file(&format!("New {label}"), extension), + DesktopMenuAction::Refresh => { + self.refresh_desktop_icons(); + // Re-reads the Lua config and fires `srd.on("refresh")`, + // so a user's own "restart my bar / reload my shell" list + // runs from the same menu row - see + // `WindowManager::request_refresh`. + self.wm.borrow_mut().request_refresh(); + } // Never actually reached - the click-dispatch site intercepts // `Separator` first, same as `context_menu::MenuAction:: // Separator`'s own dispatch. Handled here too so this match diff --git a/crates/wayland/src/state/lifecycle.rs b/crates/wayland/src/state/lifecycle.rs index 51297bb..92f7d9d 100644 --- a/crates/wayland/src/state/lifecycle.rs +++ b/crates/wayland/src/state/lifecycle.rs @@ -108,9 +108,37 @@ impl CompState { let is_dialog = self.id_to_window.get(&id).is_some_and(|dw| { dw.toplevel().is_some_and(|t| t.parent().is_some()) || dw.x11_surface().is_some_and(|x| x.is_transient_for().is_some()) }); + // Resolved the same way and at the same time as `is_dialog` above, + // for the same reason: this is protocol state, which `core` cannot + // read for itself. A client that pinned min == max on both axes is + // telling us it cannot be resized - and therefore cannot be + // maximized, so the Maximize button would do nothing. `0` means + // "unconstrained" on that axis in both protocols, so a zero on + // either side is never a match. See `Window::resizable`. + let resizable = self.id_to_window.get(&id).is_none_or(|dw| { + if let Some(toplevel) = dw.toplevel() { + let (min, max) = smithay::wayland::compositor::with_states(toplevel.wl_surface(), |states| { + let mut cached = states.cached_state.get::<smithay::wayland::shell::xdg::SurfaceCachedState>(); + let current = cached.current(); + (current.min_size, current.max_size) + }); + return !(min.w > 0 && min.h > 0 && min == max); + } + if let Some(x11) = dw.x11_surface() { + if let (Some(min), Some(max)) = (x11.min_size(), x11.max_size()) { + return min != max; + } + } + true + }); if let Some(win) = self.wm.borrow_mut().window_mut(id) { win.is_dialog = is_dialog; + win.resizable = resizable; } + let show_maximize = { + let wm = self.wm.borrow(); + wm.window(id).is_none_or(|win| wm.show_maximize(win)) + }; // Corrects `w.geometry`'s far edge to match what the client's // surface really committed, when that's known - see // `effective_frame`'s own doc comment. Every bitmap this method @@ -206,6 +234,7 @@ impl CompState { theme.button_order, theme.traffic_light_buttons, is_dialog, + show_maximize, ); let buffer = MemoryRenderBuffer::from_slice(&data, Fourcc::Argb8888, (width as i32, TITLEBAR_HEIGHT as i32), 1, Transform::Normal, None); self.decorations.insert(id, buffer); diff --git a/crates/wayland/src/state/menu.rs b/crates/wayland/src/state/menu.rs index 82e4434..d5e91cb 100644 --- a/crates/wayland/src/state/menu.rs +++ b/crates/wayland/src/state/menu.rs @@ -143,9 +143,21 @@ impl CompState { /// (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 mut 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(); + // Grow each cell to fit the widest label, exactly as + // `open_context_menu` above already does for menu rows - never + // shrinking below the built-in minimum `SnapFlyout::open` picked. + // + // The flyout was the one labelled surface in this compositor that + // never got this treatment: at the fixed 90px cell width "Bottom + // Right" was cut off mid-word and "Top Left" ran into its + // neighbour, the same "text goes out of view" fault already + // reported and fixed for the context menu. + let font = decoration::find_system_font(); + let widest = labels.iter().map(|l| decoration::measure_text_width(&font, l, decoration::FONT_PIXELS)).fold(0.0_f32, f32::max); + flyout.cell_width = flyout.cell_width.max((widest + decoration::TEXT_LEFT_PADDING * 2.0).ceil() as u32); 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); diff --git a/crates/wayland/src/state/mod.rs b/crates/wayland/src/state/mod.rs index 6396692..fee7af3 100644 --- a/crates/wayland/src/state/mod.rs +++ b/crates/wayland/src/state/mod.rs @@ -582,6 +582,13 @@ pub(crate) struct CompState { /// dropping them would lose the damage-tracking stability the whole /// scheme exists for the moment fragment counts fluctuate back up. pub(crate) border_side_buffers: HashMap<WindowId, Vec<SolidColorBuffer>>, + /// Persistent solid-colour buffers backing the drag snap-preview + /// overlay (fill plus four outline strips) - same "reuse the buffer so + /// its `Id` stays stable across frames" reasoning as + /// `border_side_buffers` above. One pool, not one per window: only ever + /// one drag is in progress at a time. See + /// `elements::snap_preview_elements`. + pub(crate) snap_preview_buffers: Vec<SolidColorBuffer>, /// Persistent solid-colour buffer backing the whole-output night-light/ /// reading-mode overlay, one per output name - same "reuse the buffer /// so its `Id` stays stable across frames" reasoning as `border_side_ diff --git a/crates/wayland/src/udev/platform.rs b/crates/wayland/src/udev/platform.rs index 732fbc2..0ae65f7 100644 --- a/crates/wayland/src/udev/platform.rs +++ b/crates/wayland/src/udev/platform.rs @@ -267,6 +267,7 @@ impl UdevPlatform { content_epoch: HashMap::new(), rounded_content_buffers: HashMap::new(), border_side_buffers: HashMap::new(), + snap_preview_buffers: Vec::new(), color_filter_buffers: HashMap::new(), last_synced_size: HashMap::new(), provisional_size: HashSet::new(), diff --git a/crates/wayland/src/udev/render.rs b/crates/wayland/src/udev/render.rs index ed62290..4170f47 100644 --- a/crates/wayland/src/udev/render.rs +++ b/crates/wayland/src/udev/render.rs @@ -45,6 +45,11 @@ impl CompState { // per-window shadow push below to keep a shadow off any monitor its // own window does not occupy (`decoration::shadow_rect_clipped`). let monitor_bounds: Vec<srdwm_core::Rect> = self.wm.borrow().monitors().iter().map(|m| m.full_geometry).collect(); + // Same "gather immutable state before `self.udev` is borrowed" + // reason again - both are read inside the per-head loop below, + // which holds that borrow for its whole body. + let drag_snap_preview = self.wm.borrow().drag_snap_preview(); + let accent_color = self.wm.borrow().theme.default_border_color; // Captured-and-blurred backgrounds collected during the per-head // loop below, applied via `self.capture_output` only after it // ends - `self.udev`'s mutable borrow is held for the whole loop @@ -450,6 +455,16 @@ impl CompState { Err(e) => log::warn!("udev: failed to import context menu buffer: {e}"), } } + // The drag snap preview - below the flyout (which the + // pointer is actively aiming at) but above every window, + // since it is showing where one of them is about to go. + if let Some(rect) = drag_snap_preview { + custom_elements.extend( + crate::elements::snap_preview_elements(&mut self.snap_preview_buffers, rect, accent_color, (origin.x, origin.y)) + .into_iter() + .map(crate::elements::OverlayElement::Solid), + ); + } // The Snap-Layouts flyout, if open - same "topmost but // never hides the cursor" placement as the context menu. if let (Some(flyout), Some(buffer)) = (self.snap_flyout.as_ref(), self.snap_flyout_buffer.as_ref()) { diff --git a/crates/wayland/src/winit/capture.rs b/crates/wayland/src/winit/capture.rs index d21ed72..c382190 100644 --- a/crates/wayland/src/winit/capture.rs +++ b/crates/wayland/src/winit/capture.rs @@ -17,10 +17,18 @@ impl WaylandPlatform { renderer.create_buffer(Fourcc::Abgr8888, (size.w, size.h).into()).map_err(err)?; let mut framebuffer = renderer.bind(&mut target).map_err(err)?; - // Not full parity with the on-screen render loop above (no border/ - // shadow strips here, same as before this function's content/opacity - // fix) - a real, pre-existing gap in what a screenshot shows on - // this backend, flagged rather than grown further in this pass. + // WHAT THIS PASS STILL DOES NOT DRAW: border strips, and the + // desktop icon grid. Everything else the on-screen loop draws is + // covered - content, titlebars, shadows, layer-shell surfaces, + // popups, both menus, the Snap-Layouts flyout and the drag snap + // preview. + // + // Keep this list honest. The whole reason the nested backend exists + // is checking behaviour with `grim`, and a tier missing here makes + // a working feature photograph as broken. That has now cost four + // separate investigations in one day (popups, shadows, the drag + // snap preview, the desktop menu), every one of them starting from + // a screenshot that was quietly lying. // Content (with each window's own `opacity`, unlike the // `self.state.space`-based single-alpha call this replaced) and the // bar/dock now render into the capture, at least: a screenshot used @@ -48,6 +56,38 @@ impl WaylandPlatform { // like the client never opened one. let popup_targets = crate::elements::popup_targets(&self.state); custom_elements.extend(crate::elements::popup_render_elements(&popup_targets, renderer, (0, 0))); + // The titlebar context menu and the desktop/desktop-icon menu, both + // topmost, in the same order the on-screen loop draws them. + for (menu_pos, buffer) in [ + (self.state.context_menu.as_ref().map(|m| m.pos), self.state.context_menu_buffer.as_ref()), + (self.state.desktop_menu.as_ref().map(|m| m.pos), self.state.desktop_menu_buffer.as_ref()), + ] { + let (Some(pos), Some(buffer)) = (menu_pos, buffer) else { continue }; + match MemoryRenderBufferRenderElement::from_buffer(renderer, (pos.0 as f64, pos.1 as f64), buffer, None, None, None, Kind::Unspecified) { + Ok(elem) => custom_elements.push(crate::elements::OverlayElement::Memory(elem)), + Err(e) => log::warn!("screencopy: failed to import menu buffer: {e}"), + } + } + // The Snap-Layouts flyout and the drag snap preview, in the same + // order the on-screen loop draws them. Both are drag-time overlays, + // and a screenshot that omits them cannot be used to check either + // one - which is exactly how this pass's missing popup tier wasted + // a session already. + if let (Some(flyout), Some(buffer)) = (self.state.snap_flyout.as_ref(), self.state.snap_flyout_buffer.as_ref()) { + let pos = (flyout.pos.0 as f64, flyout.pos.1 as f64); + match MemoryRenderBufferRenderElement::from_buffer(renderer, pos, buffer, None, None, None, Kind::Unspecified) { + Ok(elem) => custom_elements.push(crate::elements::OverlayElement::Memory(elem)), + Err(e) => log::warn!("screencopy: failed to import snap flyout buffer: {e}"), + } + } + if let Some(rect) = self.wm.borrow().drag_snap_preview() { + let accent = self.wm.borrow().theme.default_border_color; + custom_elements.extend( + crate::elements::snap_preview_elements(&mut self.state.snap_preview_buffers, rect, accent, (0, 0)) + .into_iter() + .map(crate::elements::OverlayElement::Solid), + ); + } if !hide_top_layers { custom_elements.extend(crate::elements::output_layer_elements(renderer, &self.output, |layer| matches!(layer, Layer::Top | Layer::Overlay))); } diff --git a/crates/wayland/src/winit/connect.rs b/crates/wayland/src/winit/connect.rs index 4c6954b..1648826 100644 --- a/crates/wayland/src/winit/connect.rs +++ b/crates/wayland/src/winit/connect.rs @@ -179,6 +179,7 @@ impl WaylandPlatform { content_epoch: HashMap::new(), rounded_content_buffers: HashMap::new(), border_side_buffers: HashMap::new(), + snap_preview_buffers: Vec::new(), color_filter_buffers: HashMap::new(), last_synced_size: HashMap::new(), provisional_size: HashSet::new(), diff --git a/crates/wayland/src/winit/render.rs b/crates/wayland/src/winit/render.rs index dff425f..ccc64a2 100644 --- a/crates/wayland/src/winit/render.rs +++ b/crates/wayland/src/winit/render.rs @@ -135,6 +135,26 @@ impl WaylandPlatform { Err(e) => log::warn!("failed to import context menu buffer: {e}"), } } + // ADDING A TIER HERE? THIS BACKEND RENDERS THE SCENE TWICE. What a + // screenshot shows comes from `winit/capture.rs`'s own separate + // offscreen pass, not from this loop, so anything pushed here and + // not there is on screen but invisible to `grim` - which reads as + // the feature being broken. That gap has now cost two separate + // investigations in one day (popups, then this very preview). The + // DRM backend has no equivalent trap: it serves screencopy out of + // the frame it just drew. + // + // The drag snap preview - below the flyout (which the pointer is + // actively aiming at) but above every window, since it is showing + // where one of them is about to go. + if let Some(rect) = self.wm.borrow().drag_snap_preview() { + let accent = self.wm.borrow().theme.default_border_color; + custom_elements.extend( + crate::elements::snap_preview_elements(&mut self.state.snap_preview_buffers, rect, accent, (0, 0)) + .into_iter() + .map(|e| crate::rounded_corners::WinitElement::Base(crate::elements::OverlayElement::Solid(e))), + ); + } // The Snap-Layouts flyout, if open - same topmost placement. if let (Some(flyout), Some(buffer)) = (self.state.snap_flyout.as_ref(), self.state.snap_flyout_buffer.as_ref()) { let pos = (flyout.pos.0 as f64, flyout.pos.1 as f64); |