srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/crates
diff options
context:
space:
mode:
Diffstat (limited to 'crates')
-rw-r--r--crates/wayland/src/decoration.rs42
-rw-r--r--crates/wayland/src/decoration/color.rs14
-rw-r--r--crates/wayland/src/decoration/tests.rs72
-rw-r--r--crates/wayland/src/desktop_menu.rs8
-rw-r--r--crates/wayland/src/state/desktop_icons.rs19
5 files changed, 148 insertions, 7 deletions
diff --git a/crates/wayland/src/decoration.rs b/crates/wayland/src/decoration.rs
index 56852cb..ee3f34c 100644
--- a/crates/wayland/src/decoration.rs
+++ b/crates/wayland/src/decoration.rs
@@ -40,7 +40,7 @@ mod titlebar;
pub use border::{border_strips, render_border_bottom, render_border_top};
pub(crate) use border::{border_bottom_visible_rows, border_top_visible_rows};
pub(crate) use buttons::HOVER_GLYPH_DURATION;
-pub(crate) use color::rgb_to_bgra;
+pub(crate) use color::{mix_rgb, rgb_to_bgra};
pub(crate) use corners::{round_bottom_corners, round_top_corners};
pub(crate) use font::{blit_glyph, find_system_font, FONT_PIXELS, TEXT_LEFT_PADDING};
pub use shadow::{shadow_bitmap, shadow_rect};
@@ -84,13 +84,38 @@ pub(crate) const CORNER_RADIUS: u32 = 12;
/// polished" - the previous version drew edge-to-edge square rows with a
/// single hard 1px border around the whole menu, exactly what that
/// complaint (raised about the AGS dropdown, fixed there first) describes.
-/// Still no submenus/icons/separators - real gaps beyond this pass' own
-/// scope, not attempted blind.
+/// Still no submenus/icons - real gaps beyond this pass' own scope, not
+/// attempted blind.
+///
+/// A row whose label is *entirely* the box-drawing character `─`
+/// (`\u{2500}`, one or more, no other content) renders as a real thin
+/// divider line instead of text glyphs - a label that *mixes* `─` with
+/// real text (`"─── Move to Workspace ───"`, a deliberate section-header
+/// convention `core::ContextMenu` already uses) is untouched and still
+/// renders as text, since that dual purpose (divider *and* caption) is
+/// the actual design, not a plain separator. A pure divider is drawn as
+/// pixels rather than characters because Unicode box-drawing glyphs
+/// render inconsistently thin/dotted across fonts at small sizes - a
+/// real 1px anti-aliased line, inset from both edges and blended low-
+/// opacity against the panel (matching the AGS reference dropdown's own
+/// `separator.menu-sep`: `color-mix(in srgb, var(--fg) 12%, transparent)`),
+/// reads as an intentional divider rather than a run of stray dashes.
pub fn render_context_menu(width: u32, row_height: u32, items: &[(&str, bool)], bg: (u8, u8, u8), fg: (u8, u8, u8), highlight_bg: (u8, u8, u8), border: (u8, u8, u8)) -> Vec<u8> {
let _ = border; // No outline anywhere now - see this function's own doc comment. Kept as a parameter so callers/themes don't need updating for a look this function no longer draws.
const PANEL_RADIUS: f32 = 10.0;
const ROW_INSET: i32 = 4;
const ROW_RADIUS: f32 = 6.0;
+ // AGS reference's own measured/settled ratio (`popover box.menu-list
+ // button:hover`'s `color-mix(in srgb, var(--primary-bg) 22%, var(
+ // --widget-bg))`) - a subtle tinted wash, not a flat, fully-
+ // saturated fill of whatever colour the caller passes as `highlight_
+ // bg`. Applied here rather than changing what callers pass in, so
+ // every existing call site's own colour choice still means "the
+ // accent to tint toward", not "the literal pixel colour".
+ const HIGHLIGHT_MIX: f32 = 0.22;
+ // Matches the reference's own `separator.menu-sep` background --
+ // barely-there, a hairline rather than a visible bar.
+ const SEPARATOR_MIX: f32 = 0.12;
let (width, row_height) = (width.max(1) as usize, row_height.max(1) as usize);
// Exactly `row_height * items.len()`, same as before this pass --
@@ -107,9 +132,16 @@ pub fn render_context_menu(width: u32, row_height: u32, items: &[(&str, bool)],
// the menu (desktop/window content) rather than a hard-edged square.
fill_rounded_rect(&mut buf, width, height, 0, 0, width as i32, height as i32, PANEL_RADIUS, bg, bg);
+ let highlight_fill = mix_rgb(bg, highlight_bg, HIGHLIGHT_MIX);
+ let separator_color = mix_rgb(bg, fg, SEPARATOR_MIX);
let font = find_system_font();
for (i, (label, highlighted)) in items.iter().enumerate() {
let row_top = (i * row_height) as i32;
+ if !label.is_empty() && label.chars().all(|c| c == '\u{2500}') {
+ let y = row_top + row_height as i32 / 2;
+ fill_rounded_rect_over(&mut buf, width, height, ROW_INSET * 2, y, width as i32 - ROW_INSET * 2, y + 1, 0.0, separator_color);
+ continue;
+ }
// The background text actually sits on, for `blit_glyph`'s own
// blend-toward-a-known-solid-colour contract - the row's own
// highlight fill (already baked into `buf` by this point, above)
@@ -121,9 +153,9 @@ pub fn render_context_menu(width: u32, row_height: u32, items: &[(&str, bool)],
// comment) - used correctly, it would leave a visible dark
// fringe around every character's anti-aliased edge instead of a
// clean blend into the row's real colour.
- let row_bg = if *highlighted { highlight_bg } else { bg };
+ let row_bg = if *highlighted { highlight_fill } else { bg };
if *highlighted {
- fill_rounded_rect_over(&mut buf, width, height, ROW_INSET, row_top, width as i32 - ROW_INSET, row_top + row_height as i32, ROW_RADIUS, highlight_bg);
+ fill_rounded_rect_over(&mut buf, width, height, ROW_INSET, row_top, width as i32 - ROW_INSET, row_top + row_height as i32, ROW_RADIUS, highlight_fill);
}
if let Some(font) = &font {
let baseline = row_top as f32 + row_height as f32 * 0.72;
diff --git a/crates/wayland/src/decoration/color.rs b/crates/wayland/src/decoration/color.rs
index 7ea9d80..5262cec 100644
--- a/crates/wayland/src/decoration/color.rs
+++ b/crates/wayland/src/decoration/color.rs
@@ -32,3 +32,17 @@ pub(crate) fn darken(color: (u8, u8, u8)) -> (u8, u8, u8) {
let mix = |c: u8| (c as f32 * (1.0 - AMOUNT)).round() as u8;
(mix(color.0), mix(color.1), mix(color.2))
}
+
+/// Linear channel-wise blend of `a` toward `b` by `t` (`0.0` is pure `a`,
+/// `1.0` is pure `b`) - unlike `brighten`/`darken`, which blend toward a
+/// fixed white/black, this blends toward an arbitrary second colour, at
+/// an arbitrary caller-chosen ratio. `render_context_menu`'s own row
+/// highlight uses this to reproduce the AGS reference dropdown's own
+/// subtle wash (`color-mix(in srgb, var(--primary-bg) 22%, var(--widget-
+/// bg))`) instead of a flat, fully-saturated fill - the same reference
+/// this project's own menu rebuild already targets elsewhere.
+pub(crate) fn mix_rgb(a: (u8, u8, u8), b: (u8, u8, u8), t: f32) -> (u8, u8, u8) {
+ let t = t.clamp(0.0, 1.0);
+ let mix = |ac: u8, bc: u8| (ac as f32 + (bc as f32 - ac as f32) * t).round() as u8;
+ (mix(a.0, b.0), mix(a.1, b.1), mix(a.2, b.2))
+}
diff --git a/crates/wayland/src/decoration/tests.rs b/crates/wayland/src/decoration/tests.rs
index 5cbc12f..b14cbca 100644
--- a/crates/wayland/src/decoration/tests.rs
+++ b/crates/wayland/src/decoration/tests.rs
@@ -809,7 +809,15 @@ fn context_menu_highlighted_row_has_a_different_background_than_the_rest() {
[buf[i + 2], buf[i + 1], buf[i]] // BGRA -> RGB
};
assert_eq!(px_at(100, 5), [bg.0, bg.1, bg.2], "row 0 (not highlighted) should use bg");
- assert_eq!(px_at(100, 33), [highlight.0, highlight.1, highlight.2], "row 1 (highlighted) should use highlight_bg");
+ // Not `highlight` at full strength: the AGS reference dropdown's own
+ // hover fill is a 22%-mixed wash of the accent over the panel
+ // background (`color-mix(in srgb, var(--primary-bg) 22%, var(
+ // --widget-bg))`), not a flat, fully-saturated fill - see `render_
+ // context_menu`'s own doc comment. `mix_rgb(bg, highlight, 0.22)`,
+ // computed by hand here rather than imported, so this test would
+ // actually catch the ratio drifting.
+ let expected = (53, 59, 73); // (46,52,64) blended 22% toward (76,86,106)
+ assert_eq!(px_at(100, 33), [expected.0, expected.1, expected.2], "row 1 (highlighted) should use a subtle tinted wash toward highlight_bg, not a flat fill of it");
}
#[test]
@@ -836,6 +844,68 @@ fn context_menu_panel_is_opaque_in_the_middle_but_rounded_at_the_corners() {
}
#[test]
+fn a_pure_separator_row_draws_a_narrow_line_not_a_full_text_row() {
+ // A label of only `\u{2500}` renders as a thin graphical line instead
+ // of text glyphs - see `render_context_menu`'s own doc comment for
+ // why (Unicode box-drawing glyphs render inconsistently at small
+ // sizes; a real line reads as an intentional divider). Scans every
+ // pixel in the row (not one fixed column, which could accidentally
+ // land in a font glyph's own hollow spot) - a hairline should touch
+ // only a couple of the row's own pixel rows, nowhere near a real text
+ // row's spread (see the sibling test just below).
+ //
+ // Three items, separator in the middle: `fill_rounded_rect`'s own
+ // distance field softens alpha within `PANEL_RADIUS` of *any* of the
+ // panel's four edges, not just the visible corner curves (a rounded
+ // rect's SDF clamps the comparison point toward whichever edge is
+ // nearest along each axis independently, so a point that's flat-on
+ // to one edge but still within `radius` of another edge still gets
+ // partial coverage). Sandwiching the separator row between two
+ // others, and scanning only rows/columns comfortably past `PANEL_
+ // RADIUS` in from every canvas edge, keeps this test inside the
+ // panel's genuinely flat, fully-opaque interior - confirmed by
+ // measurement (a debug dump of raw pixel deltas), not assumed --
+ // so only the separator itself can account for a non-`bg` pixel here.
+ let bg = (0x2e, 0x34, 0x40);
+ let width = 160usize;
+ let items = [("Open", false), ("\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}", false), ("Close", false)];
+ let buf = render_context_menu(width as u32, 28, &items, bg, (0xff, 0xff, 0xff), (0x4c, 0x56, 0x6a), (0x10, 0x10, 0x10));
+ let px_at = |x: usize, y: usize| -> [u8; 3] {
+ let i = (y * width + x) * 4;
+ [buf[i + 2], buf[i + 1], buf[i]]
+ };
+ const PANEL_RADIUS: usize = 10;
+ let safe_x = (PANEL_RADIUS + 2)..(width - PANEL_RADIUS - 2);
+ let row_has_any_non_bg = |y: usize| safe_x.clone().any(|x| px_at(x, y) != [bg.0, bg.1, bg.2]);
+ let separator_row_top = 28;
+ let non_bg_rows = (separator_row_top..separator_row_top + 28).filter(|&y| row_has_any_non_bg(y)).count();
+ assert!(non_bg_rows <= 3, "a hairline separator should touch only a couple of the row's pixel rows, got {non_bg_rows}");
+ assert!(non_bg_rows >= 1, "the separator must actually draw something, not vanish entirely");
+}
+
+#[test]
+fn a_labeled_section_header_separator_still_renders_as_text() {
+ // `"─── Move to Workspace ───"` deliberately mixes the divider
+ // character with real text (`core::ContextMenu`'s own section-header
+ // convention) - it must keep rendering as text, not collapse into a
+ // plain hairline just because it contains `\u{2500}` characters too.
+ // Same whole-row scan and same "sandwich away from the panel's own
+ // rounded-edge antialiasing" shape as the sibling test above.
+ let bg = (0x2e, 0x34, 0x40);
+ let width = 200usize;
+ let items = [("Open", false), ("\u{2500}\u{2500}\u{2500} Move to Workspace \u{2500}\u{2500}\u{2500}", false), ("Close", false)];
+ let buf = render_context_menu(width as u32, 28, &items, bg, (0xff, 0xff, 0xff), (0x4c, 0x56, 0x6a), (0x10, 0x10, 0x10));
+ let px_at = |x: usize, y: usize| -> [u8; 3] {
+ let i = (y * width + x) * 4;
+ [buf[i + 2], buf[i + 1], buf[i]]
+ };
+ let row_has_any_non_bg = |y: usize| (0..width).any(|x| px_at(x, y) != [bg.0, bg.1, bg.2]);
+ let header_row_top = 28;
+ let non_bg_rows = (header_row_top..header_row_top + 28).filter(|&y| row_has_any_non_bg(y)).count();
+ assert!(non_bg_rows > 3, "a text row (mixed divider+caption) should paint well more than a hairline's worth of rows, got {non_bg_rows}");
+}
+
+#[test]
fn snap_flyout_is_sized_for_a_full_grid_of_labels() {
let labels = ["Left Half", "Right Half", "Top Left", "Top Right", "Bottom Left", "Bottom Right"];
let buf = render_snap_flyout(3, 90, 60, &labels, (0x2e, 0x34, 0x40), (0xff, 0xff, 0xff), (0x10, 0x10, 0x10));
diff --git a/crates/wayland/src/desktop_menu.rs b/crates/wayland/src/desktop_menu.rs
index 3cb90ed..dbbf957 100644
--- a/crates/wayland/src/desktop_menu.rs
+++ b/crates/wayland/src/desktop_menu.rs
@@ -30,6 +30,11 @@ pub(crate) enum DesktopMenuAction {
/// concrete path to a real file manager's own richer menu (cut/copy/
/// paste, properties, ...), deliberately not reimplemented here.
OpenInFileManager,
+ /// Selects every desktop icon at once - the one bare-desktop menu
+ /// action every mainstream file manager/desktop offers (Explorer,
+ /// Nautilus, Finder) that this menu had no equivalent for at all,
+ /// reported live as this menu needing "a lot more items".
+ SelectAll,
Refresh,
/// A purely visual divider row - see `context_menu::MenuAction::
/// Separator`'s own doc comment (same shape, same reason, separate
@@ -78,6 +83,7 @@ impl DesktopMenu {
("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 }
@@ -144,7 +150,7 @@ 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", "Refresh"]);
+ assert_eq!(real_actions, vec!["New Folder", "New Text Document", "Open Terminal Here", "Open in File Manager", "Select All", "Refresh"]);
}
#[test]
diff --git a/crates/wayland/src/state/desktop_icons.rs b/crates/wayland/src/state/desktop_icons.rs
index 81a8ed8..a1b1968 100644
--- a/crates/wayland/src/state/desktop_icons.rs
+++ b/crates/wayland/src/state/desktop_icons.rs
@@ -218,6 +218,24 @@ impl CompState {
}
}
+ /// Selects every desktop icon at once - the bare-desktop menu's own
+ /// "Select All" action (see `DesktopMenuAction::SelectAll`'s own doc
+ /// comment). Same "only rebuild the buffers that actually changed"
+ /// shape as `select_desktop_icon`.
+ pub(crate) fn select_all_desktop_icons(&mut self) {
+ let Some(icons) = &mut self.desktop_icons else { return };
+ let mut changed = Vec::new();
+ for icon in &mut icons.icons {
+ if !icon.selected {
+ icon.selected = true;
+ changed.push(icon.id.clone());
+ }
+ }
+ for id in changed {
+ self.rebuild_icon_buffer(&id);
+ }
+ }
+
/// Starts a rubber-band selection at `pos` (global space) - clears
/// whatever was selected before, matching real desktop convention
/// (Windows/GNOME/macOS all start a fresh marquee selection, not an
@@ -603,6 +621,7 @@ impl CompState {
DesktopMenuAction::NewTextFile => self.new_desktop_text_file(),
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(),
// Never actually reached - the click-dispatch site intercepts
// `Separator` first, same as `context_menu::MenuAction::