From c4dc99cc3211c4dc1a461f228402be1593b883c9 Mon Sep 17 00:00:00 2001 From: srdusr <99972264+srdusr@users.noreply.github.com> Date: Mon, 27 Apr 2026 00:18:00 +0200 Subject: 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. --- crates/wayland/src/decoration/tests.rs | 36 +++++++------- crates/wayland/src/decoration/titlebar.rs | 24 ++++++++- crates/wayland/src/desktop_menu.rs | 82 +++++++++++++++++++++++++++++-- crates/wayland/src/elements.rs | 57 +++++++++++++++++++++ crates/wayland/src/input/pointer.rs | 82 +++++++++++++++++++++++++++++++ crates/wayland/src/state/desktop_icons.rs | 25 ++++++++-- crates/wayland/src/state/lifecycle.rs | 29 +++++++++++ crates/wayland/src/state/menu.rs | 14 +++++- crates/wayland/src/state/mod.rs | 7 +++ crates/wayland/src/udev/platform.rs | 1 + crates/wayland/src/udev/render.rs | 15 ++++++ crates/wayland/src/winit/capture.rs | 48 ++++++++++++++++-- crates/wayland/src/winit/connect.rs | 1 + crates/wayland/src/winit/render.rs | 20 ++++++++ 14 files changed, 411 insertions(+), 30 deletions(-) (limited to 'crates/wayland/src') 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 { (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 { (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 { 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 = 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