use bevy::ecs::lifecycle::HookContext;
use bevy::ecs::world::DeferredWorld;
use bevy::input::keyboard::{KeyCode, KeyboardInput};
use bevy::input_focus::tab_navigation::{TabGroup, TabIndex};
use bevy::input_focus::{FocusCause, FocusedInput, InputFocus, InputFocusVisible};
use bevy::picking::hover::Hovered;
use bevy::prelude::*;
use bevy::ui::auto_directional_navigation::AutoDirectionalNavigation;
use bevy::ui::{
Checked, ComputedUiRenderTargetInfo, InteractionDisabled, OverrideClip, UiGlobalTransform,
};
use bevy::ui_widgets::{Activate, ScrollArea, ValueChange};
use crate::cancel::Cancel;
use crate::context::ContextTarget;
use crate::list::span;
use crate::model::scroll_to_reveal;
use crate::nav::{Dialog, show_focus};
use crate::popover::{Placement, PopoverPanel};
use crate::shortcut::{Modifiers, Shortcut};
use crate::skin::{GooeySkin, Role};
use crate::tooltip::Tooltip;
use crate::widgets::{CHEVRON_INK_SHIFT, ONE_LINE};
pub(crate) type MenuRow = Or<(With<MenuTitle>, With<MenuItem>)>;
type HoveredTitle = (With<MenuTitle>, Changed<Hovered>);
type OpenTitle = (With<MenuTitle>, With<MenuOpen>);
const NOTHING_HERE: &str = "Nothing here";
#[derive(Component, Clone, Copy, Debug, Default, Reflect)]
#[reflect(Component, Default, Debug, Clone)]
pub struct MenuBar {
entered_from: Option<Entity>,
}
#[derive(Component, Clone, Copy, Debug, Default, Reflect)]
#[reflect(Component, Default, Debug, Clone)]
pub struct MenuTitle;
#[derive(Component, Clone, Copy, Debug, Default, Reflect)]
#[reflect(Component, Default, Debug, Clone)]
pub struct MenuPanel;
#[derive(Component, Clone, Copy, Debug, Default, Reflect)]
#[reflect(Component, Default, Debug, Clone)]
pub struct MenuOpen;
#[derive(Component, Clone, Copy, Debug, Default, Reflect)]
#[reflect(Component, Default, Debug, Clone)]
pub struct MenuMarked;
#[derive(Component, Clone, Copy, Debug)]
struct MenuNothing;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Kind {
Command,
Check,
Radio,
Submenu,
}
#[derive(Component, Clone, Debug)]
#[component(on_add = build_item)]
pub struct MenuItem {
label: String,
shortcut: Option<Shortcut>,
kind: Kind,
on: bool,
}
impl MenuItem {
pub(crate) fn command(label: impl Into<String>) -> Self {
Self::of(label, Kind::Command, false)
}
pub(crate) fn check(label: impl Into<String>, on: bool) -> Self {
Self::of(label, Kind::Check, on)
}
pub(crate) fn radio(label: impl Into<String>, on: bool) -> Self {
Self::of(label, Kind::Radio, on)
}
pub(crate) fn submenu(label: impl Into<String>) -> Self {
Self::of(label, Kind::Submenu, false)
}
fn of(label: impl Into<String>, kind: Kind, on: bool) -> Self {
Self {
label: label.into(),
shortcut: None,
kind,
on,
}
}
pub(crate) fn access(&self) -> (accesskit::Role, bool) {
match self.kind {
Kind::Command => (accesskit::Role::MenuItem, false),
Kind::Check => (accesskit::Role::MenuItemCheckBox, false),
Kind::Radio => (accesskit::Role::MenuItemRadio, false),
Kind::Submenu => (accesskit::Role::MenuItem, true),
}
}
#[must_use]
pub fn with_shortcut(mut self, shortcut: Shortcut) -> Self {
self.shortcut = Some(shortcut);
self
}
}
#[derive(Component, Clone, Debug, PartialEq, Eq)]
#[component(on_insert = disable, on_remove = enable)]
pub struct Disabled(pub String);
impl Disabled {
pub fn new(reason: impl Into<String>) -> Self {
Self(reason.into())
}
}
fn disable(mut world: DeferredWorld, HookContext { entity, .. }: HookContext) {
let Some(reason) = world.get::<Disabled>(entity).map(|d| d.0.clone()) else {
return;
};
world
.commands()
.entity(entity)
.insert((InteractionDisabled, Tooltip::new(reason)));
}
fn enable(mut world: DeferredWorld, HookContext { entity, .. }: HookContext) {
world
.commands()
.entity(entity)
.try_remove::<(InteractionDisabled, Tooltip)>();
}
fn build_item(mut world: DeferredWorld, HookContext { entity, .. }: HookContext) {
let Some(item) = world.get::<MenuItem>(entity).cloned() else {
return;
};
let mut commands = world.commands();
let mut row = commands.entity(entity);
row.insert((
Node::default(),
bevy::ui_widgets::Button,
Role::MenuItem,
Hovered::default(),
TabIndex(0),
AutoDirectionalNavigation::default(),
));
if item.on {
row.insert(Checked);
}
row.with_children(|row| {
let mark = row
.spawn((Node::default(), Role::MenuMark, Pickable::IGNORE))
.id();
row.commands()
.entity(mark)
.with_children(|mark| match item.kind {
Kind::Check => {
mark.spawn((
Node::default(),
Role::MenuCheck,
UiTransform {
rotation: Rot2::degrees(45.0),
translation: Val2::percent(0.0, -12.0),
..default()
},
Pickable::IGNORE,
));
}
Kind::Radio => {
mark.spawn((Node::default(), Role::MenuDot, Pickable::IGNORE));
}
Kind::Command | Kind::Submenu => {}
});
row.spawn((
Text::new(item.label),
Role::MenuText,
ONE_LINE,
Pickable::IGNORE,
));
if let Some(shortcut) = item.shortcut {
row.spawn((
Text::new(shortcut.label()),
Role::MenuShortcut,
ONE_LINE,
Pickable::IGNORE,
));
}
if item.kind == Kind::Submenu {
row.spawn((
Node::default(),
Role::MenuChevron,
UiTransform {
translation: Val2::percent(-CHEVRON_INK_SHIFT, 0.0),
rotation: Rot2::degrees(45.0),
..default()
},
Pickable::IGNORE,
));
}
});
}
fn panel_of(world: &World, anchor: Entity) -> Option<Entity> {
world
.get::<Children>(anchor)?
.iter()
.find(|&child| world.get::<MenuPanel>(child).is_some())
}
fn is_open(world: &World, panel: Entity) -> bool {
world.get::<PopoverPanel>(panel).is_some()
}
fn anchor_of(world: &World, panel: Entity) -> Option<Entity> {
world.get::<ChildOf>(panel).map(ChildOf::parent)
}
fn panels_below(world: &World, entity: Entity, out: &mut Vec<Entity>) {
let Some(children) = world.get::<Children>(entity) else {
return;
};
for child in children.iter() {
panels_below(world, child, out);
if world.get::<MenuPanel>(child).is_some() {
out.push(child);
}
}
}
pub(crate) fn open_menu(world: &mut World, panel: Entity) {
if is_open(world, panel) {
return;
}
let Some(anchor) = anchor_of(world, panel) else {
return;
};
let siblings: Vec<Entity> = world
.get::<ChildOf>(anchor)
.and_then(|up| world.get::<Children>(up.parent()))
.map(|kids| kids.iter().filter(|&k| k != anchor).collect())
.unwrap_or_default();
for sibling in siblings {
if let Some(other) = panel_of(world, sibling)
&& is_open(world, other)
{
close_menu(world, other);
}
}
let placement = if world.get::<MenuTitle>(anchor).is_some() {
Placement::Below
} else {
Placement::Right
};
show_menu(world, panel, anchor, placement);
world.entity_mut(anchor).insert(MenuOpen);
}
pub(crate) fn show_menu(world: &mut World, panel: Entity, anchor: Entity, placement: Placement) {
sync_nothing(world, panel);
sync_marks(world, panel);
let mut entity = world.entity_mut(panel);
entity.insert((
PopoverPanel::menu(anchor, placement),
Dialog::default(),
TabGroup {
order: 0,
modal: true,
},
Visibility::Hidden,
));
if let Some(mut node) = entity.get_mut::<Node>() {
node.display = Display::Flex;
}
}
pub(crate) fn close_menu(world: &mut World, panel: Entity) {
let mut below = Vec::new();
panels_below(world, panel, &mut below);
for inner in below {
if is_open(world, inner) {
close_menu(world, inner);
}
}
let anchor = anchor_of(world, panel);
if let Ok(mut entity) = world.get_entity_mut(panel) {
entity.remove::<(PopoverPanel, Dialog, TabGroup)>();
entity.insert(Visibility::Hidden);
if let Some(mut node) = entity.get_mut::<Node>() {
node.display = Display::None;
}
if entity.contains::<ContextTarget>() {
entity.remove::<(ContextTarget, ChildOf)>();
}
}
if let Some(anchor) = anchor
&& let Ok(mut entity) = world.get_entity_mut(anchor)
{
entity.remove::<MenuOpen>();
}
}
fn sync_marks(world: &mut World, panel: Entity) {
let items: Vec<(Entity, Kind)> = world
.get::<Children>(panel)
.map(|kids| {
kids.iter()
.filter_map(|k| world.get::<MenuItem>(k).map(|i| (k, i.kind)))
.collect()
})
.unwrap_or_default();
let marked = items
.iter()
.any(|&(_, kind)| matches!(kind, Kind::Check | Kind::Radio));
for (item, _) in items {
if marked {
world.entity_mut(item).insert(MenuMarked);
} else {
world.entity_mut(item).remove::<MenuMarked>();
}
}
}
fn sync_nothing(world: &mut World, panel: Entity) {
let kids: Vec<Entity> = world
.get::<Children>(panel)
.map(|k| k.iter().collect())
.unwrap_or_default();
let has_items = kids.iter().any(|&k| world.get::<MenuItem>(k).is_some());
let note = kids
.iter()
.copied()
.find(|&k| world.get::<MenuNothing>(k).is_some());
match (has_items, note) {
(false, None) => {
world.spawn((
Node::default(),
Text::new(NOTHING_HERE),
Role::MenuNote,
ONE_LINE,
MenuNothing,
ChildOf(panel),
));
}
(true, Some(note)) => {
world.entity_mut(note).despawn();
}
_ => {}
}
}
fn root_of(world: &World, from: Entity) -> Option<Entity> {
let mut root = None;
let mut at = Some(from);
while let Some(entity) = at {
if world.get::<MenuPanel>(entity).is_some() && is_open(world, entity) {
root = Some(entity);
}
at = world.get::<ChildOf>(entity).map(ChildOf::parent);
}
root
}
fn title_of(world: &World, from: Entity) -> Option<Entity> {
let mut at = Some(from);
while let Some(entity) = at {
if world.get::<MenuTitle>(entity).is_some() {
return Some(entity);
}
at = world.get::<ChildOf>(entity).map(ChildOf::parent);
}
None
}
fn focus_on(world: &mut World, entity: Entity) {
world
.resource_mut::<InputFocus>()
.set(entity, FocusCause::Navigated);
if let Some(mut shown) = world.get_resource_mut::<InputFocusVisible>() {
show_focus_world(&mut shown);
}
}
fn show_focus_world(shown: &mut InputFocusVisible) {
if !shown.0 {
shown.0 = true;
}
}
fn leave_bar(world: &mut World, title: Entity) {
let Some(bar) = world.get::<ChildOf>(title).map(ChildOf::parent) else {
return;
};
let Some(mut state) = world.get_mut::<MenuBar>(bar) else {
return;
};
let Some(back) = state.entered_from.take() else {
return;
};
if world.get_entity(back).is_ok() {
world
.resource_mut::<InputFocus>()
.set(back, FocusCause::Navigated);
}
}
pub(crate) fn menu_on_activate(
activate: On<Activate>,
rows: Query<(Has<MenuTitle>, Has<MenuItem>)>,
mut commands: Commands,
) {
let entity = activate.entity;
match rows.get(entity) {
Ok((true, _)) => {
commands.queue(move |world: &mut World| toggle_title(world, entity));
}
Ok((_, true)) => {
commands.queue(move |world: &mut World| activate_item(world, entity));
}
_ => {}
}
}
fn toggle_title(world: &mut World, title: Entity) {
let Some(panel) = panel_of(world, title) else {
return;
};
if is_open(world, panel) {
close_menu(world, panel);
} else {
open_menu(world, panel);
}
}
fn activate_item(world: &mut World, item: Entity) {
if world.get::<InteractionDisabled>(item).is_some() {
return;
}
let Some(kind) = world.get::<MenuItem>(item).map(|i| i.kind) else {
return;
};
match kind {
Kind::Submenu => {
if let Some(panel) = panel_of(world, item) {
open_menu(world, panel);
}
return;
}
Kind::Check => {
let on = world.get::<Checked>(item).is_none();
set_checked(world, item, on);
world.trigger(ValueChange {
source: item,
value: on,
is_final: true,
});
}
Kind::Radio => {
if world.get::<Checked>(item).is_none() {
for other in radio_run(world, item) {
set_checked(world, other, other == item);
}
world.trigger(ValueChange {
source: item,
value: true,
is_final: true,
});
}
}
Kind::Command => {}
}
if let Some(root) = root_of(world, item) {
let title = anchor_of(world, root).filter(|&a| world.get::<MenuTitle>(a).is_some());
close_menu(world, root);
if let Some(title) = title {
leave_bar(world, title);
}
}
}
fn set_checked(world: &mut World, entity: Entity, on: bool) {
let mut entity = world.entity_mut(entity);
if on {
entity.insert(Checked);
} else {
entity.remove::<Checked>();
}
}
fn radio_run(world: &World, item: Entity) -> Vec<Entity> {
let is_radio = |e: Entity| {
world
.get::<MenuItem>(e)
.is_some_and(|i| i.kind == Kind::Radio)
};
let Some(kids) = world
.get::<ChildOf>(item)
.and_then(|up| world.get::<Children>(up.parent()))
else {
return vec![item];
};
let kids: Vec<Entity> = kids.iter().collect();
let Some(at) = kids.iter().position(|&k| k == item) else {
return vec![item];
};
let start = kids[..at]
.iter()
.rposition(|&k| !is_radio(k))
.map_or(0, |i| i + 1);
let end = kids[at..]
.iter()
.position(|&k| !is_radio(k))
.map_or(kids.len(), |i| at + i);
kids[start..end].to_vec()
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Key {
Right,
Left,
Down,
Home,
End,
}
pub fn menu_by_input(
keys: Res<ButtonInput<KeyCode>>,
gamepads: Query<&Gamepad>,
focus: Res<InputFocus>,
mut shown: ResMut<InputFocusVisible>,
rows: Query<(), MenuRow>,
mut commands: Commands,
) {
let Some(focused) = focus.get().filter(|&f| rows.contains(f)) else {
return;
};
let pressed = |key: KeyCode, pad: GamepadButton| {
keys.just_pressed(key) || gamepads.iter().any(|g| g.just_pressed(pad))
};
let key = if pressed(KeyCode::ArrowRight, GamepadButton::DPadRight) {
Key::Right
} else if pressed(KeyCode::ArrowLeft, GamepadButton::DPadLeft) {
Key::Left
} else if pressed(KeyCode::ArrowDown, GamepadButton::DPadDown) {
Key::Down
} else if keys.just_pressed(KeyCode::Home) {
Key::Home
} else if keys.just_pressed(KeyCode::End) {
Key::End
} else {
return;
};
show_focus(&mut shown);
commands.queue(move |world: &mut World| menu_key(world, focused, key));
}
fn menu_key(world: &mut World, focused: Entity, key: Key) {
if world.get::<MenuTitle>(focused).is_some() {
match key {
Key::Down => {
if let Some(panel) = panel_of(world, focused)
&& world.get::<InteractionDisabled>(focused).is_none()
{
open_menu(world, panel);
}
}
Key::Right => step_title(world, focused, 1, false),
Key::Left => step_title(world, focused, -1, false),
Key::Home => edge_title(world, focused, false),
Key::End => edge_title(world, focused, true),
}
return;
}
let Some(kind) = world.get::<MenuItem>(focused).map(|i| i.kind) else {
return;
};
let Some(panel) = world.get::<ChildOf>(focused).map(ChildOf::parent) else {
return;
};
let in_submenu = anchor_of(world, panel).is_some_and(|a| world.get::<MenuItem>(a).is_some());
match key {
Key::Right => {
if kind == Kind::Submenu {
if world.get::<InteractionDisabled>(focused).is_none()
&& let Some(sub) = panel_of(world, focused)
{
open_menu(world, sub);
}
} else if let Some(title) = title_of(world, focused) {
step_title(world, title, 1, true);
}
}
Key::Left => {
if let Some(sub) = panel_of(world, focused).filter(|&sub| is_open(world, sub)) {
close_menu(world, sub);
} else if in_submenu {
close_menu(world, panel);
} else if let Some(title) = title_of(world, focused) {
step_title(world, title, -1, true);
}
}
Key::Home | Key::End => {
let items: Vec<Entity> = world
.get::<Children>(panel)
.map(|k| {
k.iter()
.filter(|&e| world.get::<MenuItem>(e).is_some())
.collect()
})
.unwrap_or_default();
let to = if key == Key::Home {
items.first()
} else {
items.last()
};
if let Some(&to) = to {
focus_on(world, to);
}
}
Key::Down => {}
}
}
fn titles_of(world: &World, title: Entity) -> Vec<Entity> {
world
.get::<ChildOf>(title)
.and_then(|up| world.get::<Children>(up.parent()))
.map(|kids| {
kids.iter()
.filter(|&k| world.get::<MenuTitle>(k).is_some())
.collect()
})
.unwrap_or_default()
}
fn step_title(world: &mut World, from: Entity, by: isize, open: bool) {
let titles = titles_of(world, from);
let Some(at) = titles.iter().position(|&t| t == from) else {
return;
};
let n = titles.len() as isize;
let to = titles[(at as isize + by).rem_euclid(n) as usize];
if let Some(panel) = panel_of(world, from)
&& is_open(world, panel)
{
close_menu(world, panel);
}
focus_on(world, to);
if open && let Some(panel) = panel_of(world, to) {
open_menu(world, panel);
}
}
fn edge_title(world: &mut World, from: Entity, last: bool) {
let titles = titles_of(world, from);
let to = if last { titles.last() } else { titles.first() };
if let Some(&to) = to {
focus_on(world, to);
}
}
#[allow(clippy::too_many_arguments)]
pub fn focus_menu_bar(
keys: Res<ButtonInput<KeyCode>>,
mut alone: Local<bool>,
focus: Res<InputFocus>,
mut shown: ResMut<InputFocusVisible>,
mut bars: Query<(
Entity,
&mut MenuBar,
&InheritedVisibility,
Option<&Children>,
)>,
titles: Query<(), With<MenuTitle>>,
parents: Query<&ChildOf>,
mut commands: Commands,
) {
let alt = |key: &KeyCode| matches!(key, KeyCode::AltLeft | KeyCode::AltRight);
if keys.get_just_pressed().any(|k| !alt(k)) {
*alone = false;
} else if keys.get_just_pressed().any(alt) {
*alone = true;
}
let tapped = *alone && keys.get_just_released().any(alt);
if tapped {
*alone = false;
}
let f10 = keys.just_pressed(KeyCode::F10) && !Modifiers::held(&keys).shift;
if !(tapped || f10) {
return;
}
let current = focus.get();
let inside = |bar: Entity| {
current.is_some_and(|f| f == bar || parents.iter_ancestors(f).any(|a| a == bar))
};
if let Some((_, mut state, ..)) = bars.iter_mut().find(|(bar, ..)| inside(*bar)) {
if let Some(back) = state.entered_from.take() {
commands.queue(move |world: &mut World| {
if world.get_entity(back).is_ok() {
world
.resource_mut::<InputFocus>()
.set(back, FocusCause::Navigated);
}
});
}
return;
}
let Some((_, mut state, _, Some(kids))) = bars.iter_mut().find(|(_, _, seen, kids)| {
seen.get() && kids.is_some_and(|k| k.iter().any(|t| titles.contains(t)))
}) else {
return;
};
let Some(first) = kids.iter().find(|&t| titles.contains(t)) else {
return;
};
state.entered_from = current;
show_focus(&mut shown);
commands.queue(move |world: &mut World| focus_on(world, first));
}
pub(crate) fn bar_on_cancel(
mut cancel: On<Cancel>,
mut bars: Query<&mut MenuBar>,
mut focus: ResMut<InputFocus>,
alive: Query<()>,
) {
let Ok(mut bar) = bars.get_mut(cancel.entity) else {
return;
};
if let Some(back) = bar.entered_from.take()
&& alive.contains(back)
{
cancel.propagate(false);
focus.set(back, FocusCause::Navigated);
}
}
pub fn switch_menus_on_hover(
hovered: Query<(Entity, &Hovered, &ChildOf), HoveredTitle>,
open: Query<(Entity, &ChildOf), OpenTitle>,
mut commands: Commands,
) {
for (title, hover, bar) in &hovered {
if !hover.get() || open.contains(title) {
continue;
}
if open.iter().any(|(_, other)| other.parent() == bar.parent()) {
commands.queue(move |world: &mut World| {
if world.get::<InteractionDisabled>(title).is_some() {
return;
}
let Some(panel) = panel_of(world, title) else {
return;
};
focus_on(world, title);
open_menu(world, panel);
});
}
}
}
pub fn fit_menus(
skin: Res<GooeySkin>,
mut panels: Query<(&ChildOf, &PopoverPanel, &mut Node), With<MenuPanel>>,
anchors: Query<(
&ComputedNode,
&UiGlobalTransform,
&ComputedUiRenderTargetInfo,
)>,
) {
for (parent, panel, mut node) in &mut panels {
let Ok((computed, transform, target)) = anchors.get(parent.parent()) else {
continue;
};
let (top, height) = span(computed, transform);
let window = target.logical_size().y;
let line = skin.border;
let room = match panel.placement {
Placement::Below | Placement::Above => {
let below = window - (top + height) - skin.gap * 1.5 - 2.0 * line;
let above = top - skin.gap * 1.5 - 2.0 * line;
below.max(above)
}
Placement::AtPointer => (window - top).max(top) - skin.gap,
_ => window - top - skin.gap,
};
let least = skin.target() * 3.0;
let cap = Val::Px(room.max(least).floor());
if node.max_height != cap {
node.max_height = cap;
}
}
}
pub fn scroll_menus_to_focus(
focus: Res<InputFocus>,
visible: Res<InputFocusVisible>,
parents: Query<&ChildOf>,
panels: Query<(), With<MenuPanel>>,
nodes: Query<(&ComputedNode, &UiGlobalTransform), Without<MenuPanel>>,
mut views: Query<(&ComputedNode, &UiGlobalTransform, &mut ScrollPosition), With<MenuPanel>>,
) {
if !visible.0 || !(focus.is_changed() || visible.is_changed()) {
return;
}
let Some(target) = focus.get() else {
return;
};
let Some(panel) = parents.iter_ancestors(target).find(|&a| panels.contains(a)) else {
return;
};
let (Ok((node, transform)), Ok((view, view_transform, mut scroll))) =
(nodes.get(target), views.get_mut(panel))
else {
return;
};
let scale = view.inverse_scale_factor();
let (top, height) = span(node, transform);
let (view_top, view_height) = span(view, view_transform);
let laid_out = view.scroll_position.y * scale;
let max = (view.content_size().y + view.scrollbar_size.y - view.size().y).max(0.0) * scale;
if max <= 0.0 {
return;
}
let from_start = top - view_top + laid_out;
let want = scroll_to_reveal(
from_start,
from_start + height,
view_height,
laid_out,
max,
0.0,
);
if (want - scroll.y).abs() > 0.5 {
scroll.y = want;
}
}
fn stop_activation_keys(mut input: On<FocusedInput<KeyboardInput>>) {
if matches!(input.input.key_code, KeyCode::Enter | KeyCode::Space) {
input.propagate(false);
}
}
pub(crate) fn menu_panel(items: impl Bundle) -> impl Bundle {
use crate::popover::{
placement_of, popover_on_cancel, stop_acquire, stop_click, stop_press, stop_release,
};
use bevy::ui::FixedNode;
use bevy::ui_widgets::observe;
(
Node {
position_type: PositionType::Absolute,
display: Display::None,
overflow: Overflow::scroll_y(),
..default()
},
Visibility::Hidden,
Role::MenuPanel,
MenuPanel,
ScrollArea,
placement_of(Placement::Below, 0.0, 0.0),
FixedNode,
BoxShadow::default(),
GlobalZIndex(150),
OverrideClip,
observe(popover_on_cancel),
observe(stop_activation_keys),
(
observe(stop_press),
observe(stop_release),
observe(stop_click),
observe(stop_acquire),
),
items,
)
}