quadraui 0.6.1

Cross-platform Rust UI toolkit: write one AppLogic and run it unmodified on TUI, GTK4 (Linux), macOS, and Windows backends
Documentation
//! GTK rasteriser for [`crate::ContextMenu`].
//!
//! Content painting (background, separators, selection highlight,
//! labels + shortcut text, border) is handled by the shared
//! [`crate::primitives::context_menu::native_surface_paint::paint`]
//! function — see that function's module doc for details on box corner
//! rounding and separator stroke weight.
//!
//! Returns per-clickable-item hit rectangles `(x, y, w, h, WidgetId)`
//! so the caller's click handler can resolve mouse events without
//! re-running layout.

use gtk4::cairo::Context;
use gtk4::pango;

use crate::accelerator::Platform;
use crate::primitives::context_menu::{native_surface_paint, ContextMenu, ContextMenuLayout};
use crate::theme::Theme;
use crate::types::WidgetId;

/// Draw a [`ContextMenu`] popup. Returns the per-clickable-item hit
/// rectangles in target-surface pixels.
#[allow(clippy::too_many_arguments)]
pub fn draw_context_menu(
    cr: &Context,
    layout: &pango::Layout,
    menu: &ContextMenu,
    menu_layout: &ContextMenuLayout,
    line_height: f64,
    theme: &Theme,
) -> Vec<(f64, f64, f64, f64, WidgetId)> {
    let _ = line_height;

    let mut surface = super::surface::CairoSurface {
        cr,
        layout: Some(layout),
        translucent_fill: true,
    };
    let hits = native_surface_paint::paint(
        menu,
        menu_layout,
        Platform::Linux,
        &mut surface,
        theme,
        &crate::style::Style::default(),
    );
    layout.set_attributes(None);

    hits.into_iter()
        .map(|(r, id)| (r.x as f64, r.y as f64, r.width as f64, r.height as f64, id))
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::event::Rect;
    use crate::primitives::context_menu::{
        ContextMenuHit, ContextMenuItem, ContextMenuItemMeasure,
    };
    use crate::types::StyledText;
    use pangocairo::cairo::{Context, Format, ImageSurface};

    fn action(id: &str) -> ContextMenuItem {
        ContextMenuItem {
            id: Some(WidgetId::new(id)),
            label: StyledText::plain(id),
            ..Default::default()
        }
    }

    fn menu() -> ContextMenu {
        ContextMenu {
            id: WidgetId::new("m"),
            items: vec![action("cut"), ContextMenuItem::default(), action("paste")],
            selected_idx: 0,
            bg: None,
            placement: crate::primitives::context_menu::ContextMenuPlacement::AnchorPoint,
        }
    }

    #[test]
    fn draw_context_menu_returns_hits_for_clickable_items_only() {
        let surface = ImageSurface::create(Format::ARgb32, 200, 120).expect("create ImageSurface");
        let cr = Context::new(&surface).expect("Context::new");
        let pango_layout = pangocairo::functions::create_layout(&cr);

        let m = menu();
        let viewport = Rect::new(0.0, 0.0, 200.0, 120.0);
        let layout = m.layout(10.0, 10.0, viewport, 120.0, |i| {
            if m.items[i].is_separator() {
                ContextMenuItemMeasure::new(6.0)
            } else {
                ContextMenuItemMeasure::new(20.0)
            }
        });

        let hits = draw_context_menu(&cr, &pango_layout, &m, &layout, 20.0, &Theme::default());
        assert_eq!(hits.len(), 2, "cut + paste are clickable, separator is not");
        assert_eq!(hits[0].4, WidgetId::new("cut"));
        assert_eq!(hits[1].4, WidgetId::new("paste"));

        // hit_test agrees with what was painted.
        let (x, y, _, _, _) = hits[0];
        let hit = layout.hit_test(x as f32 + 4.0, y as f32 + 2.0);
        assert_eq!(hit, ContextMenuHit::Item(WidgetId::new("cut")));
    }

    // Submenu ▶ affordance — GTK parity with tui::context_menu

    /// Sample a pixel's RGB from an ARGB32 Cairo image surface buffer.
    fn probe_pixel(data: &[u8], stride: usize, x: i32, y: i32) -> (u8, u8, u8) {
        let off = y as usize * stride + x as usize * 4;
        // Cairo ARGB32 is host-endian BGRA / native premultiplied; only
        // relative equality/inequality across samples in the same image
        // matters here, so the exact channel order doesn't need to be
        // untangled.
        (data[off], data[off + 1], data[off + 2])
    }

    fn submenu_parent_menu() -> ContextMenu {
        use crate::primitives::context_menu::ContextMenuItem;
        ContextMenu {
            id: WidgetId::new("m"),
            items: vec![
                ContextMenuItem {
                    id: Some(WidgetId::new("parent")),
                    label: StyledText::plain("Parent"),
                    submenu: Some(vec![action("child")]),
                    ..Default::default()
                },
                action("leaf"),
                // Selected, so neither row above picks up a selection
                // highlight that could otherwise be mistaken for ink.
                action("decoy"),
            ],
            selected_idx: 2,
            bg: None,
            placement: crate::primitives::context_menu::ContextMenuPlacement::AnchorPoint,
        }
    }

    #[test]
    fn draw_context_menu_paints_submenu_arrow_not_shown_on_plain_leaf() {
        use pangocairo::cairo::{Context, Format, ImageSurface};

        let mut surface = ImageSurface::create(Format::ARgb32, 200, 120).expect("create surface");
        let (parent_row, leaf_row) = {
            let cr = Context::new(&surface).expect("Context::new");
            let pango_layout = pangocairo::functions::create_layout(&cr);

            let m = submenu_parent_menu();
            let viewport = Rect::new(0.0, 0.0, 200.0, 120.0);
            let layout = m.layout(10.0, 10.0, viewport, 120.0, |_| {
                ContextMenuItemMeasure::new(24.0)
            });
            let parent_row = layout.visible_items[0].bounds;
            let leaf_row = layout.visible_items[1].bounds;

            let _ = draw_context_menu(&cr, &pango_layout, &m, &layout, 24.0, &Theme::default());
            (parent_row, leaf_row)
        };

        surface.flush();
        let stride = surface.stride() as usize;
        let data = surface.data().expect("surface data");

        // `bg_x`: well past the (short) label text — true item
        // background on both rows. The ▶ affordance is laid out in
        // `[right - advance - 8, right - 8]`, but where its *ink* lands
        // inside that box is font-dependent (DejaVu on Linux spills ink
        // into the right padding; macOS's fallback glyph does not), so
        // rather than probing one hardcoded pixel, scan the whole
        // right-hand band (stopping short of the 1px border stroke) a
        // few rows either side of the row's vertical centre.
        let bg_x = (parent_row.x + parent_row.width * 0.4) as i32;
        let band_x0 = (parent_row.x + parent_row.width * 0.6) as i32;
        let band_x1 = (parent_row.x + parent_row.width - 3.0) as i32;
        let parent_y = (parent_row.y + parent_row.height / 2.0) as i32;
        let leaf_y = (leaf_row.y + leaf_row.height / 2.0) as i32;

        let parent_bg = probe_pixel(&data, stride, bg_x, parent_y);
        let leaf_bg = probe_pixel(&data, stride, bg_x, leaf_y);

        let band_differs = |cy: i32, bg: (u8, u8, u8)| {
            (cy - 4..=cy + 4)
                .any(|y| (band_x0..band_x1).any(|x| probe_pixel(&data, stride, x, y) != bg))
        };

        assert!(
            band_differs(parent_y, parent_bg),
            "submenu-parent item should paint a ▶ affordance near the right edge"
        );
        assert!(
            !band_differs(leaf_y, leaf_bg),
            "plain leaf item (no submenu, no shortcut) must not paint anything there"
        );
    }

    // ── Checked-item ✓ affordance ────────────────────────────────────────

    /// Both items share one label — "Item" — so a row-vs-row pixel diff
    /// isolates exactly the check glyph's own ink: the real label text
    /// and its reserved-slot spacing paint byte-identically on both
    /// rows regardless of what the test font's actual space advance
    /// turns out to be, so the diff cannot pick up anything but the
    /// one real difference between the two items (`checked`).
    fn checked_item_menu() -> ContextMenu {
        use crate::primitives::context_menu::ContextMenuItem;
        ContextMenu {
            id: WidgetId::new("m"),
            items: vec![
                ContextMenuItem {
                    id: Some(WidgetId::new("checked")),
                    label: StyledText::plain("Item"),
                    checked: Some(true),
                    ..Default::default()
                },
                ContextMenuItem {
                    id: Some(WidgetId::new("unchecked")),
                    label: StyledText::plain("Item"),
                    checked: Some(false),
                    ..Default::default()
                },
            ],
            selected_idx: 2,
            bg: None,
            placement: crate::primitives::context_menu::ContextMenuPlacement::AnchorPoint,
        }
    }

    /// A checked item paints the codicon check glyph in its reserved
    /// leading slot; an otherwise-identical unchecked item leaves that
    /// slot's ink untouched. Rather than guessing where the test font's
    /// own space-glyph advance ends (and risking the label text itself
    /// landing inside a hardcoded probe band — exactly the "font-
    /// dependent ink position" problem
    /// `draw_context_menu_paints_submenu_arrow_not_shown_on_plain_leaf`
    /// solves by scanning a wide band), this test diffs the checked and
    /// unchecked rows against each other pixel-for-pixel: since both
    /// rows share one label, every pixel they'd otherwise disagree on
    /// comes from the check glyph alone.
    #[test]
    fn draw_context_menu_paints_check_glyph_only_on_checked_item() {
        use pangocairo::cairo::{Context, Format, ImageSurface};

        let mut surface = ImageSurface::create(Format::ARgb32, 200, 80).expect("create surface");
        let (checked_row, unchecked_row) = {
            let cr = Context::new(&surface).expect("Context::new");
            let pango_layout = pangocairo::functions::create_layout(&cr);

            let m = checked_item_menu();
            let viewport = Rect::new(0.0, 0.0, 200.0, 80.0);
            let layout = m.layout(10.0, 10.0, viewport, 80.0, |_| {
                ContextMenuItemMeasure::new(24.0)
            });
            let checked_row = layout.visible_items[0].bounds;
            let unchecked_row = layout.visible_items[1].bounds;

            let _ = draw_context_menu(&cr, &pango_layout, &m, &layout, 24.0, &Theme::default());
            (checked_row, unchecked_row)
        };

        surface.flush();
        let stride = surface.stride() as usize;
        let data = surface.data().expect("surface data");

        // Scan the leading half of each row (clear of the 1px border
        // stroke on the left, stopping before the label's own longest
        // possible extent) at a handful of rows spanning each item's
        // vertical centre, diffing checked against unchecked at the
        // same relative offset from its own row's origin.
        let dy_offset = (checked_row.height / 2.0 - 4.0) as i32;
        let band_x0 = 2_i32;
        let band_x1 = (checked_row.width * 0.5) as i32;

        let differs = (0..=8).any(|dy| {
            (band_x0..band_x1).any(|dx| {
                let x = (checked_row.x as i32) + dx;
                let cy = (checked_row.y as i32) + dy_offset + dy;
                let uy = (unchecked_row.y as i32) + dy_offset + dy;
                probe_pixel(&data, stride, x, cy) != probe_pixel(&data, stride, x, uy)
            })
        });

        assert!(
            differs,
            "a checked item must paint something in its leading slot that an \
             otherwise-identical unchecked item does not"
        );
    }
}