git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commit12d35bc12da778a15d2b26bab4b8eb8b4e7b81d9
parentdf120d6ee6
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-29 16:47
feat: a context menu row can open a submenu beside it

set_row_submenu gives a row a second menu that flies out while the
pointer is on it, in a popup that is the child of the menu's own. The
menu opens and closes it, with a triangle of hover intent so the rows
crossed on the way in do not take it; a host fills it and dispatches its
rows through context_menu::submenu. The pointer over either menu wears
the default cursor.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

 CLAUDE.md                    |  43 ++++
 src/backend/menu_popup.rs    | 206 ++++++++++++++---
 src/backend/window_runner.rs |  19 +-
 src/widget/core.rs           | 536 ++++++++++++++++++++++++++++++++++++++++++-
 4 files changed, 766 insertions(+), 38 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 3239172..eeb4264 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -240,6 +240,49 @@ Layer surfaces keep the in-window menu, placed by `context_menu::constrain_to` w
 same flip / slide / shorten rules inside the window; so does any app run with
 `CCE_UI_MENU_POPUP=0`.
 
+### 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.
+
 ## Plates, wells and seams — the surface vocabulary
 
 Everything cce draws is a lit surface, and the words below name those surfaces
diff --git a/src/backend/menu_popup.rs b/src/backend/menu_popup.rs
index b995dfb..2330aba 100644
--- a/src/backend/menu_popup.rs
+++ b/src/backend/menu_popup.rs
@@ -31,6 +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.
+//!
 //! 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
 //! by `context_menu::constrain_to` with the same flip / slide / shorten rules.
@@ -62,6 +73,13 @@ 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.
@@ -93,30 +111,54 @@ impl<A: Application> EngineState<A> {
         )
     }
 
-    /// 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.
+    /// 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
+    /// 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() {
+            if self.menu_popup.is_some() || self.submenu_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) {
+        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();
             return;
         }
-        self.close_menu_popup();
-        self.open_menu_popup(anchor, key);
+        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) {
+            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);
     }
 
     fn open_menu_popup(&mut self, anchor: (f32, f32), key: (u64, u32, u32)) {
@@ -170,10 +212,66 @@ impl<A: Application> EngineState<A> {
         self.menu_popup = Some(MenuPopup { popup, key, placed: None, committed_scale: 0 });
     }
 
-    /// 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.
+    /// 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
+    /// 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();
@@ -183,12 +281,39 @@ impl<A: Application> EngineState<A> {
         context_menu::set_hosted(false);
     }
 
-    /// Draw the menu into its popup. Called after the window's own frame,
-    /// and after a configure; a no-op until the popup is placed.
+    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.
+    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)))
+    }
+
+    /// 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.
     pub(crate) fn render_menu_popup(&mut self) {
-        let Some(mp) = self.menu_popup.as_mut() else { return };
+        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 Some(mp) = mp else { return };
         let Some((_, _, w, h)) = mp.placed else { return };
-        let Some(renderer) = self.menu_renderer.as_mut() else { return };
+        let Some(renderer) = renderer else { return };
         if !renderer.has_surface() {
             return;
         }
@@ -196,7 +321,10 @@ 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();
-        context_menu::paint_hosted(&mut pc);
+        match which {
+            Which::Menu => context_menu::paint_hosted(&mut pc),
+            Which::Submenu => context_menu::submenu::paint_hosted(&mut pc),
+        }
         let dl = pc.finish();
 
         let mut items = Vec::new();
@@ -224,25 +352,47 @@ impl<A: Application> EngineState<A> {
             clear_color: [0.0; 4],
         });
     }
+
+    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
+        }
+    }
 }
 
 impl<A: Application> PopupHandler for EngineState<A> {
     fn configure(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, popup: &Popup, config: PopupConfigure) {
-        let Some(mp) = self.menu_popup.as_mut() else { return };
-        if mp.popup.wl_surface() != popup.wl_surface() {
-            return;
-        }
+        let Some(which) = self.which_popup(popup.wl_surface()) else { return };
         let f = forced();
-        let (x, y) = (config.position.0 as f32 / f, config.position.1 as f32 / f);
+        let (mut x, mut 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 Some(mp) = mp else { return };
         mp.placed = Some((x, y, w, h));
         // Where it landed IS where the menu is — see the module docs.
-        context_menu::place(x, y, h);
+        match which {
+            Which::Menu => context_menu::place(x, y, h),
+            Which::Submenu => context_menu::submenu::place(x, y, h),
+        }
 
-        let (_, pw, ph) = Self::buffer_geometry(self.scale_factor, w, 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;
-        let display_ptr = self.display_ptr as *mut std::ffi::c_void;
-        let attached = match self.menu_renderer.as_mut() {
+        let display_ptr = display as *mut std::ffi::c_void;
+        let attached = match slot.as_mut() {
             Some(r) if r.has_surface() => {
                 r.resize(pw, ph);
                 Ok(())
@@ -252,7 +402,7 @@ impl<A: Application> PopupHandler for EngineState<A> {
                 let t = std::time::Instant::now();
                 let made = unsafe { VkRenderer::try_new(display_ptr, surface_ptr, pw, ph, 0.0) };
                 log::debug!("[menu_popup] renderer created in {:?}", t.elapsed());
-                made.map(|r| self.menu_renderer = Some(r))
+                made.map(|r| *slot = Some(r))
             }
         };
         // A lost surface is the connection dying under the menu; the window's
@@ -265,13 +415,13 @@ impl<A: Application> PopupHandler for EngineState<A> {
             return;
         }
         self.redraw = true;
-        self.render_menu_popup();
+        self.render_popup(which);
     }
 
     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.menu_popup.as_ref().is_some_and(|mp| mp.popup.wl_surface() == popup.wl_surface()) {
+        if self.which_popup(popup.wl_surface()).is_some() {
             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 b7d89e5..33d6d2c 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -3920,6 +3920,9 @@ 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,
 
@@ -4189,6 +4192,13 @@ 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,
+        // 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.
+        if crate::widget::context_menu::is_visible() && crate::widget::context_menu::hit_test(lx, ly) {
+            return CursorIcon::Default;
+        }
         let inner = self.inner.as_ref().unwrap();
         if let Some(icon) = inner.cursor_icon(lx, ly) {
             return icon;
@@ -4370,6 +4380,10 @@ 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);
 
@@ -4495,6 +4509,7 @@ 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;
     }
 }
@@ -4803,7 +4818,7 @@ impl<A: Application> PointerHandler for EngineState<A> {
             // An event on the context menu's popup surface is the app's too,
             // at the popup's offset from the window: menu dispatch works in
             // window coordinates, which now reach outside the window.
-            let popup_offset = self.menu_popup.as_ref().and_then(|p| p.offset_for(&event.surface));
+            let popup_offset = self.menu_popup_offset(&event.surface);
             let on_popup = popup_offset.is_some();
             let (lx, ly) = match popup_offset {
                 Some((ox, oy)) => (lx + ox, ly + oy),
@@ -5962,6 +5977,8 @@ 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 86368a4..3169a47 100644
--- a/src/widget/core.rs
+++ b/src/widget/core.rs
@@ -399,6 +399,24 @@ 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>>,
+    }
+
     /// A toolkit color as the `[u8; 3]` a [`TextLabel`] carries.
     fn rgb8(c: [f32; 4]) -> [u8; 3] {
         [
@@ -425,6 +443,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)>,
         /// 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.
@@ -477,6 +510,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,
                 slider_drag: None,
                 wheel_accum: 0.0,
                 slider_change: None,
@@ -535,11 +573,172 @@ 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.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() {
+                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;
+            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.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) {
+                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;
+            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();
+            }
+        }
+
+        /// 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,
+            }
+        }
+
+        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)
+        }
+
+        /// 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);
+        }
+
         /// Make row `idx` a slider. Widens the plate to hold the label, the
         /// readout at its widest (both ends of the range) and the band.
         pub fn set_row_slider(&mut self, idx: usize, slider: MenuSlider) {
@@ -707,6 +906,8 @@ pub mod context_menu {
             self.visible = false;
             self.target = None;
             self.last_cursor = None;
+            self.slider_drag = None;
+            self.close_submenu();
         }
 
         pub fn hit_test(&self, px: f32, py: f32) -> bool {
@@ -741,7 +942,10 @@ pub mod context_menu {
             if self.slider_drag.is_some() {
                 return self.slider_drag_to(px);
             }
-            self.rehover(px, py)
+            let was = (self.hovered_item, self.open_sub);
+            self.rehover(px, py);
+            self.follow_hover(px, py);
+            (self.hovered_item, self.open_sub) != was
         }
 
         fn rehover(&mut self, px: f32, py: f32) -> bool {
@@ -1020,6 +1224,18 @@ 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) {
+                    let (family, _) = label_font();
+                    let tw = crate::widget::display::measure_text_width(SUBMENU_MARK, &family, label_size);
+                    labels.push(TextLabel {
+                        text: SUBMENU_MARK.to_string(),
+                        x: self.x + self.w - PAD - tw.max(label_size * 0.4),
+                        y: iy,
+                        font_size: label_size,
+                        color: text_color,
+                    });
+                }
                 // A slider row's readout, right-aligned against its band.
                 if let Some(s) = self.slider(idx) {
                     let (family, _) = label_font();
@@ -1079,6 +1295,138 @@ 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));
+        }
+    }
+
+    /// 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 has_submenu(idx: usize) -> bool {
+        CONTEXT_MENU.with(|m| m.borrow().has_submenu(idx))
+    }
+    /// 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))
+    }
+    pub fn close_submenu() -> bool {
+        CONTEXT_MENU.with(|m| m.borrow_mut().close_submenu())
+    }
+    /// 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))
+        })
     }
 
     pub fn is_visible() -> bool {
@@ -1104,8 +1452,7 @@ pub mod context_menu {
             // a menu with a slider row.
             let Ok(mut menu) = m.try_borrow_mut() else { return };
             if menu.target == Some(id) {
-                menu.target = None;
-                menu.visible = false;
+                menu.hide();
             }
         });
     }
@@ -1163,6 +1510,9 @@ 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.
@@ -1170,11 +1520,28 @@ 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))
+        CONTEXT_MENU.with(|m| m.borrow().hit_test(px, py)) || submenu::hit_test(px, py)
     }
 
     pub fn cursor_moved(px: f32, py: f32) -> bool {
-        CONTEXT_MENU.with(|m| m.borrow_mut().cursor_moved(px, py))
+        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
     }
 
     /// Make row `idx` of the shown menu a slider — see [`MenuSlider`]. Call
@@ -1188,19 +1555,26 @@ 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())
+        CONTEXT_MENU.with(|m| m.borrow().slider_drag.is_some()) || submenu::slider_dragging()
     }
     pub fn slider_release() -> bool {
-        CONTEXT_MENU.with(|m| m.borrow_mut().slider_release())
+        let sub = SUBMENU.with(|m| m.borrow_mut().slider_release());
+        CONTEXT_MENU.with(|m| m.borrow_mut().slider_release()) || sub
     }
     /// `(row, value)` of the last slider change since the last call.
     pub fn take_slider_change() -> Option<(usize, f32)> {
@@ -1215,20 +1589,30 @@ 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])> {
-        CONTEXT_MENU.with(|m| m.borrow().extra_quads())
+        let mut quads = CONTEXT_MENU.with(|m| m.borrow().extra_quads());
+        quads.extend(SUBMENU.with(|m| m.borrow().extra_quads()));
+        quads
     }
 
     pub fn text_labels() -> Vec<TextLabel> {
-        CONTEXT_MENU.with(|m| m.borrow().text_labels())
+        let mut labels = CONTEXT_MENU.with(|m| m.borrow().text_labels());
+        labels.extend(SUBMENU.with(|m| m.borrow().text_labels()));
+        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);
     }
 }
 
@@ -1624,3 +2008,137 @@ mod context_menu_padding_tests {
         assert!(m.w >= drawn + 2.0 * PAD, "plate {} narrower than the drawn label {} plus pads", m.w, drawn);
     }
 }
+
+#[cfg(test)]
+mod context_menu_submenu_tests {
+    use super::context_menu::{self, submenu, MenuSlider, SubmenuSpec, PAD, ROW_H};
+    use crate::widget::{MouseScrollDelta, WidgetId};
+
+    fn open() -> SubmenuSpec {
+        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
+    }
+    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));
+    }
+
+    /// 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();
+    }
+
+    /// New contents for a submenu that is open change it where it stands:
+    /// the labels follow, the hover and the popup (its generation) stay.
+    #[test]
+    fn an_open_submenu_is_refilled_in_place() {
+        let mut spec = 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));
+        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.
+    #[test]
+    fn a_submenu_with_no_room_flips_to_the_other_side() {
+        open();
+        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");
+        context_menu::hide();
+    }
+}