use crate::a11y::{AccessNode, Role};
use crate::frame::FrameCtx;
use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize};
use crate::strings::StringKey;
use crate::theme::{Border, Step12, StrokeWidth};
use crate::widget_id::{WidgetId, WidgetKey};

use super::paint::{LabelText, WidgetPaint, estimate_label_width_px};
use super::tabs::{Tab, Tabs, TabsOrientation, show_tabs};
use super::toolbar::{Toolbar, ToolbarItem, show_toolbar};

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum RibbonIconSize {
    Large,
    Small,
}

impl RibbonIconSize {
    #[must_use]
    pub const fn item_px(self) -> LayoutPx {
        match self {
            Self::Large => LayoutPx::new(56.0),
            Self::Small => LayoutPx::new(28.0),
        }
    }
}

#[derive(Clone, Debug, PartialEq)]
pub struct RibbonGroup {
    pub id: WidgetId,
    pub label: StringKey,
    pub items: Vec<ToolbarItem>,
    pub icon_size: RibbonIconSize,
    pub min_width: LayoutPx,
    pub width: LayoutPx,
}

#[derive(Clone, Debug, PartialEq)]
pub struct RibbonTab {
    pub id: WidgetId,
    pub label: StringKey,
    pub disabled: bool,
    pub closable: bool,
    pub groups: Vec<RibbonGroup>,
}

impl RibbonTab {
    #[must_use]
    pub const fn new(id: WidgetId, label: StringKey, groups: Vec<RibbonGroup>) -> Self {
        Self {
            id,
            label,
            disabled: false,
            closable: false,
            groups,
        }
    }

    #[must_use]
    pub const fn closable(mut self, closable: bool) -> Self {
        self.closable = closable;
        self
    }

    #[must_use]
    pub const fn disabled(mut self, disabled: bool) -> Self {
        self.disabled = disabled;
        self
    }
}

#[derive(Copy, Clone, Debug, PartialEq)]
pub struct Ribbon<'a> {
    pub id: WidgetId,
    pub rect: LayoutRect,
    pub label: StringKey,
    pub tabs: &'a [RibbonTab],
    pub active: WidgetId,
    pub tab_strip_height: LayoutPx,
    pub group_label_height: LayoutPx,
    pub group_gap: LayoutPx,
    pub group_padding: LayoutPx,
}

impl<'a> Ribbon<'a> {
    #[must_use]
    pub const fn new(
        id: WidgetId,
        rect: LayoutRect,
        label: StringKey,
        tabs: &'a [RibbonTab],
        active: WidgetId,
    ) -> Self {
        Self {
            id,
            rect,
            label,
            tabs,
            active,
            tab_strip_height: LayoutPx::new(28.0),
            group_label_height: LayoutPx::new(16.0),
            group_gap: LayoutPx::new(8.0),
            group_padding: LayoutPx::new(8.0),
        }
    }
}

#[derive(Clone, Debug, PartialEq)]
pub struct RibbonResponse {
    pub activated_tab: Option<WidgetId>,
    pub closed_tab: Option<WidgetId>,
    pub activated_tool: Option<WidgetId>,
    pub paint: Vec<WidgetPaint>,
}

#[must_use]
pub fn show_ribbon(ctx: &mut FrameCtx<'_>, ribbon: Ribbon<'_>) -> RibbonResponse {
    let Ribbon {
        id,
        rect,
        label,
        tabs,
        active,
        tab_strip_height,
        group_label_height,
        group_gap,
        group_padding,
    } = ribbon;
    let body_height =
        LayoutPx::saturating_nonneg(rect.size.height.value() - tab_strip_height.value());
    let body_rect = LayoutRect::new(rect.origin, LayoutSize::new(rect.size.width, body_height));
    let strip_rect = LayoutRect::new(
        LayoutPos::new(
            rect.origin.x,
            LayoutPx::new(rect.origin.y.value() + body_height.value()),
        ),
        LayoutSize::new(rect.size.width, tab_strip_height),
    );
    let label_font_px = ctx.theme().typography.label.size.as_px_f32();
    let tab_views: Vec<Tab> = build_tab_strip(ctx, tabs, strip_rect, label_font_px);
    ctx.a11y
        .push(id, rect, AccessNode::new(Role::TabPanel).with_label(label));
    let mut paint = vec![WidgetPaint::Surface {
        rect,
        fill: ctx.theme().colors.surface(crate::theme::SurfaceLevel::L1),
        border: Some(Border {
            width: StrokeWidth::HAIRLINE,
            color: ctx.theme().colors.neutral.step(Step12::SUBTLE_BORDER),
        }),
        radius: ctx.theme().radius.none,
        elevation: None,
    }];
    let tabs_response = show_tabs(
        ctx,
        Tabs::new(
            id.child(WidgetKey::new("tabs")),
            TabsOrientation::Bottom,
            label,
            tab_views.as_slice(),
            active,
        ),
    );
    paint.extend(tabs_response.paint);
    let mut activated_tool: Option<WidgetId> = None;
    if let Some(active_tab) = tabs.iter().find(|t| t.id == active) {
        let groups_paint = render_groups(
            ctx,
            &active_tab.groups,
            GroupLayout {
                body_rect,
                group_label_height,
                group_gap,
                group_padding,
            },
            &mut activated_tool,
        );
        paint.extend(groups_paint);
    }
    RibbonResponse {
        activated_tab: tabs_response.activated,
        closed_tab: tabs_response.closed,
        activated_tool,
        paint,
    }
}

const RIBBON_TAB_PADDING_PX: f32 = 14.0;

fn build_tab_strip(
    ctx: &FrameCtx<'_>,
    tabs: &[RibbonTab],
    strip_rect: LayoutRect,
    label_font_px: f32,
) -> Vec<Tab> {
    let strip_max_x = strip_rect.origin.x.value() + strip_rect.size.width.value();
    tabs.iter()
        .scan(strip_rect.origin.x.value(), |x, t| {
            if *x >= strip_max_x {
                return None;
            }
            let resolved = ctx.strings.resolve(t.label);
            let desired = estimate_label_width_px(resolved, label_font_px, RIBBON_TAB_PADDING_PX);
            let width = desired.min(strip_max_x - *x).max(0.0);
            let rect = LayoutRect::new(
                LayoutPos::new(LayoutPx::new(*x), strip_rect.origin.y),
                LayoutSize::new(LayoutPx::new(width), strip_rect.size.height),
            );
            *x += width;
            Some(
                Tab::new(t.id, rect, t.label)
                    .closable(t.closable)
                    .disabled(t.disabled),
            )
        })
        .collect()
}

#[derive(Copy, Clone)]
struct GroupLayout {
    body_rect: LayoutRect,
    group_label_height: LayoutPx,
    group_gap: LayoutPx,
    group_padding: LayoutPx,
}

fn render_groups(
    ctx: &mut FrameCtx<'_>,
    groups: &[RibbonGroup],
    layout: GroupLayout,
    activated_tool: &mut Option<WidgetId>,
) -> Vec<WidgetPaint> {
    let GroupLayout {
        body_rect,
        group_label_height,
        group_gap,
        group_padding,
    } = layout;
    let mut paint = Vec::new();
    let layouts = group_rects(body_rect, groups, group_gap);
    paint.extend(group_dividers(&layouts, body_rect, group_gap, ctx));
    groups
        .iter()
        .zip(layouts.iter())
        .for_each(|(group, group_rect)| {
            ctx.a11y.push(
                group.id,
                *group_rect,
                AccessNode::new(Role::Group).with_label(group.label),
            );
            let toolbar_rect = inner_toolbar_rect(*group_rect, group_label_height, group_padding);
            let response = show_toolbar(
                ctx,
                Toolbar::horizontal(
                    group.id.child(WidgetKey::new("toolbar")),
                    toolbar_rect,
                    group.label,
                    &group.items,
                    group.icon_size.item_px(),
                    LayoutPx::new(4.0),
                ),
            );
            paint.extend(response.paint);
            if let Some(activated) = response.activated
                && activated_tool.is_none()
            {
                *activated_tool = Some(activated);
            }
            paint.push(WidgetPaint::Label {
                rect: group_label_rect(*group_rect, group_label_height),
                text: LabelText::Key(group.label),
                color: ctx.theme().colors.text_secondary(),
                role: ctx.theme().typography.caption,
            });
        });
    paint
}

fn group_dividers(
    layouts: &[LayoutRect],
    body: LayoutRect,
    gap: LayoutPx,
    ctx: &FrameCtx<'_>,
) -> Vec<WidgetPaint> {
    let thickness = StrokeWidth::HAIRLINE.value_px();
    let color = ctx.theme().colors.neutral.step(Step12::SUBTLE_BORDER);
    let inset_y = body.size.height.value() * 0.15;
    layouts
        .iter()
        .take(layouts.len().saturating_sub(1))
        .map(|rect| {
            let x = rect.origin.x.value() + rect.size.width.value() + gap.value() * 0.5
                - thickness * 0.5;
            LayoutRect::new(
                LayoutPos::new(
                    LayoutPx::new(x),
                    LayoutPx::new(body.origin.y.value() + inset_y),
                ),
                LayoutSize::new(
                    LayoutPx::new(thickness),
                    LayoutPx::saturating_nonneg(body.size.height.value() - 2.0 * inset_y),
                ),
            )
        })
        .map(|rect| WidgetPaint::Surface {
            rect,
            fill: color,
            border: None,
            radius: ctx.theme().radius.none,
            elevation: None,
        })
        .collect()
}

fn group_rects(body: LayoutRect, groups: &[RibbonGroup], gap: LayoutPx) -> Vec<LayoutRect> {
    let n = groups.len();
    if n == 0 {
        return Vec::new();
    }
    #[allow(
        clippy::cast_precision_loss,
        reason = "ribbon group counts fit in f32 mantissa"
    )]
    let total_gap = gap.value() * n.saturating_sub(1) as f32;
    let body_w = body.size.width.value();
    let avail = (body_w - total_gap).max(0.0);
    let widths = allocate_group_widths(groups, avail);
    widths
        .into_iter()
        .scan(body.origin.x.value(), |x, w| {
            let rect = LayoutRect::new(
                LayoutPos::new(LayoutPx::new(*x), body.origin.y),
                LayoutSize::new(LayoutPx::saturating_nonneg(w), body.size.height),
            );
            *x += w + gap.value();
            Some(rect)
        })
        .collect()
}

fn allocate_group_widths(groups: &[RibbonGroup], avail: f32) -> Vec<f32> {
    let min_total: f32 = groups.iter().map(|g| g.min_width.value()).sum();
    if avail <= min_total {
        let ratio = if min_total > 0.0 {
            avail / min_total
        } else {
            0.0
        };
        return groups.iter().map(|g| g.min_width.value() * ratio).collect();
    }
    let extra = avail - min_total;
    let wants: Vec<f32> = groups
        .iter()
        .map(|g| (g.width.value() - g.min_width.value()).max(0.0))
        .collect();
    let want_total: f32 = wants.iter().sum();
    if want_total <= 0.0 {
        return groups.iter().map(|g| g.min_width.value()).collect();
    }
    groups
        .iter()
        .zip(wants.iter())
        .map(|(g, want)| {
            let bonus = (extra * want / want_total).min(*want);
            g.min_width.value() + bonus
        })
        .collect()
}

fn inner_toolbar_rect(group: LayoutRect, label_height: LayoutPx, padding: LayoutPx) -> LayoutRect {
    let avail_height =
        (group.size.height.value() - label_height.value() - 2.0 * padding.value()).max(0.0);
    LayoutRect::new(
        LayoutPos::new(
            LayoutPx::new(group.origin.x.value() + padding.value()),
            LayoutPx::new(group.origin.y.value() + padding.value()),
        ),
        LayoutSize::new(
            LayoutPx::saturating_nonneg(group.size.width.value() - 2.0 * padding.value()),
            LayoutPx::new(avail_height),
        ),
    )
}

fn group_label_rect(group: LayoutRect, label_height: LayoutPx) -> LayoutRect {
    LayoutRect::new(
        LayoutPos::new(
            group.origin.x,
            LayoutPx::new(
                group.origin.y.value() + group.size.height.value() - label_height.value(),
            ),
        ),
        LayoutSize::new(group.size.width, label_height),
    )
}

#[cfg(test)]
mod tests {
    use std::sync::Arc;

    use super::{Ribbon, RibbonGroup, RibbonIconSize, RibbonTab, show_ribbon};
    use crate::focus::FocusManager;
    use crate::frame::FrameCtx;
    use crate::hit_test::{HitFrame, HitState, resolve};
    use crate::hotkey::HotkeyTable;
    use crate::input::{
        FrameInstant, InputSnapshot, PointerButton, PointerButtonMask, PointerSample,
    };
    use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize};
    use crate::strings::{StringKey, StringTable};
    use crate::theme::Theme;
    use crate::widget_id::{WidgetId, WidgetKey};
    use crate::widgets::ToolbarItem;

    fn ribbon_id() -> WidgetId {
        WidgetId::ROOT.child(WidgetKey::new("ribbon"))
    }

    fn make_group(label: &'static str, items: usize, min: f32, preferred: f32) -> RibbonGroup {
        let id = ribbon_id().child(WidgetKey::new(label));
        let item_keys: Vec<&'static str> = (0..items)
            .map(|i| match i {
                0 => "i0",
                1 => "i1",
                2 => "i2",
                3 => "i3",
                _ => "ix",
            })
            .collect();
        RibbonGroup {
            id,
            label: StringKey::new("ribbon.group"),
            items: item_keys
                .into_iter()
                .map(|k| ToolbarItem::new(id.child(WidgetKey::new(k)), StringKey::new(k)))
                .collect(),
            icon_size: RibbonIconSize::Large,
            min_width: LayoutPx::new(min),
            width: LayoutPx::new(preferred),
        }
    }

    fn body_rect(width: f32) -> LayoutRect {
        LayoutRect::new(
            LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO),
            LayoutSize::new(LayoutPx::new(width), LayoutPx::new(80.0)),
        )
    }

    #[test]
    fn allocator_gives_every_group_at_least_min_when_avail_fits_all_min() {
        let groups = vec![
            make_group("a", 12, 132.0, 1500.0),
            make_group("b", 1, 130.0, 130.0),
            make_group("c", 10, 76.0, 800.0),
        ];
        let widths = super::allocate_group_widths(&groups, 600.0);
        assert!(widths[0] >= 132.0, "entity min");
        assert!(widths[1] >= 130.0, "dim min");
        assert!(widths[2] >= 76.0, "rel min");
    }

    #[test]
    fn allocator_caps_at_preferred_for_small_demand_groups() {
        let groups = vec![
            make_group("entities", 12, 132.0, 1500.0),
            make_group("dimensions", 1, 130.0, 130.0),
            make_group("relations", 10, 76.0, 800.0),
        ];
        let widths = super::allocate_group_widths(&groups, 5000.0);
        assert!(
            (widths[1] - 130.0).abs() < 1e-3,
            "dimensions never exceeds preferred",
        );
    }

    #[test]
    fn allocator_distributes_remainder_proportionally_to_demand() {
        let groups = vec![
            make_group("a", 4, 100.0, 500.0),
            make_group("b", 4, 100.0, 500.0),
        ];
        let widths = super::allocate_group_widths(&groups, 600.0);
        assert!(
            (widths[0] - widths[1]).abs() < 1e-3,
            "equal demand → equal share"
        );
        assert!((widths[0] - 300.0).abs() < 1e-3);
    }

    #[test]
    fn allocator_scales_down_proportionally_when_avail_below_min_total() {
        let groups = vec![
            make_group("a", 4, 200.0, 400.0),
            make_group("b", 4, 100.0, 200.0),
        ];
        let widths = super::allocate_group_widths(&groups, 150.0);
        let ratio = 150.0 / 300.0;
        assert!((widths[0] - 200.0 * ratio).abs() < 1e-3);
        assert!((widths[1] - 100.0 * ratio).abs() < 1e-3);
    }

    #[test]
    fn allocator_handles_zero_avail() {
        let groups = vec![make_group("a", 4, 100.0, 200.0)];
        let widths = super::allocate_group_widths(&groups, 0.0);
        assert!(widths[0].abs() < 1e-9);
    }

    #[test]
    fn allocator_handles_zero_min_groups() {
        let groups = vec![
            make_group("a", 1, 0.0, 100.0),
            make_group("b", 1, 0.0, 100.0),
        ];
        let widths = super::allocate_group_widths(&groups, 200.0);
        assert!((widths[0] - 100.0).abs() < 1e-3);
        assert!((widths[1] - 100.0).abs() < 1e-3);
    }

    #[test]
    fn group_rects_lays_out_left_to_right_with_gap() {
        let groups = vec![
            make_group("a", 4, 100.0, 200.0),
            make_group("b", 4, 100.0, 200.0),
        ];
        let body = body_rect(450.0);
        let rects = super::group_rects(body, &groups, LayoutPx::new(8.0));
        assert_eq!(rects.len(), 2);
        assert!(rects[0].origin.x.value().abs() < 1e-9);
        let expected_b_x = rects[0].size.width.value() + 8.0;
        assert!((rects[1].origin.x.value() - expected_b_x).abs() < 1e-3);
    }

    #[test]
    fn group_rects_assigns_nonzero_width_to_every_group_when_body_is_tight() {
        let groups = vec![
            make_group("entities", 12, 132.0, 1500.0),
            make_group("dimensions", 1, 130.0, 130.0),
            make_group("relations", 10, 76.0, 800.0),
        ];
        let body = body_rect(900.0);
        let rects = super::group_rects(body, &groups, LayoutPx::new(8.0));
        rects.iter().enumerate().for_each(|(i, r)| {
            assert!(
                r.size.width.value() > 0.0,
                "group {i} got zero width on tight ribbon",
            );
        });
    }

    #[test]
    fn group_rects_handles_empty_group_slice() {
        let body = body_rect(800.0);
        let rects = super::group_rects(body, &[], LayoutPx::new(8.0));
        assert!(rects.is_empty());
    }

    fn make_ribbon_tab(name: &'static str) -> RibbonTab {
        let tab_id = ribbon_id().child(WidgetKey::new(name));
        let tool_id = tab_id.child(WidgetKey::new("tool"));
        RibbonTab::new(
            tab_id,
            StringKey::new("ribbon.tab"),
            vec![RibbonGroup {
                id: tab_id.child(WidgetKey::new("group")),
                label: StringKey::new("ribbon.group"),
                items: vec![ToolbarItem::new(tool_id, StringKey::new("ribbon.tool"))],
                icon_size: RibbonIconSize::Large,
                min_width: LayoutPx::new(80.0),
                width: LayoutPx::new(120.0),
            }],
        )
    }

    fn render(
        tabs: &[RibbonTab],
        active: WidgetId,
        focus: &mut FocusManager,
        snap: &mut InputSnapshot,
        prev: &HitState,
    ) -> (super::RibbonResponse, HitState) {
        let theme = Arc::new(Theme::light());
        let table = HotkeyTable::new();
        let mut hits = HitFrame::new();
        let rect = LayoutRect::new(
            LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO),
            LayoutSize::new(LayoutPx::new(800.0), LayoutPx::new(120.0)),
        );
        let response = {
            let mut shaper = bone_text::Shaper::new();
            let mut a11y = crate::a11y::AccessTreeBuilder::new();
            let mut ctx = FrameCtx::new(
                theme,
                snap,
                focus,
                &table,
                StringTable::empty(),
                &mut hits,
                prev,
                &mut a11y,
                &mut shaper,
            );
            show_ribbon(
                &mut ctx,
                Ribbon::new(
                    ribbon_id(),
                    rect,
                    StringKey::new("test.ribbon"),
                    tabs,
                    active,
                ),
            )
        };
        let next = resolve(prev, &hits, snap, focus.focused());
        (response, next)
    }

    #[test]
    fn switching_tabs_emits_activated_tab() {
        let tabs = vec![make_ribbon_tab("home"), make_ribbon_tab("sketch")];
        let mut focus = FocusManager::new();
        let mut prev = HitState::new();
        let click_pos = LayoutPos::new(LayoutPx::new(150.0), LayoutPx::new(105.0));
        let mut last: Option<super::RibbonResponse> = None;
        [press(click_pos), release(click_pos), idle(click_pos)]
            .into_iter()
            .for_each(|mut snap| {
                let (response, next) = render(&tabs, tabs[0].id, &mut focus, &mut snap, &prev);
                last = Some(response);
                prev = next;
            });
        let Some(response) = last else {
            panic!("response missing")
        };
        assert_eq!(response.activated_tab, Some(tabs[1].id));
    }

    #[test]
    fn click_tool_in_active_tab_emits_activated_tool() {
        let tabs = vec![make_ribbon_tab("home")];
        let mut focus = FocusManager::new();
        let mut prev = HitState::new();
        let click_pos = LayoutPos::new(LayoutPx::new(40.0), LayoutPx::new(60.0));
        let mut last: Option<super::RibbonResponse> = None;
        [press(click_pos), release(click_pos), idle(click_pos)]
            .into_iter()
            .for_each(|mut snap| {
                let (response, next) = render(&tabs, tabs[0].id, &mut focus, &mut snap, &prev);
                last = Some(response);
                prev = next;
            });
        let Some(response) = last else {
            panic!("response missing")
        };
        assert_eq!(response.activated_tool, Some(tabs[0].groups[0].items[0].id),);
    }

    #[test]
    fn closing_a_closable_ribbon_tab_propagates_closed_tab() {
        let make_closable =
            |name: &'static str| -> RibbonTab { make_ribbon_tab(name).closable(true) };
        let tabs = vec![make_closable("home"), make_closable("sketch")];
        let mut focus = FocusManager::new();
        let mut prev = HitState::new();
        let label_font_px = 12.0_f32;
        let tab_width = super::estimate_label_width_px(
            "ribbon.tab",
            label_font_px,
            super::RIBBON_TAB_PADDING_PX,
        );
        let close_pad = (28.0 - 14.0) / 2.0;
        let close_x = tab_width + tab_width - 14.0 - close_pad;
        let strip_top = 120.0 - 28.0;
        let close_y = strip_top + 28.0 / 2.0;
        let close_pos = LayoutPos::new(LayoutPx::new(close_x), LayoutPx::new(close_y));
        let mut last: Option<super::RibbonResponse> = None;
        [press(close_pos), release(close_pos), idle(close_pos)]
            .into_iter()
            .for_each(|mut snap| {
                let (response, next) = render(&tabs, tabs[0].id, &mut focus, &mut snap, &prev);
                last = Some(response);
                prev = next;
            });
        let Some(response) = last else {
            panic!("response missing")
        };
        assert_eq!(response.closed_tab, Some(tabs[1].id));
        assert!(response.activated_tab.is_none());
    }

    #[test]
    fn arrow_then_enter_switches_active_tab_via_keyboard() {
        use crate::input::{KeyCode, KeyEvent, ModifierMask, NamedKey};

        let tabs = vec![make_ribbon_tab("home"), make_ribbon_tab("sketch")];
        let mut focus = FocusManager::new();
        let prev = HitState::new();
        focus.request_focus(tabs[0].id);
        let mut warm = InputSnapshot::idle(FrameInstant::ZERO);
        let _ = render(&tabs, tabs[0].id, &mut focus, &mut warm, &prev);
        assert_eq!(focus.focused(), Some(tabs[0].id));

        let mut arrow = InputSnapshot::idle(FrameInstant::ZERO);
        arrow.keys_pressed.push(KeyEvent::new(
            KeyCode::Named(NamedKey::ArrowRight),
            ModifierMask::NONE,
        ));
        let _ = render(&tabs, tabs[0].id, &mut focus, &mut arrow, &prev);
        assert_eq!(focus.focused(), Some(tabs[1].id));

        let mut enter = InputSnapshot::idle(FrameInstant::ZERO);
        enter.keys_pressed.push(KeyEvent::new(
            KeyCode::Named(NamedKey::Enter),
            ModifierMask::NONE,
        ));
        let (response, _) = render(&tabs, tabs[0].id, &mut focus, &mut enter, &prev);
        assert_eq!(response.activated_tab, Some(tabs[1].id));
    }

    fn press(pos: LayoutPos) -> InputSnapshot {
        let mut s = InputSnapshot::idle(FrameInstant::ZERO);
        s.pointer = Some(PointerSample::new(pos));
        s.buttons_pressed = PointerButtonMask::just(PointerButton::Primary);
        s
    }

    fn release(pos: LayoutPos) -> InputSnapshot {
        let mut s = InputSnapshot::idle(FrameInstant::ZERO);
        s.pointer = Some(PointerSample::new(pos));
        s.buttons_released = PointerButtonMask::just(PointerButton::Primary);
        s
    }

    fn idle(pos: LayoutPos) -> InputSnapshot {
        let mut s = InputSnapshot::idle(FrameInstant::ZERO);
        s.pointer = Some(PointerSample::new(pos));
        s
    }
}
