GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
feat(context_menu): page rows turn the menu in place, and a side swipe turns them
A row can lead to a PAGE (set_row_page): it wears ›, and a press on it or a
two-finger swipe to the side over it asks to turn the menu into what it
leads to (PageTurn::Into); the host shows a list with show_page at the
menu's corner, under a back band (‹ Title) that a press or a swipe back
turns back from. refill() re-marks a page's rows in place.
widget::side_swipe recognizes the swipe: one recognizer per thread, one
turn per gesture, forward following the content (natural scrolling: the
fingers go left to go in). swallow() hands a host the rest of a gesture
that turned, so it does not scroll or orbit what the new plate uncovers.
A turned page repositions the popup that is up (xdg_popup.reposition)
instead of replacing it: a new surface under a still pointer gets no
pointer focus, so the swipe back went nowhere.
Retires the flyout submenu (SUBMENU, submenu module, SubmenuSpec, its
child popup): its one consumer had flyouts on some rows and plate swaps on
others, and the user asked for one kind of row.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 94 +++---
src/backend/menu_popup.rs | 233 ++++----------
src/backend/window_runner.rs | 12 +-
src/widget/core.rs | 745 ++++++++++++++++---------------------------
src/widget/mod.rs | 2 +
src/widget/side_swipe.rs | 208 ++++++++++++
6 files changed, 611 insertions(+), 683 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 70b4312..3111ebe 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -532,48 +532,58 @@ the process can reach an X display (`DISPLAY=:0` and `XAUTHORITY=$HOME/.Xauthori
fell back to the Intel device SILENTLY. It says so on stderr now (below, "Debug
environment variables"); `grep -c nvidia /proc/<pid>/maps` is the check from outside.
-### A row can open a submenu (since 2026-09-29)
-
-`context_menu::set_row_submenu(idx, SubmenuSpec { options, header_count, sliders })`,
-called after `show` like `set_row_slider`, gives a row a SUBMENU: a second menu that
-flies out beside the row while the pointer is on it. The row wears a `›` at its right
-end. **The menu opens and closes it; a host says what is in it and dispatches its
-rows.** One level deep: a submenu has no submenus.
-
-- **It is a second `ContextMenuState`**, the `SUBMENU` thread-local, so everything a
- menu does — sliders, scrolling, the plate — a submenu does by the same code. Its
- `parent_row` is the row it flew out from and what tells the two apart.
-- **The pointer calls answer for both; the row calls are each menu's own.** `hit_test`,
- `cursor_moved`, `mouse_wheel`, `slider_press`, `slider_dragging` and `slider_release`
- route to whichever menu the pointer is in, so a host with no submenus is unchanged and
- one with them routes nothing by hand. A row index means nothing without its menu, so
- `row_at` is the menu's alone (and `None` over the submenu) and `take_slider_change`
- the menu's sliders'; the submenu's are `context_menu::submenu::row_at`,
- `::take_slider_change`, `::parent_row`, `::slider`, `::options`.
-- **Hover intent is a triangle, not a timer.** Moving from a row into its submenu
- crosses the rows between; while the pointer is inside the triangle from where it last
- was on the open submenu's row to the submenu's near edge (a row taller at each end),
- those rows neither hover nor swap the submenu. Straight down the menu is outside it,
- and once the pointer has arrived in the submenu the apex is dropped, so coming back
- out is an ordinary move. `open_submenu(idx)` is for a press on the row, under a
- pointer that has not moved since the menu came up.
-- **Setting a submenu that is OPEN changes it where it stands** — labels and slider
- values, keeping its hover, scroll, held slider and popup — which is how a host
- re-marks a row after it was picked. A different number of rows shows it afresh.
-- **Its popup is the CHILD of the menu's popup** (`menu_popup.rs`), positioned against
- the menu's whole width at the row's height: anchor top-right, gravity bottom-right,
- flip-x / slide / resize-y, so it lands on the menu's left where the output has no
- room on the right. Its configure is relative to its parent, so where it is, is that
- plus where the menu landed. It has its own renderer (`submenu_renderer`), kept across
- opens. **The child closes first**: a popup that is not the topmost may not be
- destroyed, so `close_menu_popup` closes the submenu's ahead of the menu's. In a window
- with no popup surface `submenu::constrain_beside` does the same flip and slide.
-- The cursor over either menu is the default arrow (`cursor_icon_at`): what lies under
- a popup in window coordinates is the app's splitter or resize border, or nothing.
-
-The designer's viewport menu is the first consumer (Style, Markers).
-`context_menu_submenu_tests` covers the state; the popups were checked in a shadow
-session, at the output's right edge included.
+### A row can lead to a page, and a side swipe turns it (since 2026-10-02)
+
+`context_menu::set_row_page(idx)`, called after `show` like `set_row_slider`, makes a
+row a PAGE row: it wears `›` at its right end, and a press on it, or a two-finger swipe
+to the side with the pointer on it, asks to TURN the menu into what the row leads to —
+another list of rows, or another plate altogether (the designer's dialog). **The menu
+recognizes the turn; the host shows the page**, since only the host knows what is
+there:
+
+- **A turn is a `PageTurn`**: `Into(row)` or `Back`. A press is read with
+ `turn_at(x, y)` (the standard `mouse_input` path records it instead of hiding); a
+ swipe arrives through `mouse_wheel` and is drained with `take_turn()`.
+- **`show_page(x, y, back, options, header_count, target)`** shows a page with its
+ top-left at the corner of the plate it replaces (`x()`, `y()` read before), so the
+ menu reads as turning rather than a second menu arriving. With `back` naming the
+ plate it came from, a BACK BAND across its top reads `‹ Title`: a press on it is
+ `Back`, it hovers like a row, and the rows begin under it (`row_y`, `row_at` and the
+ height all count it). A swipe back from anywhere on a page with a band is `Back`; on
+ a menu that was opened rather than turned to, it goes nowhere. A page is `turned`:
+ placed in a window, or by the popup's positioner, it SLIDES on screen and never flips
+ up from the corner it took over.
+- **`refill(options, sliders)`** changes the shown rows' labels and slider values in
+ place (hover, scroll, band, page rows and a held slider kept) — how a host re-marks a
+ switch on a page that stays up after it ran. A different row count is refused.
+- **The swipe is `widget::side_swipe`**, one recognizer per thread (`side_swipe::feed`)
+ shared by the menu and any plate a host turns into, so one gesture turns one page
+ however many plates pass under the fingers; the lift (`ScrollPhase::FingerEnd`) or a
+ 250 ms pause readies the next. It fires once the fingers have gone `SWIPE_PX` (40) to
+ the side, half again more sideways than vertical; a tilt-wheel notch is a whole swipe.
+ **Forward follows the content**: the delta that scrolls a list to show what is to its
+ right, so under natural scrolling the fingers go LEFT to go in and right to go back,
+ as on every touch surface, and the user's scrolling setting flips both. A test that
+ swipes twice calls `side_swipe::end_gesture()` between, since the runner's phase is
+ process-wide.
+- **The rest of a gesture that turned is the turn's**: `side_swipe::swallow(delta)`,
+ asked at the top of a host's wheel handling, is true for it until the lift, and the
+ host drops the event. Without it a page narrower than the plate it replaced left the
+ fingers over the scene, and the end of a swipe back orbited the camera.
+- **A page MOVES the popup that is up** (`menu_popup.rs`, `xdg_popup.reposition`,
+ version 3), anchored at the corner, without `FlipY`; only where it cannot (no popup
+ placed yet, an older protocol) is a new one opened. A NEW surface under a pointer that
+ has not moved gets no pointer focus until it moves, so with a replaced popup the rest
+ of a swipe and the swipe back went to nothing — found in a shadow session, where the
+ first cut turned forward and then would not turn back.
+
+**Until this a row could open a SUBMENU** (2026-09-29 to 2026-10-02): a second
+`ContextMenuState` flying out beside the row on hover, in a child popup, with a
+hover-intent triangle. It went with the change, the `SUBMENU` thread-local, the
+`submenu` module, `SubmenuSpec` and the child popup included: the designer, its only
+consumer, had flyouts on some rows and plate swaps on others, two gestures for one
+idea, and the user asked for one. `context_menu_page_tests` covers the menu,
+`side_swipe::tests` the recognizer.
## Plates, wells and seams — the surface vocabulary
diff --git a/src/backend/menu_popup.rs b/src/backend/menu_popup.rs
index 8936ef3..e3b5871 100644
--- a/src/backend/menu_popup.rs
+++ b/src/backend/menu_popup.rs
@@ -31,16 +31,17 @@
//! keyboard stays with the window, whose Escape and press-outside handling
//! close the menu as before.
//!
-//! **A submenu is a second popup, the child of the first** (2026-09-29).
-//! `context_menu`'s open submenu is mirrored the same way into a popup whose
-//! PARENT is the menu's popup — nested as xdg means popups to nest — placed
-//! beside the row it flew out from by a positioner that flips it to the
-//! menu's other side where the output has no room. Its configure is relative
-//! to the menu's popup, so where it landed is that plus where the menu
-//! landed. It has a renderer of its own, kept across opens as the menu's is.
-//! The child goes first: a popup that is not the topmost may not be
-//! destroyed, so every close of the menu's popup closes the submenu's ahead
-//! of it.
+//! **A page turn is a new popup at the old one's corner** (2026-10-02,
+//! where until then a row's submenu was a second popup, the CHILD of this
+//! one, flying out beside it). `context_menu::show_page` puts the page's
+//! top-left where the menu's was and marks the menu `turned`; its generation
+//! moves, and the popup that is up is REPOSITIONED (`xdg_popup.reposition`)
+//! to the page's size, anchored at that corner, rather than replaced: a new
+//! surface under a pointer that has not moved gets no pointer focus until it
+//! moves, so a swipe that turned the page, and the swipe back, would land on
+//! nothing. Its positioner slides the page on screen rather than flipping it
+//! to open up from the corner — a page that jumped above the plate it
+//! replaced would not read as that plate turned.
//!
//! Only xdg toplevels get a popup. A layer surface, or any app with
//! `CCE_UI_MENU_POPUP=0`, keeps the in-window menu, constrained to the window
@@ -73,13 +74,6 @@ pub struct MenuPopup {
committed_scale: i32,
}
-/// Which of the two menus a popup shows.
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-enum Which {
- Menu,
- Submenu,
-}
-
impl MenuPopup {
/// The popup's offset from the window, if `surface` is its surface and it
/// has been placed — what a pointer event on it is translated by.
@@ -111,63 +105,65 @@ impl<A: Application> EngineState<A> {
)
}
- /// Bring the popups in line with the menu: open one for a newly shown
- /// menu, close it for a hidden one, and the same for its submenu. Runs
+ /// Bring the popup in line with the menu: open one for a newly shown
+ /// menu, close it for a hidden one. Runs
/// once per loop, after input, so a host that shows the menu and then
/// sets its slider rows (which widen it) has done both before the popup
/// is sized.
pub(crate) fn sync_menu_popup(&mut self) {
let visible = context_menu::is_visible();
if !(visible && self.window.is_some() && popup_enabled()) {
- if self.menu_popup.is_some() || self.submenu_popup.is_some() {
+ if self.menu_popup.is_some() {
self.close_menu_popup();
}
context_menu::set_hosted(false);
- context_menu::submenu::set_hosted(false);
if visible {
let (fw, fh) = self.frame_size();
context_menu::constrain_to(0.0, 0.0, fw, fh);
- if context_menu::submenu::is_visible() {
- context_menu::submenu::constrain_beside(0.0, 0.0, fw, fh);
- }
}
return;
}
let (anchor, w, content_h) = context_menu::natural_geometry();
let key = (context_menu::generation(), w.ceil() as u32, content_h.ceil() as u32);
- if !self.menu_popup.as_ref().is_some_and(|p| p.key == key) {
- self.close_menu_popup();
- self.open_menu_popup(anchor, key);
- }
- self.sync_submenu_popup();
- }
-
- fn sync_submenu_popup(&mut self) {
- if !context_menu::submenu::is_visible() {
- self.close_submenu_popup();
+ if self.menu_popup.as_ref().is_some_and(|p| p.key == key) {
return;
}
- let (w, content_h, _) = context_menu::submenu::natural_geometry();
- let key = (context_menu::submenu::generation(), w.ceil() as u32, content_h.ceil() as u32);
- if self.submenu_popup.as_ref().is_some_and(|p| p.key == key) {
+ // A page turned to in place of the menu MOVES the popup that is up
+ // rather than replacing it: a new surface under a pointer that has
+ // not moved gets no pointer focus until it does, so the rest of a
+ // swipe — and the swipe back — would land on nothing.
+ if context_menu::is_turned() && self.reposition_menu_popup(anchor, key) {
return;
}
- self.close_submenu_popup();
- // Its parent has to be on screen first; the next loop tries again.
- let Some(placed) = self.menu_popup.as_ref().and_then(|p| p.placed) else {
- context_menu::submenu::set_hosted(true);
- return;
- };
- self.open_submenu_popup(placed, key);
+ self.close_menu_popup();
+ self.open_menu_popup(anchor, key);
}
- fn open_menu_popup(&mut self, anchor: (f32, f32), key: (u64, u32, u32)) {
- let Some(window) = self.window.as_ref() else { return };
+ /// Move and resize the popup that is up to show a turned page, through
+ /// `xdg_popup.reposition` (version 3). `false` where it cannot be: no
+ /// popup placed yet, an older protocol, no positioner.
+ fn reposition_menu_popup(&mut self, anchor: (f32, f32), key: (u64, u32, u32)) -> bool {
+ let Some(mp) = self.menu_popup.as_ref() else { return false };
+ if mp.placed.is_none() || mp.popup.xdg_popup().version() < 3 {
+ return false;
+ }
+ let Some(positioner) = self.menu_positioner(anchor, key) else { return false };
+ // Configures as the popup is moved, so the token is not read back.
+ let mp = self.menu_popup.as_mut().unwrap();
+ mp.popup.reposition(&positioner, key.0 as u32);
+ mp.key = key;
+ self.redraw = true;
+ true
+ }
+
+ /// The positioner the menu's popup is placed by, sized to `key`'s width
+ /// and height and anchored at `anchor`.
+ fn menu_positioner(&self, anchor: (f32, f32), key: (u64, u32, u32)) -> Option<XdgPositioner> {
let positioner = match XdgPositioner::new(&self.xdg_shell_state) {
Ok(p) => p,
Err(e) => {
log::warn!("[menu_popup] no positioner ({e}); drawing the menu in the window");
- return;
+ return None;
}
};
let f = forced();
@@ -186,12 +182,17 @@ impl<A: Application> EngineState<A> {
// Open down and right from the pointer. Short of room below, open UP
// from it (flip); short either way, slide in from the edge; taller
// than the output, cut it down (resize) — the menu scrolls.
+ // A page keeps the corner of the plate it turned from: no flip.
+ let flip = if context_menu::is_turned() { ConstraintAdjustment::empty() } else { ConstraintAdjustment::FlipY };
positioner.set_constraint_adjustment(
- ConstraintAdjustment::FlipY
- | ConstraintAdjustment::SlideX
- | ConstraintAdjustment::SlideY
- | ConstraintAdjustment::ResizeY,
+ flip | ConstraintAdjustment::SlideX | ConstraintAdjustment::SlideY | ConstraintAdjustment::ResizeY,
);
+ Some(positioner)
+ }
+
+ fn open_menu_popup(&mut self, anchor: (f32, f32), key: (u64, u32, u32)) {
+ let Some(positioner) = self.menu_positioner(anchor, key) else { return };
+ let Some(window) = self.window.as_ref() else { return };
let popup = match Popup::new(
window.xdg_surface(),
&positioner,
@@ -212,66 +213,10 @@ impl<A: Application> EngineState<A> {
self.menu_popup = Some(MenuPopup { popup, key, placed: None, committed_scale: 0 });
}
- /// Open the submenu's popup beside the menu's, `menu` being where that
- /// one landed. Everything here is in the MENU POPUP's coordinates, which
- /// is what a child's positioner is relative to.
- fn open_submenu_popup(&mut self, menu: (f32, f32, f32, f32), key: (u64, u32, u32)) {
- let Some(parent) = self.menu_popup.as_ref() else { return };
- let fallback = |why: String| {
- log::warn!("[menu_popup] {why}; drawing the submenu in the window");
- context_menu::submenu::set_hosted(false);
- };
- let positioner = match XdgPositioner::new(&self.xdg_shell_state) {
- Ok(p) => p,
- Err(e) => return fallback(format!("no positioner ({e})")),
- };
- let f = forced();
- positioner.set_size(
- ((key.1 as f32) * f).round().max(1.0) as i32,
- ((key.2 as f32) * f).round().max(1.0) as i32,
- );
- // The anchor is a strip the menu's whole width at the height the
- // submenu's top goes: its top-right corner is where the submenu
- // opens from, and flipped, its top-left.
- let (_, my, mw, mh) = menu;
- let top = (context_menu::submenu::anchor_y() - my).clamp(0.0, (mh - 1.0).max(0.0));
- let tall = context_menu::ROW_H.min(mh - top).max(1.0);
- positioner.set_anchor_rect(
- 0,
- (top * f).round() as i32,
- ((mw * f).round() as i32).max(1),
- ((tall * f).round() as i32).max(1),
- );
- positioner.set_anchor(Anchor::TopRight);
- positioner.set_gravity(Gravity::BottomRight);
- // Beside the menu on the right; on the left where the right has no
- // room (flip); slid up where it would run off the bottom; cut down
- // and scrolling where it is taller than the output.
- positioner.set_constraint_adjustment(
- ConstraintAdjustment::FlipX
- | ConstraintAdjustment::SlideX
- | ConstraintAdjustment::SlideY
- | ConstraintAdjustment::ResizeY,
- );
- let popup = match Popup::new(
- parent.popup.xdg_surface(),
- &positioner,
- &self.qh,
- &self.compositor_state,
- &self.xdg_shell_state,
- ) {
- Ok(p) => p,
- Err(e) => return fallback(format!("cannot create popup ({e})")),
- };
- context_menu::submenu::set_hosted(true);
- self.submenu_popup = Some(MenuPopup { popup, key, placed: None, committed_scale: 0 });
- }
-
- /// Close the popup, and the submenu's ahead of it. The renderer lets go
+ /// Close the popup. The renderer lets go
/// of the surface FIRST: dropping the popup destroys the `wl_surface`,
/// and a swapchain must never outlive the surface it presents to.
pub(crate) fn close_menu_popup(&mut self) {
- self.close_submenu_popup();
if let Some(renderer) = self.menu_renderer.as_mut() {
if renderer.has_surface() {
renderer.detach_surface();
@@ -281,36 +226,15 @@ impl<A: Application> EngineState<A> {
context_menu::set_hosted(false);
}
- pub(crate) fn close_submenu_popup(&mut self) {
- if let Some(renderer) = self.submenu_renderer.as_mut() {
- if renderer.has_surface() {
- renderer.detach_surface();
- }
- }
- self.submenu_popup = None;
- }
-
- /// The offset from the window of whichever menu popup `surface` is.
+ /// The offset from the window of the menu popup, if `surface` is it.
pub(crate) fn menu_popup_offset(&self, surface: &wl_surface::WlSurface) -> Option<(f32, f32)> {
- self.menu_popup
- .as_ref()
- .and_then(|p| p.offset_for(surface))
- .or_else(|| self.submenu_popup.as_ref().and_then(|p| p.offset_for(surface)))
+ self.menu_popup.as_ref().and_then(|p| p.offset_for(surface))
}
- /// Draw the menu into its popup, and the submenu into its own. Called
- /// after the window's own frame, and after a configure; a no-op for a
- /// popup not yet placed.
+ /// Draw the menu into its popup. Called after the window's own frame,
+ /// and after a configure; a no-op for a popup not yet placed.
pub(crate) fn render_menu_popup(&mut self) {
- self.render_popup(Which::Menu);
- self.render_popup(Which::Submenu);
- }
-
- fn render_popup(&mut self, which: Which) {
- let (mp, renderer) = match which {
- Which::Menu => (self.menu_popup.as_mut(), self.menu_renderer.as_mut()),
- Which::Submenu => (self.submenu_popup.as_mut(), self.submenu_renderer.as_mut()),
- };
+ let (mp, renderer) = (self.menu_popup.as_mut(), self.menu_renderer.as_mut());
let Some(mp) = mp else { return };
let Some((_, _, w, h)) = mp.placed else { return };
let Some(renderer) = renderer else { return };
@@ -321,10 +245,7 @@ impl<A: Application> EngineState<A> {
let (s, pw, ph) = Self::buffer_geometry(self.scale_factor, w, h);
let mut pc = crate::scene::paint::PaintCtx::new();
- match which {
- Which::Menu => context_menu::paint_hosted(&mut pc),
- Which::Submenu => context_menu::submenu::paint_hosted(&mut pc),
- }
+ context_menu::paint_hosted(&mut pc);
let dl = pc.finish();
let mut items = Vec::new();
@@ -354,41 +275,25 @@ impl<A: Application> EngineState<A> {
});
}
- fn which_popup(&self, surface: &wl_surface::WlSurface) -> Option<Which> {
- if self.menu_popup.as_ref().is_some_and(|p| p.popup.wl_surface() == surface) {
- Some(Which::Menu)
- } else if self.submenu_popup.as_ref().is_some_and(|p| p.popup.wl_surface() == surface) {
- Some(Which::Submenu)
- } else {
- None
- }
+ fn is_menu_popup(&self, surface: &wl_surface::WlSurface) -> bool {
+ self.menu_popup.as_ref().is_some_and(|p| p.popup.wl_surface() == surface)
}
}
impl<A: Application> PopupHandler for EngineState<A> {
fn configure(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, popup: &Popup, config: PopupConfigure) {
- let Some(which) = self.which_popup(popup.wl_surface()) else { return };
+ if !self.is_menu_popup(popup.wl_surface()) {
+ return;
+ }
let f = forced();
- let (mut x, mut y) = (config.position.0 as f32 / f, config.position.1 as f32 / f);
+ let (x, y) = (config.position.0 as f32 / f, config.position.1 as f32 / f);
let (w, h) = (config.width.max(1) as f32 / f, config.height.max(1) as f32 / f);
- // The submenu's position is relative to its parent, the menu's popup.
- if which == Which::Submenu {
- let Some((mx, my, _, _)) = self.menu_popup.as_ref().and_then(|p| p.placed) else { return };
- x += mx;
- y += my;
- }
let (scale_factor, display) = (self.scale_factor, self.display_ptr);
- let (mp, slot) = match which {
- Which::Menu => (self.menu_popup.as_mut(), &mut self.menu_renderer),
- Which::Submenu => (self.submenu_popup.as_mut(), &mut self.submenu_renderer),
- };
+ let (mp, slot) = (self.menu_popup.as_mut(), &mut self.menu_renderer);
let Some(mp) = mp else { return };
mp.placed = Some((x, y, w, h));
// Where it landed IS where the menu is — see the module docs.
- match which {
- Which::Menu => context_menu::place(x, y, h),
- Which::Submenu => context_menu::submenu::place(x, y, h),
- }
+ context_menu::place(x, y, h);
let (_, pw, ph) = Self::buffer_geometry(scale_factor, w, h);
let surface_ptr = mp.popup.wl_surface().id().as_ptr() as *mut std::ffi::c_void;
@@ -416,13 +321,13 @@ impl<A: Application> PopupHandler for EngineState<A> {
return;
}
self.redraw = true;
- self.render_popup(which);
+ self.render_menu_popup();
}
fn done(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, popup: &Popup) {
// The compositor dismissed it (its parent went away, say). The menu
// closes with it; an app that watches `is_visible` sees that.
- if self.which_popup(popup.wl_surface()).is_some() {
+ if self.is_menu_popup(popup.wl_surface()) {
context_menu::hide();
self.close_menu_popup();
self.redraw = true;
diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index 05a8f26..9c3110e 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -4041,9 +4041,6 @@ pub struct EngineState<A: Application> {
/// surface to the next: a renderer costs a device and every pipeline
/// (tens of ms), a re-attach costs one swapchain.
pub menu_renderer: Option<VkRenderer>,
- /// The open submenu's popup, a child of `menu_popup`, and its renderer.
- pub submenu_popup: Option<crate::backend::menu_popup::MenuPopup>,
- pub submenu_renderer: Option<VkRenderer>,
/// The `wl_display` the renderers were made from, as an address.
pub display_ptr: usize,
@@ -4320,7 +4317,7 @@ impl<A: Application> EngineState<A> {
/// [`Application::cursor_icon`] override, else the standard-CSD edge
/// cursors (status bars and non-standard-CSD apps fall back to Default).
fn cursor_icon_at(&self, lx: f32, ly: f32) -> CursorIcon {
- // Over the context menu or its submenu the pointer is the menu's,
+ // Over the context menu the pointer is the menu's,
// whatever of the app lies at that place under it (a splitter, a
// resize border) — and in their popups that place may be outside
// the window altogether.
@@ -4531,10 +4528,6 @@ impl<A: Application> EngineState<A> {
crate::widget::context_menu::w(),
crate::widget::context_menu::h(),
));
- if crate::widget::context_menu::submenu::is_visible() {
- use crate::widget::context_menu::submenu;
- dl_overlay_rects.push((submenu::x(), submenu::y(), submenu::w(), submenu::h()));
- }
}
let spans = dl_text_spans(&self.dl_text_items, scale_f32, bounds, &dl_overlay_rects);
@@ -4663,7 +4656,6 @@ impl<A: Application> Drop for EngineState<A> {
// its wl_surface) drops with the rest of the fields.
self.close_menu_popup();
self.menu_renderer = None;
- self.submenu_renderer = None;
self.renderer = None;
}
}
@@ -6141,8 +6133,6 @@ fn run_session<'l, A: Application>(
sent_popover_region: None,
menu_popup: None,
menu_renderer: None,
- submenu_popup: None,
- submenu_renderer: None,
display_ptr: 0,
exit: false,
redraw: false,
diff --git a/src/widget/core.rs b/src/widget/core.rs
index 11a1287..9c40de0 100644
--- a/src/widget/core.rs
+++ b/src/widget/core.rs
@@ -399,22 +399,32 @@ pub mod context_menu {
}
}
- /// The mark a row that opens a submenu carries at its right end.
- pub const SUBMENU_MARK: &str = "›";
-
- /// What a row's SUBMENU holds: a second menu that flies out beside the
- /// row while the pointer is on it, set on a shown menu with
- /// [`set_row_submenu`]. The menu opens and closes it — a host only says
- /// what is in it and dispatches its rows, through [`submenu`]. Its rows
- /// are actions or sliders as the menu's own are; it has no submenus of
- /// its own.
- #[derive(Debug, Clone, Default, PartialEq)]
- pub struct SubmenuSpec {
- pub options: Vec<String>,
- /// Leading rows that are headers: dimmed, never hovered.
- pub header_count: usize,
- /// Slider rows by index; shorter than `options` is fine.
- pub sliders: Vec<Option<MenuSlider>>,
+ /// The mark a row that leads to a PAGE carries at its right end.
+ pub const PAGE_MARK: &str = "›";
+ /// What a page's back band leads with, before the title of the plate it
+ /// goes back to.
+ pub const BACK_MARK: &str = "‹";
+
+ /// A page turn the menu has been asked for, drained by the host with
+ /// [`take_turn`] (a swipe) or read with [`turn_at`] (a press). The menu
+ /// does not turn by itself: what a row leads to may be another list of
+ /// rows or another plate altogether (the designer's dialog), so the host
+ /// shows it — a list with [`show_page`], which keeps the plate where it
+ /// stands, so the menu reads as turning into the page rather than as a
+ /// second menu arriving.
+ ///
+ /// Until 2026-10-02 a row could open a SUBMENU, a second menu flying out
+ /// beside it on hover, while rows that led to another plate swapped it
+ /// in place on a click: two kinds of row for one idea. A page row is
+ /// both, and is the only kind.
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
+ pub enum PageTurn {
+ /// Into the page row `n` leads to: a press on it, or a side swipe
+ /// forward with the pointer on it.
+ Into(usize),
+ /// Back to the plate this page was turned to from: a press on the
+ /// back band, or a side swipe back anywhere over the plate.
+ Back,
}
/// A toolkit color as the `[u8; 3]` a [`TextLabel`] carries.
@@ -443,21 +453,21 @@ pub mod context_menu {
/// Emptied by every `show`, so a menu's sliders are the ones its
/// host set this time.
pub sliders: Vec<Option<MenuSlider>>,
- /// Submenus by row, parallel to `options` — see [`SubmenuSpec`].
- /// Emptied by every `show`, as the sliders are.
- pub submenus: Vec<Option<SubmenuSpec>>,
- /// The row whose submenu is open, on the MENU.
- pub open_sub: Option<usize>,
- /// On the SUBMENU: the row of the menu it flew out from. `None` on
- /// the menu itself, which is how the two are told apart.
- pub parent_row: Option<usize>,
- /// On the submenu: the rect of that row's menu, `(x, y, w, h)` — what
- /// it is placed beside, and flips to the other side of.
- pub beside: (f32, f32, f32, f32),
- /// Where the pointer last was on the row whose submenu is open: the
- /// apex of the triangle a pointer heading INTO the submenu stays
- /// inside, crossing other rows on its way without switching to them.
- intent_apex: Option<(f32, f32)>,
+ /// Rows that lead to a PAGE, parallel to `options` — see
+ /// [`set_row_page`](Self::set_row_page). Emptied by every `show`.
+ pub pages: Vec<bool>,
+ /// On a page: the title of the plate it was turned to from, which
+ /// the back band across its top reads as `‹ Title`. `None` on a menu
+ /// that was opened rather than turned to.
+ pub back: Option<String>,
+ /// The pointer is on the back band.
+ pub back_hovered: bool,
+ /// Shown by [`show_page`](Self::show_page), in place of the plate it
+ /// turned from: a placement keeps its corner where that plate's
+ /// was, sliding it on screen rather than flipping it.
+ pub turned: bool,
+ /// A turn a swipe asked for, until the host takes it.
+ turn: Option<PageTurn>,
/// The slider row a press took hold of, until the release.
pub slider_drag: Option<usize>,
/// A trackpad's leftover fraction of a wheel notch.
@@ -510,11 +520,11 @@ pub mod context_menu {
target: None,
header_count: 0,
sliders: Vec::new(),
- submenus: Vec::new(),
- open_sub: None,
- parent_row: None,
- beside: (0.0, 0.0, 0.0, 0.0),
- intent_apex: None,
+ pages: Vec::new(),
+ back: None,
+ back_hovered: false,
+ turned: false,
+ turn: None,
slider_drag: None,
wheel_accum: 0.0,
slider_change: None,
@@ -573,170 +583,110 @@ pub mod context_menu {
self.target = Some(target);
self.header_count = header_count;
self.sliders = vec![None; self.options.len()];
- self.submenus = vec![None; self.options.len()];
- self.close_submenu();
+ self.pages = vec![false; self.options.len()];
+ self.back = None;
+ self.back_hovered = false;
+ self.turned = false;
+ self.turn = None;
self.slider_drag = None;
self.wheel_accum = 0.0;
self.slider_change = None;
}
- /// Give row `idx` a submenu, or replace the one it has. Widens the
- /// plate for the mark. Replacing the contents of a submenu that is
- /// OPEN changes it where it stands — its hover, its scroll and a
- /// held slider are kept — which is how a host re-marks a row of it
- /// after that row was picked.
- pub fn set_row_submenu(&mut self, idx: usize, spec: SubmenuSpec) {
- if idx >= self.options.len() || self.parent_row.is_some() {
+ /// Make row `idx` lead to a PAGE — see [`PageTurn`]. It wears
+ /// [`PAGE_MARK`] at its right end; the plate widens for it.
+ pub fn set_row_page(&mut self, idx: usize) {
+ if idx >= self.options.len() {
return;
}
let (family, size) = label_font();
let measure = |t: &str| crate::widget::display::measure_text_width(t, &family, size);
- let need = PAD + measure(&self.options[idx]) + SLIDER_GAP + measure(SUBMENU_MARK) + PAD;
+ let need = PAD + measure(&self.options[idx]) + SLIDER_GAP + measure(PAGE_MARK) + PAD;
self.w = self.w.max(need);
- if self.open_sub == Some(idx) {
- SUBMENU.with(|s| {
- if let Ok(mut s) = s.try_borrow_mut() {
- s.refill(&spec);
- }
- });
+ self.pages[idx] = true;
+ }
+
+ /// Whether row `idx` leads to a page.
+ pub fn leads_to_page(&self, idx: usize) -> bool {
+ self.pages.get(idx).copied().unwrap_or(false)
+ }
+
+ /// Show `options` as a PAGE, the plate's top-left at `(x, y)` — where
+ /// the plate it turns from stood — with a back band reading `‹ back`
+ /// across its top when `back` names that plate. The rows are new
+ /// rows (sliders and page rows are set again after, as after
+ /// [`show`](Self::show)); what changes is that a placement keeps the
+ /// corner rather than opening from a pointer.
+ pub fn show_page(&mut self, x: f32, y: f32, back: Option<&str>, options: Vec<String>, header_count: usize, target: WidgetId) {
+ self.show(x, y, options, header_count, target);
+ self.turned = true;
+ if let Some(title) = back {
+ let label = format!("{BACK_MARK} {title}");
+ let (family, size) = label_font();
+ let need = PAD + crate::widget::display::measure_text_width(&label, &family, size) + PAD;
+ self.w = self.w.max(need);
+ self.back = Some(title.to_string());
+ self.content_h += ROW_H;
+ self.h = self.content_h;
}
- self.submenus[idx] = Some(spec);
}
- /// Whether row `idx` opens a submenu.
- pub fn has_submenu(&self, idx: usize) -> bool {
- self.submenus.get(idx).is_some_and(|s| s.is_some())
- }
-
- /// Open row `idx`'s submenu beside it. A no-op for a row with none,
- /// or whose submenu is the one open.
- pub fn open_submenu(&mut self, idx: usize) -> bool {
- if self.open_sub == Some(idx) {
+ /// New labels and slider values for the rows that are showing, in
+ /// place — the hover, the scroll, the back band, the page rows and a
+ /// held slider are kept — which is how a host re-marks a row of a
+ /// menu that stays up after the row ran. `false`, and nothing
+ /// changed, when the number of rows differs: that is another menu,
+ /// to be shown afresh.
+ pub fn refill(&mut self, options: Vec<String>, sliders: &[Option<MenuSlider>]) -> bool {
+ if options.len() != self.options.len() {
return false;
}
- let Some(Some(spec)) = self.submenus.get(idx).cloned() else { return false };
- let Some(target) = self.target else { return false };
- // The submenu's first row level with the row it flew out from.
- let (x, y) = (self.x + self.w, self.row_y(idx) - PAD);
- let beside = (self.x, self.y, self.w, self.h);
- SUBMENU.with(|s| {
- let Ok(mut s) = s.try_borrow_mut() else { return };
- s.show(x, y, spec.options.clone(), spec.header_count, target);
- for (i, sl) in spec.sliders.iter().enumerate() {
- if let Some(sl) = sl {
- s.set_row_slider(i, *sl);
- }
- }
- s.parent_row = Some(idx);
- s.beside = beside;
- s.hosted = self.hosted;
- });
- self.open_sub = Some(idx);
- true
- }
-
- /// Close the open submenu, if there is one.
- pub fn close_submenu(&mut self) -> bool {
- self.intent_apex = None;
- if self.open_sub.take().is_none() {
- return false;
- }
- SUBMENU.with(|s| {
- if let Ok(mut s) = s.try_borrow_mut() {
- s.hide();
- }
- });
- true
- }
-
- /// New contents for a submenu that is showing: the labels and the
- /// slider values, in place. A different NUMBER of rows is a different
- /// menu, and is shown afresh where this one stands.
- fn refill(&mut self, spec: &SubmenuSpec) {
- if spec.options.len() != self.options.len() {
- let (anchor, target, parent, beside, hosted) = (self.anchor, self.target, self.parent_row, self.beside, self.hosted);
- let Some(target) = target else { return };
- self.show(anchor.0, anchor.1, spec.options.clone(), spec.header_count, target);
- for (i, sl) in spec.sliders.iter().enumerate() {
- if let Some(sl) = sl {
- self.set_row_slider(i, *sl);
- }
- }
- self.parent_row = parent;
- self.beside = beside;
- self.hosted = hosted;
- return;
- }
- self.options = spec.options.clone();
- self.header_count = spec.header_count;
+ self.options = options;
for i in 0..self.options.len() {
// A held slider is the pointer's until the release.
if self.slider_drag == Some(i) {
continue;
}
- self.sliders[i] = spec.sliders.get(i).copied().flatten();
+ self.sliders[i] = sliders.get(i).copied().flatten();
}
+ true
}
- /// After a pointer move has settled the menu's hover: open the
- /// submenu of the row now hovered, close the open one when another
- /// row is — unless the pointer is on its way INTO the submenu, inside
- /// the triangle from where it left the row to the submenu's near
- /// edge — and keep the open submenu's row lit while the pointer is
- /// anywhere else.
- fn follow_hover(&mut self, px: f32, py: f32) {
- if self.parent_row.is_some() || self.slider_drag.is_some() {
- return;
- }
- match (self.hovered_item, self.open_sub) {
- (Some(i), Some(open)) if i != open && self.heading_into_submenu(px, py) => {
- self.hovered_item = Some(open);
- }
- (Some(i), _) if self.has_submenu(i) => {
- self.open_submenu(i);
- self.intent_apex = Some((px, py));
- }
- (Some(_), _) => {
- self.close_submenu();
- }
- (None, open) => self.hovered_item = open,
+ /// The band's height above the rows: one row on a page that goes
+ /// back somewhere, none otherwise.
+ fn band_h(&self) -> f32 {
+ if self.back.is_some() { ROW_H } else { 0.0 }
+ }
+
+ /// The top of the back band, where it is drawn.
+ pub fn back_band_y(&self) -> f32 {
+ self.y + PAD - self.scroll
+ }
+
+ /// Whether `(px, py)` is on the back band.
+ pub fn on_back_band(&self, px: f32, py: f32) -> bool {
+ if self.back.is_none() || !self.hit_test(px, py) {
+ return false;
}
+ let top = self.back_band_y();
+ py >= top && py < top + ROW_H
}
- fn heading_into_submenu(&self, px: f32, py: f32) -> bool {
- let Some((ax, ay)) = self.intent_apex else { return false };
- let (sx, sy, sw, sh) = SUBMENU.with(|s| s.try_borrow().map(|s| (s.x, s.y, s.w, s.h)).unwrap_or_default());
- // The edge of the submenu nearer the menu, a row taller at each
- // end: a hand does not aim at a corner.
- let near = if sx >= self.x { sx } else { sx + sw };
- let (b, c) = ((near, sy - ROW_H), (near, sy + sh + ROW_H));
- let side = |p: (f32, f32), q: (f32, f32)| (px - q.0) * (p.1 - q.1) - (p.0 - q.0) * (py - q.1);
- let (d1, d2, d3) = (side((ax, ay), b), side(b, c), side(c, (ax, ay)));
- let neg = d1 < 0.0 || d2 < 0.0 || d3 < 0.0;
- let pos = d1 > 0.0 || d2 > 0.0 || d3 > 0.0;
- (px - ax).abs() > 0.5 && !(neg && pos)
+ /// The turn a press at `(px, py)` asks for: the back band goes back,
+ /// a page row goes into its page.
+ pub fn turn_at(&self, px: f32, py: f32) -> Option<PageTurn> {
+ if !self.visible {
+ return None;
+ }
+ if self.on_back_band(px, py) {
+ return Some(PageTurn::Back);
+ }
+ self.row_at(px, py).filter(|&i| self.leads_to_page(i)).map(PageTurn::Into)
}
- /// Keep a SUBMENU on screen inside `(bx, by, bw, bh)`: beside its
- /// menu on the right, on the left where the right has no room, slid
- /// up where it would run off the bottom, cut down and scrolling where
- /// it is taller than the box. For hosts with no popup surface.
- pub fn constrain_beside(&mut self, bx: f32, by: f32, bw: f32, bh: f32) {
- let (mx, _, mw, _) = self.beside;
- let x = if mx + mw + self.w <= bx + bw {
- mx + mw
- } else if mx - self.w >= bx {
- mx - self.w
- } else {
- (bx + bw - self.w).max(bx)
- };
- let ay = self.anchor.1;
- let (y, max_h) = if ay + self.content_h <= by + bh {
- (ay.max(by), self.content_h)
- } else {
- ((by + bh - self.content_h).max(by), bh)
- };
- self.place(x, y, max_h);
+ /// The turn a side swipe asked for since the last call.
+ pub fn take_turn(&mut self) -> Option<PageTurn> {
+ self.turn.take()
}
/// Make row `idx` a slider. Widens the plate to hold the label, the
@@ -783,7 +733,7 @@ pub mod context_menu {
let x = if ax + self.w > bx + bw { (bx + bw - self.w).max(bx) } else { ax.max(bx) };
let (y, max_h) = if ay + self.content_h <= by + bh {
(ay.max(by), self.content_h)
- } else if ay - self.content_h >= by {
+ } else if !self.turned && ay - self.content_h >= by {
(ay - self.content_h, self.content_h)
} else {
((by + bh - self.content_h).max(by), bh)
@@ -839,7 +789,7 @@ pub mod context_menu {
/// [`Self::scroll_by`] this does not re-hover the row under the
/// pointer: the keyboard put the highlight where it is.
fn scroll_into_view(&mut self, idx: usize) {
- let top = idx as f32 * ROW_H;
+ let top = self.band_h() + idx as f32 * ROW_H;
let bottom = top + ROW_H + 2.0 * PAD;
if top < self.scroll {
self.scroll = top;
@@ -896,6 +846,21 @@ pub mod context_menu {
if !self.visible {
return false;
}
+ // A side swipe turns a page: forward with the pointer on a page
+ // row, back from anywhere on a page that has somewhere to go.
+ if self.hit_test(px, py) && self.slider_drag.is_none() {
+ let turn = match crate::widget::side_swipe::feed(delta) {
+ Some(crate::widget::SwipeDir::Forward) => {
+ self.row_at(px, py).filter(|&i| self.leads_to_page(i)).map(PageTurn::Into)
+ }
+ Some(crate::widget::SwipeDir::Back) => self.back.is_some().then_some(PageTurn::Back),
+ None => None,
+ };
+ if turn.is_some() {
+ self.turn = turn;
+ return true;
+ }
+ }
let Some(idx) = self.row_at(px, py) else { return false };
let Some(s) = self.slider(idx) else {
// Not a slider: a menu cut down to fit scrolls its rows.
@@ -960,7 +925,8 @@ pub mod context_menu {
self.target = None;
self.last_cursor = None;
self.slider_drag = None;
- self.close_submenu();
+ self.back_hovered = false;
+ self.turn = None;
}
pub fn hit_test(&self, px: f32, py: f32) -> bool {
@@ -971,7 +937,7 @@ pub mod context_menu {
/// The top of row `idx`, where it is drawn: scrolled, so a row above
/// the view lies above `y`.
pub fn row_y(&self, idx: usize) -> f32 {
- self.y + PAD + idx as f32 * ROW_H - self.scroll
+ self.y + PAD + self.band_h() + idx as f32 * ROW_H - self.scroll
}
/// The row under `(px, py)`, or `None` outside the plate or in its
@@ -981,7 +947,7 @@ pub mod context_menu {
if px < self.x || px > self.x + self.w || py < self.y || py > self.y + self.h {
return None;
}
- let rel = py - self.y - PAD + self.scroll;
+ let rel = py - self.y - PAD - self.band_h() + self.scroll;
if rel < 0.0 {
return None;
}
@@ -995,15 +961,15 @@ pub mod context_menu {
if self.slider_drag.is_some() {
return self.slider_drag_to(px);
}
- let was = (self.hovered_item, self.open_sub);
+ let was = (self.hovered_item, self.back_hovered);
self.rehover(px, py);
- self.follow_hover(px, py);
- (self.hovered_item, self.open_sub) != was
+ (self.hovered_item, self.back_hovered) != was
}
fn rehover(&mut self, px: f32, py: f32) -> bool {
- let was_hovered = self.hovered_item;
+ let was_hovered = (self.hovered_item, self.back_hovered);
self.hovered_item = None;
+ self.back_hovered = self.on_back_band(px, py);
if let Some(idx) = self.row_at(px, py) {
// A "-" row is a SEPARATOR (the dropdown's convention):
// engraved, never hovered, never an action.
@@ -1011,7 +977,7 @@ pub mod context_menu {
self.hovered_item = Some(idx);
}
}
- self.hovered_item != was_hovered
+ (self.hovered_item, self.back_hovered) != was_hovered
}
pub fn mouse_input(&mut self, button: MouseButton, state: ElementState, px: f32, py: f32, ctx: Option<&mut crate::context::UiContext>) -> bool {
@@ -1030,6 +996,10 @@ pub mod context_menu {
return false;
}
+ if let Some(turn) = self.turn_at(px, py) {
+ self.turn = Some(turn);
+ return true;
+ }
if self.hit_test(px, py) {
if let Some(idx) = self.row_at(px, py) {
if idx >= self.header_count {
@@ -1122,6 +1092,23 @@ pub mod context_menu {
}
fn paint_rows(&self, ctx: &mut crate::scene::paint::PaintCtx, rect: crate::scene::layout::Rect, r: f32, depth: f32) {
+ if self.back.is_some() {
+ // The back band: lit like a row under the pointer, and cut off
+ // from the page's rows by the separator's groove.
+ let top = self.back_band_y();
+ if self.back_hovered {
+ let inset = (depth * 0.5).max(2.0).max(PAD * 0.5);
+ ctx.rounded_rect(
+ crate::scene::layout::Rect { x: self.x + inset, y: top + 2.0, width: self.w - 2.0 * inset, height: ROW_H - 4.0 },
+ (r - inset).max(0.0),
+ (true, true, true, true),
+ [0.20, 0.40, 0.65, 0.6],
+ );
+ }
+ let inset = (depth * 0.5).max(PAD);
+ let cy = top + ROW_H;
+ ctx.groove((self.x + inset, cy), (self.x + self.w - inset, cy), 0.75, depth, rect);
+ }
if let Some(h_idx) = self.hovered_item {
// Inset off the roll so the fill sits on the face instead of
// climbing the lit edge, and round the corners it actually meets:
@@ -1245,6 +1232,16 @@ pub mod context_menu {
let mut labels = Vec::new();
if !self.visible || self.hosted { return labels; }
+ if let Some(title) = &self.back {
+ let (_, label_size) = label_font();
+ labels.push(TextLabel {
+ text: format!("{BACK_MARK} {title}"),
+ x: self.x + PAD,
+ y: self.back_band_y() + (ROW_H - label_size) / 2.0,
+ font_size: label_size,
+ color: rgb8(if self.back_hovered { crate::color::TEXT_HEADER } else { crate::color::TEXT_DIM }),
+ });
+ }
for (idx, opt) in self.options.iter().enumerate() {
if opt == "-" {
continue;
@@ -1277,12 +1274,12 @@ pub mod context_menu {
font_size: label_size,
color: text_color,
});
- // A row with a submenu says so at its right end.
- if self.has_submenu(idx) {
+ // A row that leads to a page says so at its right end.
+ if self.leads_to_page(idx) {
let (family, _) = label_font();
- let tw = crate::widget::display::measure_text_width(SUBMENU_MARK, &family, label_size);
+ let tw = crate::widget::display::measure_text_width(PAGE_MARK, &family, label_size);
labels.push(TextLabel {
- text: SUBMENU_MARK.to_string(),
+ text: PAGE_MARK.to_string(),
x: self.x + self.w - PAD - tw.max(label_size * 0.4),
y: iy,
font_size: label_size,
@@ -1348,138 +1345,40 @@ pub mod context_menu {
thread_local! {
pub static CONTEXT_MENU: RefCell<ContextMenuState> = RefCell::new(ContextMenuState::new());
- /// The open submenu: a second menu, beside the row of the first it
- /// flew out from. See [`submenu`].
- pub static SUBMENU: RefCell<ContextMenuState> = RefCell::new(ContextMenuState::new());
}
- /// The open SUBMENU, for a host that dispatches the menu itself. The
- /// menu opens and closes it (see [`SubmenuSpec`]); what is left to the
- /// host is what it does for the menu's own rows: ask which row a press
- /// landed on, and drain its sliders.
- ///
- /// The calls that are about where the POINTER is — [`hit_test`],
- /// [`cursor_moved`], [`mouse_wheel`], [`slider_press`],
- /// [`slider_dragging`], [`slider_release`] at the menu's top level —
- /// answer for the menu and its submenu together, so a host with no
- /// submenus is unchanged and one with them routes nothing by hand. The
- /// calls that NAME A ROW are apart, since a row index means nothing
- /// without the menu it is a row of: [`row_at`] there is the menu's alone
- /// (and `None` over the submenu), and the submenu's is here.
- ///
- /// [`hit_test`]: super::hit_test
- /// [`cursor_moved`]: super::cursor_moved
- /// [`mouse_wheel`]: super::mouse_wheel
- /// [`slider_press`]: super::slider_press
- /// [`slider_dragging`]: super::slider_dragging
- /// [`slider_release`]: super::slider_release
- /// [`row_at`]: super::row_at
- pub mod submenu {
- use super::{MenuSlider, SUBMENU};
-
- pub fn is_visible() -> bool {
- SUBMENU.with(|m| m.borrow().visible)
- }
- /// The row of the menu the open submenu belongs to.
- pub fn parent_row() -> Option<usize> {
- SUBMENU.with(|m| {
- let m = m.borrow();
- m.visible.then_some(m.parent_row).flatten()
- })
- }
- pub fn hit_test(px: f32, py: f32) -> bool {
- SUBMENU.with(|m| m.borrow().hit_test(px, py))
- }
- pub fn row_at(px: f32, py: f32) -> Option<usize> {
- SUBMENU.with(|m| {
- let m = m.borrow();
- if !m.visible {
- return None;
- }
- m.row_at(px, py)
- })
- }
- pub fn row_y(idx: usize) -> f32 {
- SUBMENU.with(|m| m.borrow().row_y(idx))
- }
- pub fn slider(idx: usize) -> Option<MenuSlider> {
- SUBMENU.with(|m| m.borrow().slider(idx))
- }
- pub fn slider_dragging() -> bool {
- SUBMENU.with(|m| m.borrow().slider_drag.is_some())
- }
- /// `(row, value)` of the last change to one of the submenu's sliders
- /// since the last call.
- pub fn take_slider_change() -> Option<(usize, f32)> {
- SUBMENU.with(|m| m.borrow_mut().take_slider_change())
- }
- pub fn x() -> f32 { SUBMENU.with(|m| m.borrow().x) }
- pub fn y() -> f32 { SUBMENU.with(|m| m.borrow().y) }
- pub fn w() -> f32 { SUBMENU.with(|m| m.borrow().w) }
- pub fn h() -> f32 { SUBMENU.with(|m| m.borrow().h) }
- pub fn hovered_item() -> Option<usize> { SUBMENU.with(|m| m.borrow().hovered_item) }
- pub fn options() -> Vec<String> { SUBMENU.with(|m| m.borrow().options.clone()) }
- pub fn generation() -> u64 { SUBMENU.with(|m| m.borrow().generation) }
- /// See [`super::ContextMenuState::place`].
- pub fn place(x: f32, y: f32, max_h: f32) {
- SUBMENU.with(|m| m.borrow_mut().place(x, y, max_h));
- }
- /// See [`super::ContextMenuState::constrain_beside`].
- pub fn constrain_beside(bx: f32, by: f32, bw: f32, bh: f32) {
- SUBMENU.with(|m| m.borrow_mut().constrain_beside(bx, by, bw, bh));
- }
- pub fn set_hosted(hosted: bool) {
- SUBMENU.with(|m| m.borrow_mut().hosted = hosted);
- }
- /// `(w, content_h)` and the rect of the menu it stands beside.
- pub fn natural_geometry() -> (f32, f32, (f32, f32, f32, f32)) {
- SUBMENU.with(|m| {
- let m = m.borrow();
- (m.w, m.content_h, m.beside)
- })
- }
- /// The top of the row it flew out from, less the padding: where its
- /// own top goes.
- pub fn anchor_y() -> f32 {
- SUBMENU.with(|m| m.borrow().anchor.1)
- }
- /// As [`super::paint_hosted`], for the submenu's own popup surface.
- pub fn paint_hosted(ctx: &mut crate::scene::paint::PaintCtx) {
- let mut menu = SUBMENU.with(|m| m.borrow().clone());
- menu.hosted = false;
- menu.in_popup = true;
- let (x, y) = (menu.x, menu.y);
- ctx.translate(-x, -y, |ctx| menu.paint_with_labels(ctx));
- }
+ /// Make row `idx` of the shown menu lead to a page — see [`PageTurn`].
+ /// Call after [`show`] / [`show_page`], which clear every row back to an
+ /// action.
+ pub fn set_row_page(idx: usize) {
+ CONTEXT_MENU.with(|m| m.borrow_mut().set_row_page(idx));
}
-
- /// Give row `idx` of the shown menu a submenu — see [`SubmenuSpec`].
- /// Call after [`show`], which clears every row back to an action, and
- /// again whenever the submenu's contents change.
- pub fn set_row_submenu(idx: usize, spec: SubmenuSpec) {
- CONTEXT_MENU.with(|m| m.borrow_mut().set_row_submenu(idx, spec));
+ pub fn leads_to_page(idx: usize) -> bool {
+ CONTEXT_MENU.with(|m| m.borrow().leads_to_page(idx))
}
- pub fn has_submenu(idx: usize) -> bool {
- CONTEXT_MENU.with(|m| m.borrow().has_submenu(idx))
+ /// See [`ContextMenuState::show_page`].
+ pub fn show_page(x: f32, y: f32, back: Option<&str>, options: Vec<String>, header_count: usize, target: WidgetId) {
+ CONTEXT_MENU.with(|m| m.borrow_mut().show_page(x, y, back, options, header_count, target));
}
- /// Open row `idx`'s submenu: what a press on the row does, for a
- /// pointer that has not moved since the menu came up under it.
- pub fn open_submenu(idx: usize) -> bool {
- CONTEXT_MENU.with(|m| m.borrow_mut().open_submenu(idx))
+ /// See [`ContextMenuState::refill`].
+ pub fn refill(options: Vec<String>, sliders: &[Option<MenuSlider>]) -> bool {
+ CONTEXT_MENU.with(|m| m.borrow_mut().refill(options, sliders))
}
- pub fn close_submenu() -> bool {
- CONTEXT_MENU.with(|m| m.borrow_mut().close_submenu())
+ /// See [`ContextMenuState::turn_at`].
+ pub fn turn_at(px: f32, py: f32) -> Option<PageTurn> {
+ CONTEXT_MENU.with(|m| m.borrow().turn_at(px, py))
}
- /// Whether the point is the submenu's: inside it, or anywhere while one
- /// of its sliders is held.
- fn submenu_takes(px: f32, py: f32) -> bool {
- if CONTEXT_MENU.with(|m| m.borrow().slider_drag.is_some()) {
- return false;
- }
- SUBMENU.with(|s| {
- let s = s.borrow();
- s.visible && (s.slider_drag.is_some() || s.hit_test(px, py))
- })
+ /// See [`ContextMenuState::take_turn`].
+ pub fn take_turn() -> Option<PageTurn> {
+ CONTEXT_MENU.with(|m| m.borrow_mut().take_turn())
+ }
+ /// Whether the shown menu is a page turned to in place of another plate.
+ pub fn is_turned() -> bool {
+ CONTEXT_MENU.with(|m| m.borrow().turned)
+ }
+ /// The title of the plate the shown page goes back to.
+ pub fn back_title() -> Option<String> {
+ CONTEXT_MENU.with(|m| m.borrow().back.clone())
}
pub fn is_visible() -> bool {
@@ -1574,9 +1473,6 @@ pub mod context_menu {
/// row, and runs off the end on the last one (2026-09-22 audit: six
/// call sites across five apps had it).
pub fn row_at(px: f32, py: f32) -> Option<usize> {
- if submenu::hit_test(px, py) {
- return None;
- }
CONTEXT_MENU.with(|m| m.borrow().row_at(px, py))
}
/// A row's top, PAD-aware — for a host painting the rows itself.
@@ -1584,28 +1480,11 @@ pub mod context_menu {
CONTEXT_MENU.with(|m| m.borrow().row_y(idx))
}
pub fn hit_test(px: f32, py: f32) -> bool {
- CONTEXT_MENU.with(|m| m.borrow().hit_test(px, py)) || submenu::hit_test(px, py)
+ CONTEXT_MENU.with(|m| m.borrow().hit_test(px, py))
}
pub fn cursor_moved(px: f32, py: f32) -> bool {
- if submenu_takes(px, py) {
- let moved = SUBMENU.with(|s| s.borrow_mut().cursor_moved(px, py));
- // The row it flew out from stays lit while the pointer is in it.
- let relit = CONTEXT_MENU.with(|m| {
- let mut m = m.borrow_mut();
- let was = m.hovered_item;
- m.hovered_item = m.open_sub;
- // It has arrived: on its way back out it is heading nowhere.
- m.intent_apex = None;
- m.hovered_item != was
- });
- return moved || relit;
- }
- let left = SUBMENU.with(|s| {
- let mut s = s.borrow_mut();
- s.visible && s.hovered_item.take().is_some()
- });
- CONTEXT_MENU.with(|m| m.borrow_mut().cursor_moved(px, py)) || left
+ CONTEXT_MENU.with(|m| m.borrow_mut().cursor_moved(px, py))
}
/// Make row `idx` of the shown menu a slider — see [`MenuSlider`]. Call
@@ -1619,26 +1498,19 @@ pub mod context_menu {
/// The wheel, for hosts that route it: steps the slider under the
/// pointer. `false` when no slider row is there — let it scroll the page.
pub fn mouse_wheel(delta: &MouseScrollDelta, px: f32, py: f32) -> bool {
- if submenu_takes(px, py) {
- return SUBMENU.with(|m| m.borrow_mut().mouse_wheel(delta, px, py));
- }
CONTEXT_MENU.with(|m| m.borrow_mut().mouse_wheel(delta, px, py))
}
/// A left press, for hosts that dispatch the menu themselves: `true` when
/// it landed on a slider row, which the host must then NOT treat as an
/// action or a dismissal.
pub fn slider_press(px: f32, py: f32) -> bool {
- if submenu_takes(px, py) {
- return SUBMENU.with(|m| m.borrow_mut().slider_press(px, py));
- }
CONTEXT_MENU.with(|m| m.borrow_mut().slider_press(px, py))
}
pub fn slider_dragging() -> bool {
- CONTEXT_MENU.with(|m| m.borrow().slider_drag.is_some()) || submenu::slider_dragging()
+ CONTEXT_MENU.with(|m| m.borrow().slider_drag.is_some())
}
pub fn slider_release() -> bool {
- let sub = SUBMENU.with(|m| m.borrow_mut().slider_release());
- CONTEXT_MENU.with(|m| m.borrow_mut().slider_release()) || sub
+ CONTEXT_MENU.with(|m| m.borrow_mut().slider_release())
}
/// `(row, value)` of the last slider change since the last call.
pub fn take_slider_change() -> Option<(usize, f32)> {
@@ -1653,30 +1525,20 @@ pub mod context_menu {
/// the display-list path call this in place of the [`extra_quads`] loop.
pub fn paint(ctx: &mut crate::scene::paint::PaintCtx) {
CONTEXT_MENU.with(|m| m.borrow().paint(ctx));
- SUBMENU.with(|m| m.borrow().paint(ctx));
}
pub fn extra_quads() -> Vec<(f32, f32, f32, f32, [f32; 4])> {
- let mut quads = CONTEXT_MENU.with(|m| m.borrow().extra_quads());
- quads.extend(SUBMENU.with(|m| m.borrow().extra_quads()));
- quads
+ CONTEXT_MENU.with(|m| m.borrow().extra_quads())
}
pub fn text_labels() -> Vec<TextLabel> {
- let mut labels = CONTEXT_MENU.with(|m| m.borrow().text_labels());
- labels.extend(SUBMENU.with(|m| m.borrow().text_labels()));
- labels
+ CONTEXT_MENU.with(|m| m.borrow().text_labels())
}
/// Plate and labels in one call — see
/// [`ContextMenuState::paint_with_labels`].
pub fn paint_with_labels(ctx: &mut crate::scene::paint::PaintCtx) {
CONTEXT_MENU.with(|m| m.borrow().paint_with_labels(ctx));
- // In the window the two paint under a borrow, and the slider stamp's
- // drop reaches back for the menu (`clear_if_matches`), which lets go
- // when it cannot have it; the submenu's copy keeps that to one cell.
- let sub = SUBMENU.with(|m| m.borrow().clone());
- sub.paint_with_labels(ctx);
}
}
@@ -2101,135 +1963,86 @@ mod context_menu_padding_tests {
}
#[cfg(test)]
-mod context_menu_submenu_tests {
- use super::context_menu::{self, submenu, MenuSlider, SubmenuSpec, PAD, ROW_H};
- use crate::widget::{MouseScrollDelta, WidgetId};
+mod context_menu_page_tests {
+ use super::context_menu::{self, PageTurn, PAD, ROW_H};
+ use crate::widget::{MouseScrollDelta, Position, WidgetId};
- fn open() -> SubmenuSpec {
+ fn open() {
context_menu::show(400.0, 200.0, vec!["Frame".into(), "Style".into(), "Markers".into(), "Exit".into()], 0, WidgetId(1));
- let spec = SubmenuSpec {
- options: vec!["○ Wireframe".into(), "Opacity".into(), "Smooth".into()],
- header_count: 0,
- sliders: vec![None, Some(MenuSlider { value: 50.0, min: 0.0, max: 100.0, step: 5.0, decimals: 0, suffix: "%" })],
- };
- context_menu::set_row_submenu(1, spec.clone());
- context_menu::set_row_submenu(2, SubmenuSpec { options: vec!["Points".into()], ..Default::default() });
- spec
+ context_menu::set_row_page(1);
+ context_menu::set_row_page(2);
}
- fn over(row: usize) -> (f32, f32) {
- (context_menu::x() + 20.0, context_menu::row_y(row) + ROW_H * 0.5)
- }
-
- /// A row's submenu opens under the pointer, beside the menu with its
- /// first row level with the row it flew out from, and the pointer calls
- /// answer for both menus while the row calls stay each menu's own.
- #[test]
- fn a_submenu_flies_out_beside_the_row_under_the_pointer() {
- open();
- assert!(!submenu::is_visible());
- let (x, y) = over(1);
- assert!(context_menu::cursor_moved(x, y));
- assert_eq!(submenu::parent_row(), Some(1));
- assert_eq!((submenu::x(), submenu::row_y(0)), (context_menu::x() + context_menu::w(), context_menu::row_y(1)));
- assert_eq!(submenu::y(), context_menu::row_y(1) - PAD);
- assert!(submenu::slider(1).is_some(), "its slider rows came with it");
-
- let (sx, sy) = (submenu::x() + 20.0, submenu::row_y(2) + ROW_H * 0.5);
- assert!(context_menu::hit_test(sx, sy), "over the submenu is over the menu");
- assert_eq!(context_menu::row_at(sx, sy), None, "but on no row of the menu's own");
- assert_eq!(submenu::row_at(sx, sy), Some(2));
- context_menu::cursor_moved(sx, sy);
- assert_eq!(submenu::hovered_item(), Some(2));
- assert_eq!(context_menu::hovered_item(), Some(1), "the row it flew out from stays lit");
-
- // The wheel and a press over its slider row are the submenu's, and
- // the change is drained from it, not from the menu.
- let (wx, wy) = (submenu::x() + 20.0, submenu::row_y(1) + ROW_H * 0.5);
- assert!(context_menu::mouse_wheel(&MouseScrollDelta::LineDelta(0.0, 1.0), wx, wy));
- assert_eq!(context_menu::take_slider_change(), None);
- assert_eq!(submenu::take_slider_change(), Some((1, 55.0)));
- let band = context_menu::SUBMENU.with(|m| m.borrow().slider_band(1));
- assert!(context_menu::slider_press(band.x + band.width, wy));
- assert!(context_menu::slider_dragging());
- // Held, it follows the pointer anywhere — over the menu included.
- let (mx, my) = over(3);
- context_menu::cursor_moved(band.x, my.max(wy));
- let _ = mx;
- assert_eq!(submenu::take_slider_change(), Some((1, 0.0)));
- assert!(submenu::is_visible(), "a held slider keeps its submenu");
- assert!(context_menu::slider_release());
- context_menu::hide();
- assert!(!submenu::is_visible(), "the submenu goes with its menu");
- assert!(!context_menu::hit_test(sx, sy));
+ fn over(idx: usize) -> (f32, f32) {
+ (context_menu::x() + 20.0, context_menu::row_y(idx) + ROW_H * 0.5)
}
- /// The pointer on another row swaps or closes the submenu — unless it
- /// is on its way INTO the submenu, crossing that row inside the triangle
- /// from where it left its own row to the submenu's near edge.
- #[test]
- fn a_pointer_heading_into_the_submenu_keeps_it_open() {
- open();
- let (x, y) = over(1);
- context_menu::cursor_moved(x, y);
- assert_eq!(submenu::parent_row(), Some(1));
-
- // Down and to the right, toward the submenu's lower rows: over row
- // 2 of the menu, inside the triangle.
- let toward = (context_menu::x() + context_menu::w() - 4.0, context_menu::row_y(2) + 4.0);
- context_menu::cursor_moved(toward.0, toward.1);
- assert_eq!(submenu::parent_row(), Some(1), "still the first row's");
- assert_eq!(context_menu::hovered_item(), Some(1));
-
- // Straight down the menu: that is a move to the next row.
- context_menu::cursor_moved(x, y);
- let (x2, y2) = over(2);
- context_menu::cursor_moved(x2, y2);
- assert_eq!(submenu::parent_row(), Some(2), "the other row's submenu took its place");
- assert_eq!(submenu::options(), vec!["Points".to_string()]);
-
- // And onto a row with none.
- let (x3, y3) = over(3);
- context_menu::cursor_moved(x3, y3);
- assert!(!submenu::is_visible());
- assert_eq!(context_menu::hovered_item(), Some(3));
- context_menu::hide();
+ /// One swipe, a few events long, the fingers lifted after. The runner's
+ /// phase is left alone — it is one value for the whole process and
+ /// other tests set it.
+ fn swipe(dx: f64, x: f32, y: f32) -> bool {
+ crate::widget::side_swipe::end_gesture();
+ let mut took = false;
+ for _ in 0..4 {
+ took |= context_menu::mouse_wheel(&MouseScrollDelta::PixelDelta(Position { x: dx / 4.0, y: 0.0 }), x, y);
+ }
+ took
}
- /// New contents for a submenu that is open change it where it stands:
- /// the labels follow, the hover and the popup (its generation) stay.
+ /// A page row is a row that turns the plate: a press on it, or a swipe
+ /// forward with the pointer on it, asks for its page, and nothing opens
+ /// on hover alone.
#[test]
- fn an_open_submenu_is_refilled_in_place() {
- let mut spec = open();
+ fn a_page_row_turns_on_a_press_or_a_swipe() {
+ open();
let (x, y) = over(1);
context_menu::cursor_moved(x, y);
- context_menu::cursor_moved(submenu::x() + 20.0, submenu::row_y(0) + ROW_H * 0.5);
- let (at, generation) = ((submenu::x(), submenu::y()), submenu::generation());
- spec.options[0] = "● Wireframe".into();
- spec.sliders[1].as_mut().unwrap().value = 80.0;
- context_menu::set_row_submenu(1, spec);
- assert_eq!(submenu::options()[0], "● Wireframe");
- assert_eq!(submenu::slider(1).unwrap().value, 80.0);
- assert_eq!(submenu::hovered_item(), Some(0));
- assert_eq!(((submenu::x(), submenu::y()), submenu::generation()), (at, generation));
+ assert_eq!(context_menu::take_turn(), None, "hovering turns nothing");
+ assert_eq!(context_menu::turn_at(x, y), Some(PageTurn::Into(1)));
+ let (ex, ey) = over(3);
+ assert_eq!(context_menu::turn_at(ex, ey), None, "an action row is no page");
+
+ assert!(swipe(-80.0, x, y), "the swipe was the menu's");
+ assert_eq!(context_menu::take_turn(), Some(PageTurn::Into(1)));
+ assert_eq!(context_menu::take_turn(), None, "taken once");
+ for _ in 0..4 {
+ context_menu::mouse_wheel(&MouseScrollDelta::PixelDelta(Position { x: -20.0, y: 0.0 }), x, y);
+ }
+ assert_eq!(context_menu::take_turn(), None, "one turn a gesture");
+ open();
+ assert!(!swipe(-80.0, ex, ey), "forward over an action row turns nothing");
+ open();
+ assert!(!swipe(80.0, x, y), "back from a menu that was opened goes nowhere");
+ assert_eq!(context_menu::take_turn(), None);
context_menu::hide();
}
- /// In a window with no popup surface the submenu is kept inside it: on
- /// the menu's left where the right has no room, slid up off the bottom.
+ /// A page stands where the menu stood, under a back band that a press or
+ /// a swipe back turns back from; its rows begin under the band.
#[test]
- fn a_submenu_with_no_room_flips_to_the_other_side() {
+ fn a_page_stands_in_the_menus_place_with_a_way_back() {
open();
+ let (mx, my) = (context_menu::x(), context_menu::y());
+ context_menu::show_page(mx, my, Some("View"), vec!["○ Wireframe".into(), "Opacity".into()], 0, WidgetId(1));
+ assert!(context_menu::is_turned());
+ assert_eq!((context_menu::x(), context_menu::y()), (mx, my));
+ assert_eq!(context_menu::back_title().as_deref(), Some("View"));
+ assert_eq!(context_menu::row_y(0), my + PAD + ROW_H, "the rows begin under the band");
+ assert_eq!(context_menu::h(), 2.0 * ROW_H + ROW_H + 2.0 * PAD);
+
+ let band = (mx + 20.0, my + PAD + ROW_H * 0.5);
+ assert_eq!(context_menu::row_at(band.0, band.1), None, "the band is no row");
+ assert_eq!(context_menu::turn_at(band.0, band.1), Some(PageTurn::Back));
let (x, y) = over(1);
- context_menu::cursor_moved(x, y);
- let right = context_menu::x() + context_menu::w();
- submenu::constrain_beside(0.0, 0.0, right + submenu::w() + 1.0, 1000.0);
- assert_eq!(submenu::x(), right);
- submenu::constrain_beside(0.0, 0.0, right + 10.0, 1000.0);
- assert_eq!(submenu::x() + submenu::w(), context_menu::x(), "flipped to the left");
- let bottom = context_menu::row_y(1) + ROW_H;
- submenu::constrain_beside(0.0, 0.0, 2000.0, bottom);
- assert!(submenu::y() + submenu::h() <= bottom + 0.01, "slid up");
+ assert_eq!(context_menu::row_at(x, y), Some(1));
+ assert!(swipe(80.0, x, y));
+ assert_eq!(context_menu::take_turn(), Some(PageTurn::Back), "back from anywhere on the page");
+
+ // A placement that cannot fit it below slides it, never flips it up
+ // from the corner it took over.
+ context_menu::constrain_to(0.0, 0.0, 2000.0, my + 10.0);
+ assert!(context_menu::y() + context_menu::h() <= my + 10.0 + 0.01);
+ assert!(context_menu::y() >= 0.0);
context_menu::hide();
}
}
diff --git a/src/widget/mod.rs b/src/widget/mod.rs
index 9086f2f..af24ef0 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -627,12 +627,14 @@ pub mod line_edit;
pub mod model;
pub mod scroll_region;
pub mod scroll_motion;
+pub mod side_swipe;
// Re-exports
pub use self::editor::TextEditorState;
pub use self::line_edit::{EditOutcome, LineEdit};
pub use self::layout_helper::{ColumnLayout, RowLayout};
pub use self::scroll_region::{ScrollRegion, ScrollbarActivity};
+pub use self::side_swipe::{SideSwipe, SwipeDir};
pub use self::scroll_motion::{Bounds, ScrollAxis, ScrollMotion, ScrollPhase, ScrollSettings, LINE_PX};
pub use self::model::{Adapted, EventCtx, Input, Layout, Paint};
pub use self::core::{Widget, focus, hover_animation, clipboard, context_menu, clear_widget_references};
diff --git a/src/widget/side_swipe.rs b/src/widget/side_swipe.rs
new file mode 100644
index 0000000..295e99a
--- /dev/null
+++ b/src/widget/side_swipe.rs
@@ -0,0 +1,208 @@
+//! A two-finger swipe TO THE SIDE, read as a page turn: forward into what a
+//! row leads to, or back to where the plate was before.
+//!
+//! The runner delivers a trackpad's two fingers as wheel events with a pixel
+//! delta, and a mouse's tilt wheel as horizontal notches. A plate that turns
+//! pages (a context menu's page rows, a dialog opened from one) feeds every
+//! wheel event it takes to [`feed`] (a [`SideSwipe`]), which answers once per gesture:
+//! nothing for a vertical scroll or a small sideways drift, and one
+//! [`SwipeDir`] when the fingers have travelled far enough to the side to
+//! mean it. The rest of that gesture turns nothing, so one swipe is one page.
+//!
+//! **The direction follows the content**, as a horizontal list does: the
+//! delta that would scroll a list to show what is to its RIGHT is
+//! [`SwipeDir::Forward`] — the page a row leads to comes in from the right,
+//! the side its `›` points to — and the other way is [`SwipeDir::Back`]. The
+//! compositor applies natural scrolling before the delta reaches the app, so
+//! under natural scrolling forward is the fingers going LEFT (the page is
+//! pushed aside) and back is the fingers going right, as on every touch
+//! surface; under traditional scrolling the two swap with the user's own
+//! setting, and nothing here asks which it is.
+//!
+//! **One gesture is one turn across every plate**: the plate a swipe turns
+//! is replaced by another under the same fingers, and a recognizer of its
+//! own would see the rest of the gesture as a new one and turn again. So
+//! the plates of a thread share one, [`feed`], which only the lift (or a
+//! pause) readies for the next turn.
+
+use crate::widget::scroll_motion::{current_scroll_phase, ScrollPhase};
+use crate::widget::MouseScrollDelta;
+use std::time::{Duration, Instant};
+
+/// How far the fingers travel to the side, in the runner's pixel delta,
+/// before a swipe is a page turn. A deliberate flick covers this in two or
+/// three events; a vertical scroll's sideways wander does not reach it.
+pub const SWIPE_PX: f32 = 40.0;
+/// How much more sideways than vertical a swipe has to be.
+pub const SWIPE_SLOPE: f32 = 1.5;
+/// A pause this long between wheel events ends a gesture, for a source that
+/// sends no finger lift (a tilt wheel, a trackpad whose stop was dropped).
+pub const SWIPE_GAP: Duration = Duration::from_millis(250);
+
+/// Which way a side swipe turns.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum SwipeDir {
+ /// Into what the row under the pointer leads to.
+ Forward,
+ /// Back to the plate this one was turned to from.
+ Back,
+}
+
+/// One gesture's sideways travel. See the module docs.
+#[derive(Debug, Clone, Default)]
+pub struct SideSwipe {
+ dx: f32,
+ dy: f32,
+ /// This gesture has turned a page already.
+ fired: bool,
+ last: Option<Instant>,
+}
+
+impl SideSwipe {
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Feed a wheel event in the phase the runner published for it.
+ pub fn feed(&mut self, delta: &MouseScrollDelta) -> Option<SwipeDir> {
+ self.feed_at(delta, current_scroll_phase(), Instant::now())
+ }
+
+ /// [`Self::feed`] in a phase and at a time the caller names — the tests',
+ /// which run in parallel and cannot share the runner's phase.
+ pub fn feed_at(&mut self, delta: &MouseScrollDelta, phase: ScrollPhase, now: Instant) -> Option<SwipeDir> {
+ if phase == ScrollPhase::FingerEnd {
+ self.reset();
+ return None;
+ }
+ if self.last.is_some_and(|t| now.duration_since(t) > SWIPE_GAP) {
+ self.reset();
+ }
+ self.last = Some(now);
+ let (x, y, whole) = match delta {
+ MouseScrollDelta::LineDelta(x, y) => (*x, *y, true),
+ MouseScrollDelta::PixelDelta(p) => (p.x as f32, p.y as f32, false),
+ };
+ self.dx += x;
+ self.dy += y;
+ if self.fired {
+ return None;
+ }
+ // A notch of a tilt wheel is a whole swipe; fingers have to travel.
+ let far = if whole { self.dx.abs() >= 1.0 } else { self.dx.abs() >= SWIPE_PX };
+ if !far || self.dx.abs() < SWIPE_SLOPE * self.dy.abs() {
+ return None;
+ }
+ self.fired = true;
+ // A negative x scrolls a list to show what is to its right.
+ Some(if self.dx < 0.0 { SwipeDir::Forward } else { SwipeDir::Back })
+ }
+
+ /// Forget the gesture in progress.
+ pub fn reset(&mut self) {
+ *self = Self::default();
+ }
+}
+
+thread_local! {
+ static SHARED: std::cell::RefCell<SideSwipe> = std::cell::RefCell::new(SideSwipe::new());
+}
+
+/// Feed a wheel event to the thread's one recognizer — see the module docs.
+pub fn feed(delta: &MouseScrollDelta) -> Option<SwipeDir> {
+ SHARED.with(|s| s.borrow_mut().feed(delta))
+}
+
+/// Whether `delta` continues a gesture that has turned a page already. The
+/// rest of that gesture is the turn's: the plate it turned into may be
+/// smaller than the one it replaced, and what is under the fingers then is a
+/// list to scroll or a scene to orbit, which would take it as theirs. A host
+/// asks this ahead of its own wheel handling and drops the event on `true`;
+/// the event is fed, so the lift or a pause still ends the gesture.
+pub fn swallow(delta: &MouseScrollDelta) -> bool {
+ swallow_at(delta, current_scroll_phase(), Instant::now())
+}
+
+fn swallow_at(delta: &MouseScrollDelta, phase: ScrollPhase, now: Instant) -> bool {
+ SHARED.with(|s| {
+ let mut s = s.borrow_mut();
+ let live = s.fired && phase != ScrollPhase::FingerEnd && s.last.is_some_and(|t| now.duration_since(t) <= SWIPE_GAP);
+ if live {
+ s.feed_at(delta, phase, now);
+ }
+ live
+ })
+}
+
+/// End the gesture in progress, as the fingers lifting does: what a test
+/// that swipes twice calls between, since the runner's phase is one value
+/// for the whole process and not the test's to set.
+pub fn end_gesture() {
+ SHARED.with(|s| s.borrow_mut().reset());
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::widget::Position;
+
+ fn px(x: f64, y: f64) -> MouseScrollDelta {
+ MouseScrollDelta::PixelDelta(Position { x, y })
+ }
+
+ /// Fingers that travel far enough to the side turn one page, the way the
+ /// content goes; the rest of the gesture turns nothing, and the lift
+ /// readies the next.
+ #[test]
+ fn a_side_swipe_turns_one_page_per_gesture() {
+ let mut s = SideSwipe::new();
+ let t = Instant::now();
+ let f = ScrollPhase::Finger;
+ assert_eq!(s.feed_at(&px(-15.0, 1.0), f, t), None, "not far enough yet");
+ assert_eq!(s.feed_at(&px(-15.0, 1.0), f, t), None);
+ assert_eq!(s.feed_at(&px(-15.0, 2.0), f, t), Some(SwipeDir::Forward));
+ assert_eq!(s.feed_at(&px(-80.0, 0.0), f, t), None, "one turn a gesture");
+ assert_eq!(s.feed_at(&px(0.0, 0.0), ScrollPhase::FingerEnd, t), None);
+ assert_eq!(s.feed_at(&px(60.0, 0.0), f, t), Some(SwipeDir::Back));
+ }
+
+ /// What is left of a gesture after it turned is swallowed, until the
+ /// lift; before it turns, nothing is.
+ #[test]
+ fn the_rest_of_a_turning_gesture_is_swallowed() {
+ end_gesture();
+ let t = Instant::now();
+ let f = ScrollPhase::Finger;
+ assert!(!swallow_at(&px(-30.0, 0.0), f, t), "nothing has turned");
+ SHARED.with(|s| s.borrow_mut().feed_at(&px(-50.0, 0.0), f, t));
+ assert!(swallow_at(&px(-30.0, 0.0), f, t), "the rest of the turn");
+ assert!(!swallow_at(&px(0.0, 0.0), ScrollPhase::FingerEnd, t), "the lift is not");
+ SHARED.with(|s| s.borrow_mut().feed_at(&px(0.0, 0.0), ScrollPhase::FingerEnd, t));
+ assert!(!swallow_at(&px(-30.0, 0.0), f, t), "a new gesture");
+ end_gesture();
+ }
+
+ /// A scroll that is mostly vertical never turns, however far it drifts.
+ #[test]
+ fn a_vertical_scroll_is_not_a_swipe() {
+ let mut s = SideSwipe::new();
+ let t = Instant::now();
+ for _ in 0..20 {
+ assert_eq!(s.feed_at(&px(-10.0, 30.0), ScrollPhase::Finger, t), None);
+ }
+ }
+
+ /// A tilt-wheel notch is a swipe; notches in a burst are one, and a
+ /// pause readies the next.
+ #[test]
+ fn a_tilt_wheel_notch_turns_and_a_pause_ends_the_gesture() {
+ let mut s = SideSwipe::new();
+ let t = Instant::now();
+ let w = ScrollPhase::Wheel;
+ let notch = MouseScrollDelta::LineDelta(-1.0, 0.0);
+ assert_eq!(s.feed_at(¬ch, w, t), Some(SwipeDir::Forward));
+ assert_eq!(s.feed_at(¬ch, w, t + Duration::from_millis(50)), None);
+ let later = t + Duration::from_millis(50) + SWIPE_GAP + Duration::from_millis(1);
+ assert_eq!(s.feed_at(&MouseScrollDelta::LineDelta(1.0, 0.0), w, later), Some(SwipeDir::Back));
+ }
+}