Files
ARCADIA-Studio/crates/oa-desktop/src/context_menu.rs
T

1729 lines
58 KiB
Rust

//! Controlled contextual menus. Keep `Popup` around the root even when closed
//! so a dismissal click's release cannot reach the widget underneath it.
//! Each invocation gets a fresh token; action messages should carry that token
//! for application-level context/revision validation. The application closes
//! the menu when handling an action; `on_close` handles dismissal only.
use crate::style;
use iced::advanced::Renderer as _;
use iced::{
Border, Color, Element, Event, Length, Point, Rectangle, Renderer, Shadow, Size, Theme, Vector,
advanced::{
Clipboard, Layout, Shell, Widget, layout, overlay, renderer,
text::{self, Paragraph as _, Renderer as _},
widget::{Operation, Tree, tree},
},
alignment, keyboard, mouse, touch, window,
};
const PAD: f32 = 6.;
const GAP: f32 = 8.;
const ROW: f32 = 34.;
#[derive(Debug, Clone)]
pub enum Entry<Message> {
Title(String),
Separator,
Action {
label: String,
shortcut: Option<String>,
message: Option<Message>,
},
}
impl<Message> Entry<Message> {
pub fn title(label: impl Into<String>) -> Self {
Self::Title(label.into())
}
pub fn separator() -> Self {
Self::Separator
}
pub fn action(label: impl Into<String>, message: Message) -> Self {
Self::Action {
label: label.into(),
shortcut: None,
message: Some(message),
}
}
pub fn disabled(label: impl Into<String>) -> Self {
Self::Action {
label: label.into(),
shortcut: None,
message: None,
}
}
pub fn shortcut(mut self, shortcut: impl Into<String>) -> Self {
if let Self::Action {
shortcut: label, ..
} = &mut self
{
*label = Some(shortcut.into());
}
self
}
fn height(&self) -> f32 {
match self {
Self::Title(_) => 28.,
Self::Separator => 9.,
Self::Action { .. } => ROW,
}
}
fn enabled(&self) -> bool {
matches!(
self,
Self::Action {
message: Some(_),
..
}
)
}
}
#[derive(Debug, Clone)]
pub struct Menu<Message> {
pub token: u64,
/// Absolute window coordinates, as returned by `Area` or the diagram.
pub anchor: Point,
pub entries: Vec<Entry<Message>>,
pub on_close: Message,
}
pub struct Popup<'a, Message> {
content: Element<'a, Message>,
menu: Option<Menu<Message>>,
}
impl<'a, Message> Popup<'a, Message> {
pub fn new(content: impl Into<Element<'a, Message>>, menu: Option<Menu<Message>>) -> Self {
Self {
content: content.into(),
menu,
}
}
}
#[derive(Default)]
struct State {
token: Option<u64>,
focused: Option<usize>,
pressed: Option<usize>,
scroll: f32,
scroll_drag: Option<f32>,
closing: bool,
swallow_mouse_release: Option<mouse::Button>,
swallow_touch_release: Option<touch::Finger>,
}
impl State {
fn reconcile<Message>(&mut self, menu: Option<&Menu<Message>>) {
let token = menu.map(|menu| menu.token);
if self.token != token {
self.token = token;
self.focused = menu.and_then(|menu| menu.entries.iter().position(Entry::enabled));
self.pressed = None;
self.scroll = 0.;
self.scroll_drag = None;
self.closing = false;
}
if let Some(menu) = menu {
self.focused = self
.focused
.filter(|index| menu.entries.get(*index).is_some_and(Entry::enabled));
self.pressed = self
.pressed
.filter(|index| menu.entries.get(*index).is_some_and(Entry::enabled));
}
}
fn swallow_release(&mut self, event: &Event) -> bool {
match event {
Event::Mouse(mouse::Event::ButtonReleased(button))
if self.swallow_mouse_release == Some(*button) =>
{
self.swallow_mouse_release = None;
true
}
Event::Touch(
touch::Event::FingerLifted { id, .. } | touch::Event::FingerLost { id, .. },
) if self.swallow_touch_release == Some(*id) => {
self.swallow_touch_release = None;
true
}
_ => false,
}
}
fn navigate<Message>(
&mut self,
entries: &[Entry<Message>],
direction: Navigation,
height: f32,
) {
self.focused = next_enabled(entries, self.focused, direction);
self.pressed = None;
if let Some(index) = self.focused {
let top: f32 = entries[..index].iter().map(Entry::height).sum();
let bottom = top + entries[index].height();
if top < self.scroll {
self.scroll = top;
}
if bottom > self.scroll + height {
self.scroll = (bottom - height).max(0.);
}
}
}
fn dismiss<Message: Clone>(&mut self, menu: &Menu<Message>, shell: &mut Shell<'_, Message>) {
if !self.closing {
self.closing = true;
shell.publish(menu.on_close.clone());
}
}
fn activate<Message: Clone>(
&mut self,
menu: &Menu<Message>,
index: usize,
shell: &mut Shell<'_, Message>,
) {
if !self.closing
&& let Some(Entry::Action {
message: Some(message),
..
}) = menu.entries.get(index)
{
self.closing = true;
shell.publish(message.clone());
}
}
fn update<Message: Clone>(
&mut self,
menu: &Menu<Message>,
geometry: Geometry,
event: &Event,
cursor: mouse::Cursor,
shell: &mut Shell<'_, Message>,
) {
let hit = |point| geometry.hit(&menu.entries, self.scroll, point);
if self.closing {
// Iced can deliver press and release in one event batch before the
// application handles Close. Do not carry that consumed release
// into the next independent click after the controlled menu closes.
self.swallow_release(event);
if input_event(event) {
shell.capture_event();
}
return;
}
match event {
Event::Keyboard(keyboard::Event::KeyPressed { key, .. }) => match key {
keyboard::Key::Named(keyboard::key::Named::Escape) => self.dismiss(menu, shell),
keyboard::Key::Named(keyboard::key::Named::ArrowDown) => {
self.navigate(&menu.entries, Navigation::Next, geometry.inner.height)
}
keyboard::Key::Named(keyboard::key::Named::ArrowUp) => {
self.navigate(&menu.entries, Navigation::Previous, geometry.inner.height)
}
keyboard::Key::Named(keyboard::key::Named::Home) => {
self.navigate(&menu.entries, Navigation::First, geometry.inner.height)
}
keyboard::Key::Named(keyboard::key::Named::End) => {
self.navigate(&menu.entries, Navigation::Last, geometry.inner.height)
}
keyboard::Key::Named(keyboard::key::Named::Enter | keyboard::key::Named::Space) => {
if let Some(index) = self.focused {
self.activate(menu, index, shell);
}
}
_ => {}
},
Event::Mouse(mouse::Event::CursorMoved { position }) => {
if let Some(offset) = self.scroll_drag {
self.scroll = geometry.scroll_for_pointer(*position, offset);
} else {
self.focused = hit(*position).filter(|index| menu.entries[*index].enabled());
}
}
Event::Mouse(mouse::Event::ButtonPressed(button)) => {
if let Some(point) = cursor.position() {
if !geometry.menu.contains(point) {
self.swallow_mouse_release = Some(*button);
self.dismiss(menu, shell);
} else if *button == mouse::Button::Left {
if let Some(track) = geometry.scroll_track()
&& track.contains(point)
{
let thumb = geometry.scroll_thumb(self.scroll).unwrap();
let offset = if thumb.contains(point) {
point.y - thumb.y
} else {
thumb.height / 2.
};
self.scroll = geometry.scroll_for_pointer(point, offset);
self.scroll_drag = Some(offset);
self.pressed = None;
} else {
self.pressed =
hit(point).filter(|index| menu.entries[*index].enabled());
self.focused = self.pressed;
}
}
}
}
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
if self.scroll_drag.take().is_none()
&& let Some(index) = self.pressed.take()
&& cursor.position().and_then(hit) == Some(index)
{
self.activate(menu, index, shell);
}
}
Event::Mouse(mouse::Event::WheelScrolled { delta }) => {
if cursor.is_over(geometry.menu) {
let y = match delta {
mouse::ScrollDelta::Lines { y, .. } => y * ROW,
mouse::ScrollDelta::Pixels { y, .. } => *y,
};
self.scroll = (self.scroll - y).clamp(0., geometry.max_scroll());
self.focused = cursor
.position()
.and_then(|point| geometry.hit(&menu.entries, self.scroll, point))
.filter(|index| menu.entries[*index].enabled());
self.pressed = None;
}
}
Event::Touch(touch::Event::FingerPressed { id, position }) => {
if !geometry.menu.contains(*position) {
self.swallow_touch_release = Some(*id);
self.dismiss(menu, shell);
} else {
self.pressed = hit(*position).filter(|index| menu.entries[*index].enabled());
self.focused = self.pressed;
}
}
Event::Touch(touch::Event::FingerLifted { position, .. }) => {
if let Some(index) = self.pressed.take()
&& hit(*position) == Some(index)
{
self.activate(menu, index, shell);
}
}
Event::Touch(touch::Event::FingerLost { .. }) => self.pressed = None,
Event::Window(window::Event::Unfocused) => self.dismiss(menu, shell),
_ => {}
}
if input_event(event) {
shell.capture_event();
shell.request_redraw();
}
}
}
fn input_event(event: &Event) -> bool {
matches!(
event,
Event::Mouse(_) | Event::Touch(_) | Event::Keyboard(_) | Event::InputMethod(_)
)
}
#[derive(Clone, Copy)]
enum Navigation {
First,
Last,
Next,
Previous,
}
fn next_enabled<Message>(
entries: &[Entry<Message>],
current: Option<usize>,
direction: Navigation,
) -> Option<usize> {
let count = entries.len();
let current = current.filter(|index| *index < count);
let start = match direction {
Navigation::First => 0,
Navigation::Last => count.saturating_sub(1),
Navigation::Next => current.map_or(0, |index| (index + 1) % count.max(1)),
Navigation::Previous => current.map_or(count.saturating_sub(1), |index| {
(index + count - 1) % count.max(1)
}),
};
let backwards = matches!(direction, Navigation::Last | Navigation::Previous);
(0..count)
.map(|step| {
if backwards {
(start + count - step) % count
} else {
(start + step) % count
}
})
.find(|index| entries[*index].enabled())
}
#[derive(Clone, Copy)]
struct Geometry {
menu: Rectangle,
inner: Rectangle,
content_height: f32,
}
impl Geometry {
fn new(anchor: Point, width: f32, content_height: f32, viewport: Size) -> Self {
let width = width.min((viewport.width - 2. * GAP).max(0.));
let height = (content_height + 2. * PAD).min((viewport.height - 2. * GAP).max(0.));
let bounded = |value: f32, max: f32| {
if value.is_finite() {
value.clamp(GAP.min(max), max)
} else {
GAP.min(max)
}
};
let x = bounded(anchor.x, (viewport.width - width - GAP).max(0.));
let y = bounded(anchor.y, (viewport.height - height - GAP).max(0.));
let menu = Rectangle {
x,
y,
width,
height,
};
Self::from_bounds(menu, content_height)
}
fn from_bounds(menu: Rectangle, content_height: f32) -> Self {
let inner = Rectangle {
x: menu.x + PAD,
y: menu.y + PAD,
width: (menu.width - 2. * PAD).max(0.),
height: (menu.height - 2. * PAD).max(0.),
};
Self {
menu,
inner,
content_height,
}
}
fn max_scroll(self) -> f32 {
(self.content_height - self.inner.height).max(0.)
}
fn row(self, top: f32, height: f32, scroll: f32) -> Rectangle {
Rectangle {
x: self.inner.x,
y: self.inner.y + top - scroll,
width: (self.inner.width - if self.max_scroll() > 0. { 12. } else { 0. }).max(0.),
height,
}
}
fn hit<Message>(self, entries: &[Entry<Message>], scroll: f32, point: Point) -> Option<usize> {
if !self.inner.contains(point)
|| self
.scroll_track()
.is_some_and(|track| track.contains(point))
{
return None;
}
let mut top = 0.;
entries.iter().position(|entry| {
let contains = self.row(top, entry.height(), scroll).contains(point);
top += entry.height();
contains
})
}
fn scroll_track(self) -> Option<Rectangle> {
(self.max_scroll() > 0.).then_some(Rectangle {
x: self.inner.x + (self.inner.width - 8.).max(0.),
width: 8f32.min(self.inner.width),
..self.inner
})
}
fn scroll_thumb(self, scroll: f32) -> Option<Rectangle> {
let track = self.scroll_track()?;
let height = (track.height * track.height / self.content_height)
.max(24.)
.min(track.height);
Some(Rectangle {
y: track.y + (track.height - height) * (scroll / self.max_scroll()).clamp(0., 1.),
height,
..track
})
}
fn scroll_for_pointer(self, point: Point, offset: f32) -> f32 {
let Some(track) = self.scroll_track() else {
return 0.;
};
let height = self.scroll_thumb(0.).unwrap().height;
if track.height <= height {
return 0.;
}
((point.y - track.y - offset) / (track.height - height)).clamp(0., 1.) * self.max_scroll()
}
}
impl<Message: Clone> Widget<Message, Theme, Renderer> for Popup<'_, Message> {
fn tag(&self) -> tree::Tag {
tree::Tag::of::<State>()
}
fn state(&self) -> tree::State {
let mut state = State::default();
state.reconcile(self.menu.as_ref());
tree::State::new(state)
}
fn children(&self) -> Vec<Tree> {
vec![Tree::new(&self.content)]
}
fn diff(&self, tree: &mut Tree) {
tree.diff_children(std::slice::from_ref(&self.content));
tree.state
.downcast_mut::<State>()
.reconcile(self.menu.as_ref());
}
fn size(&self) -> Size<Length> {
self.content.as_widget().size()
}
fn size_hint(&self) -> Size<Length> {
self.content.as_widget().size_hint()
}
fn layout(
&mut self,
tree: &mut Tree,
renderer: &Renderer,
limits: &layout::Limits,
) -> layout::Node {
self.content
.as_widget_mut()
.layout(&mut tree.children[0], renderer, limits)
}
fn operate(
&mut self,
tree: &mut Tree,
layout: Layout<'_>,
renderer: &Renderer,
operation: &mut dyn Operation,
) {
self.content
.as_widget_mut()
.operate(&mut tree.children[0], layout, renderer, operation);
}
fn update(
&mut self,
tree: &mut Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &Renderer,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
let state = tree.state.downcast_mut::<State>();
if self.menu.is_some() && input_event(event) || state.swallow_release(event) {
shell.capture_event();
return;
}
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
}
fn draw(
&self,
tree: &Tree,
renderer: &mut Renderer,
theme: &Theme,
renderer_style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.content.as_widget().draw(
&tree.children[0],
renderer,
theme,
renderer_style,
layout,
if self.menu.is_some() {
mouse::Cursor::Unavailable
} else {
cursor
},
viewport,
);
}
fn mouse_interaction(
&self,
tree: &Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &Renderer,
) -> mouse::Interaction {
if self.menu.is_some() {
mouse::Interaction::None
} else {
self.content.as_widget().mouse_interaction(
&tree.children[0],
layout,
cursor,
viewport,
renderer,
)
}
}
fn overlay<'b>(
&'b mut self,
tree: &'b mut Tree,
layout: Layout<'b>,
renderer: &Renderer,
viewport: &Rectangle,
translation: Vector,
) -> Option<overlay::Element<'b, Message, Theme, Renderer>> {
if let Some(menu) = self.menu.as_ref() {
Some(overlay::Element::new(Box::new(MenuOverlay {
menu,
state: tree.state.downcast_mut(),
})))
} else {
self.content.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
}
}
}
impl<'a, Message: Clone + 'a> From<Popup<'a, Message>> for Element<'a, Message> {
fn from(popup: Popup<'a, Message>) -> Self {
Element::new(popup)
}
}
struct MenuOverlay<'a, Message> {
menu: &'a Menu<Message>,
state: &'a mut State,
}
impl<Message> MenuOverlay<'_, Message> {
fn geometry(&self, layout: Layout<'_>) -> Geometry {
// Iced reconstructs overlays between layout, update and draw. Only the
// retained Layout owns the measured bounds, never a transient overlay.
Geometry::from_bounds(
layout
.children()
.next()
.expect("Menu overlay layout contains its measured menu bounds")
.bounds(),
self.menu.entries.iter().map(Entry::height).sum(),
)
}
fn layout_with_font(&mut self, font: iced::Font, bounds: Size) -> layout::Node {
let width = self
.menu
.entries
.iter()
.map(|entry| match entry {
Entry::Title(label) => measure(label, 12., font) + 32.,
Entry::Separator => 0.,
Entry::Action {
label, shortcut, ..
} => {
measure(label, 13., font)
+ shortcut
.as_ref()
.map_or(0., |label| measure(label, 12., font) + 28.)
+ 40.
}
})
.fold(240f32, f32::max)
.min(480.);
let geometry = Geometry::new(
self.menu.anchor,
width,
self.menu.entries.iter().map(Entry::height).sum(),
bounds,
);
self.state.scroll = self.state.scroll.clamp(0., geometry.max_scroll());
// The overlay covers the whole window so outside presses are captured.
layout::Node::with_children(
bounds,
vec![layout::Node::new(geometry.menu.size()).move_to(geometry.menu.position())],
)
}
}
impl<Message: Clone> overlay::Overlay<Message, Theme, Renderer> for MenuOverlay<'_, Message> {
fn layout(&mut self, renderer: &Renderer, bounds: Size) -> layout::Node {
self.layout_with_font(renderer.default_font(), bounds)
}
fn update(
&mut self,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
_renderer: &Renderer,
_clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
) {
let geometry = self.geometry(layout);
self.state.update(self.menu, geometry, event, cursor, shell);
}
fn draw(
&self,
renderer: &mut Renderer,
_theme: &Theme,
_style: &renderer::Style,
layout: Layout<'_>,
_cursor: mouse::Cursor,
) {
let geometry = self.geometry(layout);
renderer.fill_quad(
renderer::Quad {
bounds: geometry.menu,
border: Border {
color: style::LINE,
width: 1.,
radius: 8.into(),
},
shadow: Shadow {
color: Color::from_rgba(0.1, 0.15, 0.2, 0.17),
offset: Vector::new(0., 4.),
blur_radius: 12.,
},
..Default::default()
},
Color::WHITE,
);
renderer.with_layer(geometry.inner, |renderer| {
let mut top = 0.;
for (index, entry) in self.menu.entries.iter().enumerate() {
let row = geometry.row(top, entry.height(), self.state.scroll);
top += entry.height();
if !row.intersects(&geometry.inner) {
continue;
}
match entry {
Entry::Separator => renderer.fill_quad(
renderer::Quad {
bounds: Rectangle {
x: row.x + 8.,
y: row.center_y(),
width: (row.width - 16.).max(0.),
height: 1.,
},
..Default::default()
},
style::LINE,
),
Entry::Title(label) => draw_label(
renderer,
label,
12.,
style::MUTED,
Rectangle {
x: row.x + 9.,
width: (row.width - 18.).max(0.),
..row
},
text::Alignment::Left,
),
Entry::Action {
label,
shortcut,
message,
} => {
if self.state.focused == Some(index) && message.is_some() {
renderer.fill_quad(
renderer::Quad {
bounds: row,
border: Border {
radius: 5.into(),
..Default::default()
},
..Default::default()
},
Color::from_rgb8(218, 238, 235),
);
if row.width > 2. && row.height > 2. {
renderer.fill_quad(
renderer::Quad {
bounds: Rectangle {
x: row.x + 1.,
y: row.y + 1.,
width: row.width - 2.,
height: row.height - 2.,
},
border: Border {
color: style::TEAL,
width: 1.5,
radius: 4.into(),
},
..Default::default()
},
Color::TRANSPARENT,
);
}
}
let color = if message.is_some() {
style::INK
} else {
Color {
a: 0.65,
..style::MUTED
}
};
let suffix = shortcut.as_ref().map_or(0., |label| {
measure(label, 12., renderer.default_font()).min(row.width * 0.45) + 22.
});
draw_label(
renderer,
label,
13.,
color,
Rectangle {
x: row.x + 10.,
width: (row.width - suffix - 20.).max(0.),
..row
},
text::Alignment::Left,
);
if let Some(shortcut) = shortcut {
draw_label(
renderer,
shortcut,
12.,
if message.is_some() {
style::MUTED
} else {
color
},
Rectangle {
x: row.x + row.width - suffix,
width: (suffix - 10.).max(0.),
..row
},
text::Alignment::Right,
);
}
}
}
}
if let Some(thumb) = geometry.scroll_thumb(self.state.scroll) {
renderer.fill_quad(
renderer::Quad {
bounds: thumb,
border: Border {
radius: 4.into(),
..Default::default()
},
..Default::default()
},
style::BOUNDARY,
);
}
});
}
fn mouse_interaction(
&self,
layout: Layout<'_>,
cursor: mouse::Cursor,
_renderer: &Renderer,
) -> mouse::Interaction {
let geometry = self.geometry(layout);
if cursor.position().is_some_and(|point| {
geometry
.hit(&self.menu.entries, self.state.scroll, point)
.is_some_and(|index| self.menu.entries[index].enabled())
|| geometry
.scroll_track()
.is_some_and(|track| track.contains(point))
}) {
mouse::Interaction::Pointer
} else {
mouse::Interaction::None
}
}
}
fn measure(label: &str, size: f32, font: iced::Font) -> f32 {
<Renderer as text::Renderer>::Paragraph::with_text(text::Text {
content: label,
bounds: Size::INFINITE,
size: size.into(),
line_height: text::LineHeight::default(),
font,
align_x: text::Alignment::Left,
align_y: alignment::Vertical::Center,
shaping: text::Shaping::Advanced,
wrapping: text::Wrapping::None,
})
.min_width()
}
fn ellipsize(label: &str, width: f32, size: f32, font: iced::Font) -> String {
if width <= 0. {
return String::new();
}
if measure(label, size, font) <= width {
return label.to_owned();
}
if measure("…", size, font) > width {
return String::new();
}
let ends: Vec<_> = label
.char_indices()
.map(|(index, _)| index)
.chain(std::iter::once(label.len()))
.collect();
let (mut low, mut high) = (0, ends.len());
while low + 1 < high {
let middle = (low + high) / 2;
if measure(&format!("{}…", &label[..ends[middle]]), size, font) <= width {
low = middle;
} else {
high = middle;
}
}
format!("{}…", &label[..ends[low]])
}
fn draw_label(
renderer: &mut Renderer,
label: &str,
size: f32,
color: Color,
bounds: Rectangle,
align_x: text::Alignment,
) {
let font = renderer.default_font();
let content = ellipsize(label, bounds.width, size, font);
renderer.fill_text(
text::Text {
content,
bounds: bounds.size(),
size: size.into(),
line_height: text::LineHeight::default(),
font,
align_x,
align_y: alignment::Vertical::Center,
shaping: text::Shaping::Advanced,
wrapping: text::Wrapping::None,
},
Point::new(
if align_x == text::Alignment::Right {
bounds.x + bounds.width
} else {
bounds.x
},
bounds.center_y(),
),
color,
bounds,
);
}
/// Adds a contextual trigger without changing the child's layout or overlays.
/// Right-click anchors at the absolute cursor position. `left` anchors below
/// the child and intercepts its left press before a nested button can consume it.
pub struct Area<'a, Message> {
content: Element<'a, Message>,
on_open: Box<dyn Fn(Point) -> Message + 'a>,
button: mouse::Button,
}
impl<'a, Message> Area<'a, Message> {
pub fn new(
content: impl Into<Element<'a, Message>>,
on_open: impl Fn(Point) -> Message + 'a,
) -> Self {
Self {
content: content.into(),
on_open: Box::new(on_open),
button: mouse::Button::Right,
}
}
pub fn left(
content: impl Into<Element<'a, Message>>,
on_open: impl Fn(Point) -> Message + 'a,
) -> Self {
Self {
content: content.into(),
on_open: Box::new(on_open),
button: mouse::Button::Left,
}
}
}
#[derive(Default)]
struct AreaState {
release: Option<mouse::Button>,
}
impl AreaState {
fn consume_release(&mut self, event: &Event) -> bool {
// The popup may consume the opening release. A new press belongs to
// another click, including one outside this Area after Escape.
if matches!(
event,
Event::Mouse(mouse::Event::ButtonPressed(_)) | Event::Window(window::Event::Unfocused)
) {
self.release = None;
}
if let Event::Mouse(mouse::Event::ButtonReleased(button)) = event
&& self.release == Some(*button)
{
self.release = None;
return true;
}
false
}
}
impl<Message> Widget<Message, Theme, Renderer> for Area<'_, Message> {
fn tag(&self) -> tree::Tag {
tree::Tag::of::<AreaState>()
}
fn state(&self) -> tree::State {
tree::State::new(AreaState::default())
}
fn children(&self) -> Vec<Tree> {
vec![Tree::new(&self.content)]
}
fn diff(&self, tree: &mut Tree) {
tree.diff_children(std::slice::from_ref(&self.content));
}
fn size(&self) -> Size<Length> {
self.content.as_widget().size()
}
fn size_hint(&self) -> Size<Length> {
self.content.as_widget().size_hint()
}
fn layout(
&mut self,
tree: &mut Tree,
renderer: &Renderer,
limits: &layout::Limits,
) -> layout::Node {
self.content
.as_widget_mut()
.layout(&mut tree.children[0], renderer, limits)
}
fn operate(
&mut self,
tree: &mut Tree,
layout: Layout<'_>,
renderer: &Renderer,
operation: &mut dyn Operation,
) {
self.content
.as_widget_mut()
.operate(&mut tree.children[0], layout, renderer, operation);
}
fn update(
&mut self,
tree: &mut Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &Renderer,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
let state = tree.state.downcast_mut::<AreaState>();
if state.consume_release(event) {
shell.capture_event();
return;
}
if let Event::Mouse(mouse::Event::ButtonPressed(button)) = event
&& *button == self.button
&& cursor.is_over(layout.bounds())
&& cursor.is_over(*viewport)
&& let Some(position) = cursor.position()
{
state.release = Some(*button);
let anchor = if self.button == mouse::Button::Left {
Point::new(
layout.bounds().x,
layout.bounds().y + layout.bounds().height,
)
} else {
position
};
shell.publish((self.on_open)(anchor));
shell.capture_event();
return;
}
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
}
fn draw(
&self,
tree: &Tree,
renderer: &mut Renderer,
theme: &Theme,
renderer_style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.content.as_widget().draw(
&tree.children[0],
renderer,
theme,
renderer_style,
layout,
cursor,
viewport,
);
}
fn mouse_interaction(
&self,
tree: &Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &Renderer,
) -> mouse::Interaction {
if self.button == mouse::Button::Left
&& cursor.is_over(layout.bounds())
&& cursor.is_over(*viewport)
{
mouse::Interaction::Pointer
} else {
self.content.as_widget().mouse_interaction(
&tree.children[0],
layout,
cursor,
viewport,
renderer,
)
}
}
fn overlay<'b>(
&'b mut self,
tree: &'b mut Tree,
layout: Layout<'b>,
renderer: &Renderer,
viewport: &Rectangle,
translation: Vector,
) -> Option<overlay::Element<'b, Message, Theme, Renderer>> {
self.content.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
}
}
impl<'a, Message: 'a> From<Area<'a, Message>> for Element<'a, Message> {
fn from(area: Area<'a, Message>) -> Self {
Element::new(area)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn pixel_contrast_on_white(pixel: &[u8]) -> f32 {
let linear = |byte: u8| {
let value = f32::from(byte) / 255.;
if value <= 0.04045 {
value / 12.92
} else {
((value + 0.055) / 1.055).powf(2.4)
}
};
1.05 / (0.2126 * linear(pixel[0])
+ 0.7152 * linear(pixel[1])
+ 0.0722 * linear(pixel[2])
+ 0.05)
}
fn target_rule_coverage(pixels: &[u8], width: usize, row: Rectangle) -> f32 {
// Only the lower padding, away from text, corners and scroll thumb.
// A pale fill or scattered text pixels cannot satisfy a continuous cue.
let left = (row.x + 14.).ceil() as usize;
let right = (row.x + row.width - 20.).floor() as usize;
let count = (left..right)
.filter(|x| {
((row.y + row.height - 4.).ceil() as usize
..(row.y + row.height - 1.).ceil() as usize)
.any(|y| {
let index = (y * width + x) * 4;
pixel_contrast_on_white(&pixels[index..index + 4]) >= 3.
})
})
.count();
count as f32 / (right - left) as f32
}
#[tokio::test]
async fn mounted_keyboard_menu_paints_target_and_scroll_thumb_then_invokes_that_action() {
use iced::advanced::{overlay::Overlay as _, renderer::Headless};
use iced_runtime::user_interface::{Cache, UserInterface};
let mut renderer = <Renderer as Headless>::new(iced::Font::DEFAULT, 13.into(), None)
.await
.expect("custom menu paint test requires WGPU headless rendering");
let mut menu = menu();
menu.anchor = Point::new(20., 20.);
menu.entries
.extend((30..70).map(|id| Entry::action(format!("Other action {id}"), id)));
let size = Size::new(480., 420.);
let mut measured_state = State::default();
let mut measured = MenuOverlay {
menu: &menu,
state: &mut measured_state,
};
let node = measured.layout(&renderer, size);
let geometry = measured.geometry(Layout::new(&node));
let row = |index: usize| {
geometry.row(
menu.entries[..index].iter().map(Entry::height).sum(),
menu.entries[index].height(),
0.,
)
};
let thumb = geometry.scroll_thumb(0.).expect("fixture must overflow");
let mut ui = UserInterface::build(
Popup::new(
iced::widget::Space::new()
.width(Length::Fill)
.height(Length::Fill),
Some(menu.clone()),
),
size,
Cache::new(),
&mut renderer,
);
let cursor = mouse::Cursor::Unavailable;
let mut messages = vec![];
let theme = style::theme();
let paint_style = renderer::Style {
text_color: style::INK,
};
for (movement, active, inactive) in
[(None, 1, 4), (Some(keyboard::key::Named::ArrowDown), 4, 1)]
{
if let Some(movement) = movement {
let _ = ui.update(
&[key(movement)],
cursor,
&mut renderer,
&mut iced::advanced::clipboard::Null,
&mut messages,
);
assert!(
messages.is_empty(),
"moving the target must not invoke an action"
);
}
// Building an Iced UI does not lay out its overlay. The normal
// update pass must initialize it before draw can paint the menu.
let _ = ui.update(
&[],
cursor,
&mut renderer,
&mut iced::advanced::clipboard::Null,
&mut messages,
);
assert!(messages.is_empty(), "layout cannot invoke an action");
ui.draw(&mut renderer, &theme, &paint_style, cursor);
let pixels = Headless::screenshot(&mut renderer, Size::new(480, 420), 1., Color::WHITE);
assert!(
target_rule_coverage(&pixels, 480, row(active)) > 0.8,
"the keyboard target needs a continuous high-contrast shape cue"
);
for index in [inactive, 2, 5] {
assert!(
target_rule_coverage(&pixels, 480, row(index)) < 0.2,
"ordinary and disabled rows must not acquire the target cue"
);
}
let point = thumb.center();
let index = (point.y.floor() as usize * 480 + point.x.floor() as usize) * 4;
assert!(
pixel_contrast_on_white(&pixels[index..index + 4]) >= 3.,
"the actual overflow thumb must be visible without hover"
);
}
let _ = ui.update(
&[
key(keyboard::key::Named::Enter),
key(keyboard::key::Named::Enter),
],
cursor,
&mut renderer,
&mut iced::advanced::clipboard::Null,
&mut messages,
);
assert_eq!(
messages,
[22],
"Enter invokes exactly the painted Properties target once"
);
}
#[test]
fn actions_area_does_not_swallow_first_independent_click_after_overlay_consumed_its_release() {
let release = Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left));
let mut state = AreaState {
release: Some(mouse::Button::Left),
};
// Opening release went to the menu; Escape closed it. The following
// Revert press is outside the Area, so no new release is armed.
assert!(
!state.consume_release(&Event::Mouse(mouse::Event::ButtonPressed(
mouse::Button::Left
)))
);
assert!(!state.consume_release(&release));
// A coalesced release arriving before the popup mounts is still caught.
state.release = Some(mouse::Button::Left);
assert!(state.consume_release(&release));
assert!(!state.consume_release(&release));
}
fn menu() -> Menu<u8> {
Menu {
token: 1,
anchor: Point::new(1060., 660.),
entries: vec![
Entry::title("Selected element"),
Entry::action("Add port", 11).shortcut("P"),
Entry::disabled("Remove bend"),
Entry::separator(),
Entry::action("Properties", 22),
Entry::disabled("Unavailable action"),
],
on_close: 99,
}
}
fn geometry(menu: &Menu<u8>) -> Geometry {
Geometry::new(
menu.anchor,
320.,
menu.entries.iter().map(Entry::height).sum(),
Size::new(1100., 700.),
)
}
fn key(named: keyboard::key::Named) -> Event {
Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(named),
modified_key: keyboard::Key::Named(named),
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Space),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::empty(),
text: None,
repeat: false,
})
}
fn dispatch(
state: &mut State,
menu: &Menu<u8>,
event: Event,
point: Option<Point>,
) -> (Vec<u8>, bool) {
let mut messages = Vec::new();
let mut shell = Shell::new(&mut messages);
state.update(
menu,
geometry(menu),
&event,
point.map_or(mouse::Cursor::Unavailable, mouse::Cursor::Available),
&mut shell,
);
let captured = shell.is_event_captured();
(messages, captured)
}
fn row_center(menu: &Menu<u8>, index: usize, scroll: f32) -> Point {
let top = menu.entries[..index].iter().map(Entry::height).sum();
geometry(menu)
.row(top, menu.entries[index].height(), scroll)
.center()
}
#[test]
fn reconstructed_overlay_uses_retained_layout_for_rows_and_hit_testing() {
let menu = menu();
let mut state = State::default();
state.reconcile(Some(&menu));
// Iced lays out one overlay, drops it, then reconstructs the overlay
// for update/draw while retaining the layout tree from the first one.
let node = MenuOverlay {
menu: &menu,
state: &mut state,
}
.layout_with_font(iced::Font::DEFAULT, Size::new(1100., 700.));
let overlay = MenuOverlay {
menu: &menu,
state: &mut state,
};
let geometry = overlay.geometry(Layout::new(&node));
assert_eq!(
geometry.menu.height,
menu.entries.iter().map(Entry::height).sum::<f32>() + 2. * PAD
);
assert_eq!(geometry.menu, node.children()[0].bounds());
assert!(geometry.menu.width >= 240.);
let top = menu.entries[..4].iter().map(Entry::height).sum();
let point = geometry.row(top, ROW, 0.).center();
assert!(geometry.inner.contains(point));
assert_eq!(geometry.hit(&menu.entries, 0., point), Some(4));
let mut messages = Vec::new();
for event in [
mouse::Event::ButtonPressed(mouse::Button::Left),
mouse::Event::ButtonReleased(mouse::Button::Left),
] {
// A fresh overlay must not lose geometry even between the two
// halves of a click on a visible action near the bottom.
let overlay = MenuOverlay {
menu: &menu,
state: &mut state,
};
let mut shell = Shell::new(&mut messages);
overlay.state.update(
&menu,
overlay.geometry(Layout::new(&node)),
&Event::Mouse(event),
mouse::Cursor::Available(point),
&mut shell,
);
assert!(shell.is_event_captured());
}
assert_eq!(messages, [22]);
}
#[test]
fn retained_overlay_layout_keeps_measured_width_and_scrollbar_after_reconstruction() {
let menu = Menu {
entries: (0..90)
.map(|index| {
Entry::action(
format!("Action {index} with a longer contextual description"),
index,
)
.shortcut("Ctrl+Shift+Enter")
})
.collect(),
..menu()
};
let mut state = State::default();
state.reconcile(Some(&menu));
let node = MenuOverlay {
menu: &menu,
state: &mut state,
}
.layout_with_font(iced::Font::DEFAULT, Size::new(1100., 700.));
let overlay = MenuOverlay {
menu: &menu,
state: &mut state,
};
let geometry = overlay.geometry(Layout::new(&node));
assert!(
geometry.menu.width > 280.,
"Retain measured label and shortcut width"
);
assert_eq!(geometry.menu.height, 700. - 2. * GAP);
assert!(geometry.scroll_track().is_some());
overlay
.state
.navigate(&menu.entries, Navigation::Last, geometry.inner.height);
assert_eq!(overlay.state.scroll, geometry.max_scroll());
let final_row = geometry.row(89. * ROW, ROW, overlay.state.scroll);
assert_eq!(
geometry.hit(&menu.entries, overlay.state.scroll, final_row.center()),
Some(89)
);
}
#[test]
fn keyboard_skips_headers_separators_and_disabled_actions_and_invokes_once() {
let menu = menu();
let mut state = State::default();
state.reconcile(Some(&menu));
assert_eq!(state.focused, Some(1));
for (key_name, expected) in [
(keyboard::key::Named::ArrowDown, 4),
(keyboard::key::Named::ArrowDown, 1),
(keyboard::key::Named::ArrowUp, 4),
(keyboard::key::Named::Home, 1),
(keyboard::key::Named::End, 4),
] {
let (messages, captured) = dispatch(&mut state, &menu, key(key_name), None);
assert!(messages.is_empty());
assert!(captured);
assert_eq!(state.focused, Some(expected));
}
assert_eq!(
dispatch(&mut state, &menu, key(keyboard::key::Named::Enter), None),
(vec![22], true)
);
assert_eq!(
dispatch(&mut state, &menu, key(keyboard::key::Named::Enter), None),
(vec![], true)
);
}
#[test]
fn empty_and_disabled_only_menus_still_capture_keys_and_escape_closes() {
for entries in [
vec![],
vec![
Entry::title("No selection"),
Entry::separator(),
Entry::disabled("Delete"),
],
] {
let menu = Menu { entries, ..menu() };
let mut state = State::default();
state.reconcile(Some(&menu));
for direction in [
keyboard::key::Named::ArrowUp,
keyboard::key::Named::ArrowDown,
keyboard::key::Named::Home,
keyboard::key::Named::End,
keyboard::key::Named::Enter,
keyboard::key::Named::Delete,
] {
assert_eq!(
dispatch(&mut state, &menu, key(direction), None),
(vec![], true)
);
assert_eq!(state.focused, None);
}
assert_eq!(
dispatch(&mut state, &menu, key(keyboard::key::Named::Escape), None),
(vec![99], true)
);
}
}
#[test]
fn outside_dismissal_consumes_press_and_release_across_controlled_close() {
let menu = menu();
let mut state = State::default();
state.reconcile(Some(&menu));
let outside = Point::new(20., 20.);
assert_eq!(
dispatch(
&mut state,
&menu,
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
Some(outside)
),
(vec![99], true)
);
state.reconcile::<u8>(None);
assert!(
state.swallow_release(&Event::Mouse(mouse::Event::ButtonReleased(
mouse::Button::Left
)))
);
assert!(
!state.swallow_release(&Event::Mouse(mouse::Event::ButtonReleased(
mouse::Button::Left
))),
"Only the dismissal gesture is suppressed"
);
state.reconcile(Some(&menu));
let finger = touch::Finger(4);
assert_eq!(
dispatch(
&mut state,
&menu,
Event::Touch(touch::Event::FingerPressed {
id: finger,
position: outside
}),
None
),
(vec![99], true)
);
state.reconcile::<u8>(None);
assert!(
state.swallow_release(&Event::Touch(touch::Event::FingerLifted {
id: finger,
position: outside
}))
);
}
#[test]
fn coalesced_dismissal_release_does_not_swallow_the_next_independent_click() {
let menu = menu();
let mut state = State::default();
state.reconcile(Some(&menu));
let outside = Some(Point::new(20., 20.));
assert_eq!(
dispatch(
&mut state,
&menu,
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
outside
),
(vec![99], true)
);
let release = Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left));
assert_eq!(
dispatch(&mut state, &menu, release.clone(), outside),
(vec![], true)
);
state.reconcile::<u8>(None);
assert!(!state.swallow_release(&release));
}
#[test]
fn clicking_disabled_or_dragging_off_an_action_never_executes_it() {
let menu = menu();
let mut state = State::default();
state.reconcile(Some(&menu));
let disabled = row_center(&menu, 2, 0.);
let enabled = row_center(&menu, 1, 0.);
for (press, release) in [(disabled, disabled), (enabled, disabled)] {
assert!(
dispatch(
&mut state,
&menu,
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
Some(press)
)
.0
.is_empty()
);
assert!(
dispatch(
&mut state,
&menu,
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
Some(release)
)
.0
.is_empty()
);
}
dispatch(
&mut state,
&menu,
Event::Mouse(mouse::Event::CursorMoved { position: enabled }),
Some(enabled),
);
assert_eq!(state.focused, Some(1));
dispatch(
&mut state,
&menu,
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
Some(enabled),
);
assert_eq!(
dispatch(
&mut state,
&menu,
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
Some(enabled)
),
(vec![11], true)
);
}
#[test]
fn a_new_context_token_cannot_reuse_the_previous_mouse_press_or_scroll() {
let menu = menu();
let mut state = State::default();
state.reconcile(Some(&menu));
let point = row_center(&menu, 1, 0.);
dispatch(
&mut state,
&menu,
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
Some(point),
);
state.scroll = 50.;
let mut replacement = menu.clone();
replacement.token += 1;
replacement.entries[1] = Entry::action("Different target", 44);
state.reconcile(Some(&replacement));
assert_eq!(state.scroll, 0.);
assert_eq!(state.pressed, None);
assert_eq!(
dispatch(
&mut state,
&replacement,
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
Some(point)
),
(vec![], true)
);
}
#[test]
fn tall_menu_clamps_to_viewport_and_keyboard_wheel_and_scrollbar_reach_the_end() {
let menu = Menu {
entries: (0..90)
.map(|index| Entry::action(format!("Action {index}"), index))
.collect(),
..menu()
};
let geometry = geometry(&menu);
assert!(geometry.menu.x >= GAP && geometry.menu.y >= GAP);
assert!(geometry.menu.x + geometry.menu.width <= 1100. - GAP);
assert!(geometry.menu.y + geometry.menu.height <= 700. - GAP);
let mut state = State::default();
state.reconcile(Some(&menu));
dispatch(&mut state, &menu, key(keyboard::key::Named::End), None);
assert_eq!(state.focused, Some(89));
assert_eq!(state.scroll, geometry.max_scroll());
let last = row_center(&menu, 89, state.scroll);
assert_eq!(geometry.hit(&menu.entries, state.scroll, last), Some(89));
dispatch(
&mut state,
&menu,
Event::Mouse(mouse::Event::WheelScrolled {
delta: mouse::ScrollDelta::Lines { x: 0., y: 2. },
}),
Some(last),
);
assert_eq!(state.scroll, geometry.max_scroll() - 2. * ROW);
let track = geometry.scroll_track().unwrap();
let bottom = Point::new(track.center_x(), track.y + track.height - 1.);
dispatch(
&mut state,
&menu,
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
Some(bottom),
);
assert_eq!(state.scroll, geometry.max_scroll());
dispatch(
&mut state,
&menu,
Event::Mouse(mouse::Event::CursorMoved {
position: track.position(),
}),
Some(track.position()),
);
assert_eq!(state.scroll, 0.);
assert!(
dispatch(
&mut state,
&menu,
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
Some(last)
)
.0
.is_empty()
);
}
#[test]
fn anchors_outside_the_window_and_nonfinite_input_stay_bounded() {
for anchor in [
Point::new(-100., -200.),
Point::new(10_000., 10_000.),
Point::new(f32::NAN, f32::INFINITY),
] {
let geometry = Geometry::new(anchor, 480., 4000., Size::new(1100., 700.));
assert!(geometry.menu.x.is_finite() && geometry.menu.y.is_finite());
assert!(geometry.menu.x >= 0. && geometry.menu.y >= 0.);
assert!(geometry.menu.x + geometry.menu.width <= 1100.);
assert!(geometry.menu.y + geometry.menu.height <= 700.);
}
}
}