1571 lines
52 KiB
Rust
1571 lines
52 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),
|
|
);
|
|
}
|
|
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::LINE,
|
|
);
|
|
}
|
|
});
|
|
}
|
|
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::*;
|
|
|
|
#[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.);
|
|
}
|
|
}
|
|
}
|