diff options
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); |