git.lucas.co / cce-designer
graphic design tool
git clone https://git.lucas.co/cce-designer.git

commitccffec250909309712d0cf6318d62aa296c1bb48
parente960f0cfee
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-29 16:48
feat: the viewport menu's Style and Markers are flyout submenus

cce-ui's context menu rows can open a submenu beside them, so the two
pages of the one popup become two submenus: filled from the live state,
re-marked where they stand after a switch, and left open by their rows.

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

 CLAUDE.md   |  51 ++++++++++---------
 Cargo.lock  |   2 +-
 Cargo.toml  |   2 +-
 src/app.rs  | 144 ++++++++++++++++++++++++++++++----------------------
 src/main.rs | 166 ++++++++++++++++++++++++++++++++----------------------------
 5 files changed, 203 insertions(+), 162 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index a427819..095e9a1 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -2064,35 +2064,40 @@ multi-selection, so taking `Shift+L` now would have to be given back later.
 
 ### Display mode: the viewport menu, and smooth shading
 
-The viewport's right-click menu has a top level and two PAGES (since
-2026-09-29; until then it was one list of some twenty rows). The top level
-holds what is done — Frame All, View 1:1 — the guides (Show Grid, Show
-Origin; the reference CUBE guide was removed on 2026-09-25 — its command,
-mesh, RT-scene copy, settings field and menubar item, with the Guides
-menubar addressed through `GUIDES_MENU` / `GUIDE_*` so no item slid onto
-another's action, while old files carrying `show_cube_enabled` still load),
-and a row into each page: **Style ›** (how the geometry is drawn: the
+The viewport's right-click menu has two SUBMENUS (since 2026-09-29;
+until then it was one list of some twenty rows). The menu itself holds
+what is done — Frame All, View 1:1 — the guides (Show Grid, Show Origin;
+the reference CUBE guide was removed on 2026-09-25 — its command, mesh,
+RT-scene copy, settings field and menubar item, with the Guides menubar
+addressed through `GUIDES_MENU` / `GUIDE_*` so no item slid onto another's
+action, while old files carrying `show_cube_enabled` still load), and a
+row for each submenu: **Style** (how the geometry is drawn: the
 wireframe's switch, thickness and opacity, then the surface's shading,
-opacity and Show Occluded) and **Markers ›** (what is drawn on it: Show
+opacity and Show Occluded) and **Markers** (what is drawn on it: Show
 Points, Point Size, Group Marker Scale and Pull Arrow Scale; then the
 overlays a class at a time — Show Point Markers and its size, Show Point
 Numbers, Show Point Normals; Show Primitive Numbers, Show Primitive
 Normals; Show Vertex Numbers).
 
-**A submenu is the same popup showing another page.** cce-ui's context
-menu is one thread-local menu in one popup surface and has no cascade, so
-`ViewportMenuAction::Submenu(page)` re-shows the menu with the page's rows
-at `viewport_menu_anchor`, where it was opened, and the page's first row
-(`‹ Markers`, `ViewportMenuAction::Back`) re-shows the top level.
-`viewport_menu_rows_of(page)` is the rows of any page, `viewport_menu_rows`
-those of the one showing (`State::viewport_menu_page`), and
-`show_viewport_menu_page` the one place the menu is put up. **A row of a
-page keeps the menu open**: a page is a panel of settings, entered to set
-several, so a switch flips, the page is shown again for its marks, and the
-menu closes on a press outside it or Escape; a top-level row closes it as
-before. `the_viewport_menu_turns_to_a_page_and_stays_open` drives it by
-presses. A cascading flyout beside the parent row would be a cce-ui
-change, to the menu and to the runner's popup hosting.
+**The submenus are cce-ui's** (`context_menu::set_row_submenu`, see its
+CLAUDE.md, "A row can open a submenu"): a second menu in its own popup
+that flies out beside the row under the pointer, which the toolkit opens,
+closes and places. What this app does is fill and dispatch.
+`viewport_menu_rows_of(page)` is the rows of the menu (`None`) or of a
+submenu; `fill_viewport_submenus` hands each `ViewportMenuAction::Submenu`
+row its rows and sliders, read from the live state, at the open and again
+after anything a submenu row did — the toolkit changes an open submenu
+where it stands, which is how a mark follows its switch — and
+`open_viewport_submenu_actions` is the open submenu's actions, by
+`submenu::parent_row`. **A row of a submenu keeps both menus up**: a
+submenu is a panel of settings, opened to set several, so they close on a
+press outside or Escape; a row of the menu itself closes it as before.
+The slider hooks are unchanged, the toolkit's pointer calls answering for
+both menus; `drain_viewport_menu_slider` drains the menu's sliders and
+then the submenu's. For one day the two were PAGES of the one popup,
+entered by a row and left by a Back row, because the toolkit had no
+submenu. `the_viewport_menu_flies_its_settings_out_beside_it` drives it
+by pointer.
 
 **The primitive and vertex overlays** (`toggle_prim_numbers`,
 `toggle_prim_normals`, `toggle_vertex_numbers`, the same day) are
diff --git a/Cargo.lock b/Cargo.lock
index 52b050e..b1e4c47 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -437,7 +437,7 @@ dependencies = [
 [[package]]
 name = "cce-ui"
 version = "0.1.0"
-source = "git+https://github.com/lsgalante/cce-ui.git?rev=df120d6ee6f8d1aecdd3bd753ac2baa47dec92c0#df120d6ee6f8d1aecdd3bd753ac2baa47dec92c0"
+source = "git+https://github.com/lsgalante/cce-ui.git?rev=12d35bc12da778a15d2b26bab4b8eb8b4e7b81d9#12d35bc12da778a15d2b26bab4b8eb8b4e7b81d9"
 dependencies = [
  "ash",
  "bitflags 2.13.1",
diff --git a/Cargo.toml b/Cargo.toml
index cf57efb..68102de 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,7 +4,7 @@ version = "0.1.0"
 edition = "2021"
 
 [dependencies]
-cce-ui = { git = "https://github.com/lsgalante/cce-ui.git", rev = "df120d6ee6f8d1aecdd3bd753ac2baa47dec92c0" }
+cce-ui = { git = "https://github.com/lsgalante/cce-ui.git", rev = "12d35bc12da778a15d2b26bab4b8eb8b4e7b81d9" }
 smithay-client-toolkit = "0.19.2"
 calloop = "0.13.0"
 calloop-wayland-source = "0.3.0"
diff --git a/src/app.rs b/src/app.rs
index 9b66c1c..c6c2be8 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -410,9 +410,8 @@ pub const GUIDE_GRID: usize = 0;
 pub const GUIDE_ORIGIN: usize = 1;
 pub const GUIDE_CAMERA_PIVOT: usize = 2;
 
-/// A page of the viewport menu below its top level: the menu is one popup,
-/// so a submenu is the same popup showing another page, entered by a row
-/// and left by the page's first row.
+/// A submenu of the viewport menu: a second menu that flies out beside its
+/// row (cce-ui's `context_menu::SubmenuSpec`).
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 pub enum ViewportMenuPage {
     /// How the geometry itself is drawn: the wireframe and the surface.
@@ -433,10 +432,8 @@ impl ViewportMenuPage {
 
 #[derive(Debug, Clone, Copy, PartialEq)]
 pub enum ViewportMenuAction {
-    /// Show a page of the menu in its place. The menu stays open.
+    /// A row that opens a submenu beside it. The menu stays open.
     Submenu(ViewportMenuPage),
-    /// A page's first row: back to the top level. The menu stays open.
-    Back,
     /// Move the active camera so the visible node geometry fills the view.
     FrameAll,
     /// Put the pivot plane at true size: one world unit (the Guides "World
@@ -2046,10 +2043,6 @@ pub struct State {
     /// machinery as the node menu; this flag says the open menu is OURS).
     pub viewport_menu_active: bool,
     pub viewport_menu_actions: Vec<ViewportMenuAction>,
-    /// The page the viewport menu shows, `None` for its top level, and
-    /// where it was opened: a page is shown where the menu already is.
-    pub viewport_menu_page: Option<ViewportMenuPage>,
-    pub viewport_menu_anchor: (f32, f32),
     /// A parameter row's right-click menu — the same thread-local; the
     /// target is (node id, parameter name) rather than a slot and a row, so
     /// it holds across a re-layout of the pane.
@@ -5053,27 +5046,40 @@ impl State {
 
     /// Open the viewport right-click context menu at the cursor.
     pub(crate) fn open_viewport_context_menu(&mut self) {
-        self.viewport_menu_anchor = (self.cursor_x, self.cursor_y);
-        self.show_viewport_menu_page(None);
-    }
-
-    /// Show a page of the viewport menu (`None`, its top level) where the
-    /// menu was opened. It is how the menu opens, how a submenu row and a
-    /// Back row turn the page, and how a page re-reads its marks after one
-    /// of its switches was flipped.
-    pub(crate) fn show_viewport_menu_page(&mut self, page: Option<ViewportMenuPage>) {
-        self.viewport_menu_page = page;
         let (options, actions) = self.viewport_menu_rows();
         let target = self.slots.viewport.id();
-        let (x, y) = self.viewport_menu_anchor;
-        cce_ui::widget::context_menu::show(x, y, options, 0, target);
+        cce_ui::widget::context_menu::show(self.cursor_x, self.cursor_y, options, 0, target);
         for (i, a) in actions.iter().enumerate() {
             if let Some(slider) = self.viewport_menu_slider(*a) {
                 cce_ui::widget::context_menu::set_row_slider(i, slider);
             }
         }
-        self.viewport_menu_active = true;
         self.viewport_menu_actions = actions;
+        self.fill_viewport_submenus();
+        self.viewport_menu_active = true;
+    }
+
+    /// Give each submenu row of the open viewport menu its submenu, read
+    /// from the live state: at the open, and again after anything a row of
+    /// one did, which is how its marks and readouts follow. The menu changes
+    /// an open submenu where it stands.
+    pub(crate) fn fill_viewport_submenus(&self) {
+        use cce_ui::widget::context_menu::{self, SubmenuSpec};
+        for (i, a) in self.viewport_menu_actions.iter().enumerate() {
+            let ViewportMenuAction::Submenu(page) = *a else { continue };
+            let (options, actions) = self.viewport_menu_rows_of(Some(page));
+            let sliders = actions.iter().map(|a| self.viewport_menu_slider(*a)).collect();
+            context_menu::set_row_submenu(i, SubmenuSpec { options, header_count: 0, sliders });
+        }
+    }
+
+    /// The rows' actions of the submenu that is open, if one is.
+    pub(crate) fn open_viewport_submenu_actions(&self) -> Option<Vec<ViewportMenuAction>> {
+        let row = cce_ui::widget::context_menu::submenu::parent_row()?;
+        match self.viewport_menu_actions.get(row)? {
+            ViewportMenuAction::Submenu(page) => Some(self.viewport_menu_rows_of(Some(*page)).1),
+            _ => None,
+        }
     }
 
     /// The slider a viewport menu row carries, read from the live value —
@@ -5239,15 +5245,25 @@ impl State {
     /// drag. `persist` saves state.kdl, which the wheel does per step and a
     /// drag does once, on the release.
     pub(crate) fn drain_viewport_menu_slider(&mut self, persist: bool) -> bool {
-        let Some((idx, v)) = cce_ui::widget::context_menu::take_slider_change() else {
+        use cce_ui::widget::context_menu;
+        // The menu's own sliders, then those of its open submenu.
+        let changed = context_menu::take_slider_change()
+            .and_then(|(idx, v)| Some((self.viewport_menu_actions.get(idx).copied()?, v)))
+            .or_else(|| {
+                let (idx, v) = context_menu::submenu::take_slider_change()?;
+                Some((self.open_viewport_submenu_actions()?.get(idx).copied()?, v))
+            });
+        let Some((action, v)) = changed else {
             if persist {
                 self.save_settings();
             }
             return false;
         };
-        if let Some(action) = self.viewport_menu_actions.get(idx).copied() {
+        {
             if self.viewport_menu_slider(action).is_some() {
                 self.land_viewport_menu_slider(action, v);
+                // What a submenu is opened with next time is what is so now.
+                self.fill_viewport_submenus();
                 if persist {
                     self.save_settings();
                 }
@@ -5270,11 +5286,20 @@ impl State {
     /// test can read it. Marks are the ●/○ the pin rows and the network menu
     /// use.
     pub(crate) fn viewport_menu_rows(&self) -> (Vec<String>, Vec<ViewportMenuAction>) {
-        self.viewport_menu_rows_of(self.viewport_menu_page)
+        self.viewport_menu_rows_of(None)
+    }
+
+    /// Open the submenu of the open viewport menu that holds `action`, as a
+    /// press on its row does, and hand back that submenu's actions.
+    #[cfg(test)]
+    pub(crate) fn open_viewport_submenu_with(&mut self, action: ViewportMenuAction) -> Vec<ViewportMenuAction> {
+        let page = self.viewport_menu_page_of(action).expect("a submenu holds the row");
+        self.run_viewport_menu_action(ViewportMenuAction::Submenu(page));
+        self.open_viewport_submenu_actions().expect("the submenu opened")
     }
 
     /// The page of the viewport menu that holds `action`, `None` for the
-    /// top level's own rows — how a test finds a row without knowing the
+    /// menu's own rows — how a test finds a row without knowing the
     /// menu's layout.
     #[cfg(test)]
     pub(crate) fn viewport_menu_page_of(&self, action: ViewportMenuAction) -> Option<ViewportMenuPage> {
@@ -5283,11 +5308,11 @@ impl State {
             .find(|page| self.viewport_menu_rows_of(Some(*page)).1.contains(&action))
     }
 
-    /// The rows of one page of the viewport menu. The top level holds what
-    /// is DONE (framing), the guides, and a row into each page; the STYLE
-    /// page holds how the geometry is drawn (wireframe, then surface) and
-    /// the MARKERS page what is drawn on it (the points, then the overlays
-    /// of each element class). A page's first row names it and leads back.
+    /// The rows of the viewport menu (`None`) or of one of its submenus.
+    /// The menu holds what is DONE (framing), the guides, and a row for each
+    /// submenu; the STYLE submenu holds how the geometry is drawn
+    /// (wireframe, then surface) and the MARKERS submenu what is drawn on
+    /// it (the points, then the overlays of each element class).
     pub(crate) fn viewport_menu_rows_of(&self, page: Option<ViewportMenuPage>) -> (Vec<String>, Vec<ViewportMenuAction>) {
         let mut options: Vec<String> = Vec::new();
         let mut actions: Vec<ViewportMenuAction> = Vec::new();
@@ -5306,10 +5331,7 @@ impl State {
 
         match page {
             Some(ViewportMenuPage::Style) => {
-                row(&mut options, &mut actions, format!("‹ {}", ViewportMenuPage::Style.label()), ViewportMenuAction::Back);
-
                 // Wireframe.
-                row(&mut options, &mut actions, "-".into(), sep);
                 toggle(&mut options, &mut actions, "toggle_wireframe");
                 row(&mut options, &mut actions, "Wire Thickness".into(), ViewportMenuAction::WireThicknessSlider);
                 row(&mut options, &mut actions, "Wire Opacity".into(), ViewportMenuAction::WireOpacitySlider);
@@ -5323,11 +5345,8 @@ impl State {
                 return (options, actions);
             }
             Some(ViewportMenuPage::Markers) => {
-                row(&mut options, &mut actions, format!("‹ {}", ViewportMenuPage::Markers.label()), ViewportMenuAction::Back);
-
                 // The Render points, the group markers sized off them, and
                 // the pull arrows' length, the other selection feedback.
-                row(&mut options, &mut actions, "-".into(), sep);
                 toggle(&mut options, &mut actions, "toggle_render_points");
                 row(&mut options, &mut actions, "Point Size".into(), ViewportMenuAction::PointSizeSlider);
                 row(&mut options, &mut actions, "Group Marker Scale".into(), ViewportMenuAction::GroupMarkerScaleSlider);
@@ -5358,10 +5377,10 @@ impl State {
         toggle(&mut options, &mut actions, "toggle_grid");
         toggle(&mut options, &mut actions, "toggle_origin");
 
-        // The display settings, a page each.
+        // The display settings, a submenu each; the menu marks the rows.
         row(&mut options, &mut actions, "-".into(), sep);
         for page in [ViewportMenuPage::Style, ViewportMenuPage::Markers] {
-            row(&mut options, &mut actions, format!("{} ›", page.label()), ViewportMenuAction::Submenu(page));
+            row(&mut options, &mut actions, page.label().into(), ViewportMenuAction::Submenu(page));
         }
 
         // The viewport's editor binding, as a radio group: follow the active
@@ -5396,8 +5415,13 @@ impl State {
             ViewportMenuAction::OneToOne => {
                 self.view_one_to_one();
             }
-            ViewportMenuAction::Submenu(page) => self.show_viewport_menu_page(Some(page)),
-            ViewportMenuAction::Back => self.show_viewport_menu_page(None),
+            // The menu opens a submenu under the pointer by itself; a press
+            // on the row opens it for a pointer that has not moved.
+            ViewportMenuAction::Submenu(_) => {
+                if let Some(i) = self.viewport_menu_actions.iter().position(|a| *a == action) {
+                    cce_ui::widget::context_menu::open_submenu(i);
+                }
+            }
             ViewportMenuAction::PinFollow => {
                 self.viewport_pin = None;
                 self.rebuild_scene_geometry();
@@ -5434,7 +5458,6 @@ impl State {
         cce_ui::widget::context_menu::hide();
         self.viewport_menu_active = false;
         self.viewport_menu_actions.clear();
-        self.viewport_menu_page = None;
     }
 
     /// Route a left press while the viewport menu is open — same contract as
@@ -5449,24 +5472,25 @@ impl State {
             self.drain_viewport_menu_slider(false);
             return true;
         }
-        if cce_ui::widget::context_menu::hit_test(self.cursor_x, self.cursor_y) {
-            let idx = cce_ui::widget::context_menu::row_at(self.cursor_x, self.cursor_y);
+        use cce_ui::widget::context_menu;
+        // A row of the open submenu runs and the menu stays up: a submenu
+        // is a panel of settings, opened to set several, and a switch that
+        // closed the menu would cost a right-click and a row per setting.
+        // It is filled again for its marks.
+        if context_menu::submenu::hit_test(self.cursor_x, self.cursor_y) {
+            let picked = context_menu::submenu::row_at(self.cursor_x, self.cursor_y)
+                .and_then(|i| self.open_viewport_submenu_actions()?.get(i).copied());
+            if let Some(action) = picked {
+                self.run_viewport_menu_action(action);
+                self.fill_viewport_submenus();
+            }
+            return true;
+        }
+        if context_menu::hit_test(self.cursor_x, self.cursor_y) {
+            let idx = context_menu::row_at(self.cursor_x, self.cursor_y);
             let picked = idx.and_then(|i| self.viewport_menu_actions.get(i).copied());
-            // Turning the page keeps the menu up, and so does a row of a
-            // page: a page is a panel of settings, entered to set several,
-            // and a switch that closed it would cost a right-click and a
-            // row per setting. It is shown again for its marks.
-            let page = self.viewport_menu_page;
             match picked {
-                Some(a @ (ViewportMenuAction::Submenu(_) | ViewportMenuAction::Back)) => {
-                    self.run_viewport_menu_action(a);
-                }
-                picked if page.is_some() => {
-                    if let Some(action) = picked {
-                        self.run_viewport_menu_action(action);
-                        self.show_viewport_menu_page(page);
-                    }
-                }
+                Some(a @ ViewportMenuAction::Submenu(_)) => self.run_viewport_menu_action(a),
                 picked => {
                     self.close_viewport_menu();
                     if let Some(action) = picked {
@@ -6470,8 +6494,6 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             node_menu_actions: Vec::new(),
             viewport_menu_active: false,
             viewport_menu_actions: Vec::new(),
-            viewport_menu_page: None,
-            viewport_menu_anchor: (0.0, 0.0),
             param_menu_active: false,
             param_menu_actions: Vec::new(),
             param_menu_target: None,
diff --git a/src/main.rs b/src/main.rs
index 91fb864..cb8d679 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1869,15 +1869,15 @@ mod tests {
         state.cursor_x = 300.0;
         state.cursor_y = 200.0;
         state.open_viewport_context_menu();
-        state.show_viewport_menu_page(state.viewport_menu_page_of(A::OpacitySlider));
+        let sub_actions = state.open_viewport_submenu_with(A::OpacitySlider);
         assert!(state.viewport_menu_open());
-        let i = state.viewport_menu_actions.iter().position(|a| *a == A::OpacitySlider).expect("an Opacity row");
-        let s = context_menu::slider(i).expect("the row is a slider");
+        let i = sub_actions.iter().position(|a| *a == A::OpacitySlider).expect("an Opacity row");
+        let s = context_menu::submenu::slider(i).expect("the row is a slider");
         assert_eq!((s.value, s.min, s.max, s.step), (50.0, 0.0, 100.0, 5.0));
 
         // Wheel over the row: one notch up is 5% more, saved, menu still up.
-        state.cursor_y = context_menu::row_y(i) + context_menu::ROW_H * 0.5;
-        state.cursor_x = context_menu::x() + 20.0;
+        state.cursor_y = context_menu::submenu::row_y(i) + context_menu::ROW_H * 0.5;
+        state.cursor_x = context_menu::submenu::x() + 20.0;
         let wheel = |state: &mut State, notches: f32| {
             state.handle_event(&WindowEvent::MouseWheel { delta: MouseScrollDelta::LineDelta(0.0, notches) })
         };
@@ -1890,7 +1890,7 @@ mod tests {
         assert!((state.geo_opacity - 0.40).abs() < 1e-6);
 
         // Press on the band's right end: 100%, live; drag back; release.
-        let band = context_menu::CONTEXT_MENU.with(|m| m.borrow().slider_band(i));
+        let band = context_menu::SUBMENU.with(|m| m.borrow().slider_band(i));
         state.cursor_x = band.x + band.width - 1.0;
         state.handle_event(&WindowEvent::MouseInput { state: ElementState::Pressed, button: MouseButton::Left });
         assert!((state.geo_opacity - 1.0).abs() < 1e-6, "{}", state.geo_opacity);
@@ -1960,20 +1960,20 @@ mod tests {
         state.cursor_x = 300.0;
         state.cursor_y = 200.0;
         state.open_viewport_context_menu();
-        state.show_viewport_menu_page(state.viewport_menu_page_of(A::WireOpacitySlider));
-        let acts = state.viewport_menu_actions.clone();
+        let sub_actions = state.open_viewport_submenu_with(A::WireOpacitySlider);
+        let acts = sub_actions.clone();
         let i = acts.iter().position(|a| *a == A::WireOpacitySlider).expect("a Wire Opacity row");
-        let sl = context_menu::slider(i).expect("the row is a slider");
+        let sl = context_menu::submenu::slider(i).expect("the row is a slider");
         assert_eq!((sl.value, sl.min, sl.max, sl.step), (50.0, 0.0, 100.0, 5.0));
 
-        state.cursor_x = context_menu::x() + 20.0;
-        state.cursor_y = context_menu::row_y(i) + context_menu::ROW_H * 0.5;
+        state.cursor_x = context_menu::submenu::x() + 20.0;
+        state.cursor_y = context_menu::submenu::row_y(i) + context_menu::ROW_H * 0.5;
         state.handle_event(&WindowEvent::MouseWheel { delta: MouseScrollDelta::LineDelta(0.0, -2.0) });
         assert!((state.wire_opacity - 0.40).abs() < 1e-6, "{}", state.wire_opacity);
         assert!((state.geo_opacity - 0.5).abs() < 1e-6, "the polygon opacity moved with the wires'");
 
         let j = acts.iter().position(|a| *a == A::OpacitySlider).expect("an Opacity row");
-        state.cursor_y = context_menu::row_y(j) + context_menu::ROW_H * 0.5;
+        state.cursor_y = context_menu::submenu::row_y(j) + context_menu::ROW_H * 0.5;
         state.handle_event(&WindowEvent::MouseWheel { delta: MouseScrollDelta::LineDelta(0.0, 2.0) });
         assert!((state.geo_opacity - 0.60).abs() < 1e-6, "{}", state.geo_opacity);
         assert!((state.wire_opacity - 0.40).abs() < 1e-6, "the wires' opacity moved with the polygons'");
@@ -2022,11 +2022,10 @@ mod tests {
         assert_eq!((d.render.wire_color, d.render.wire_opacity), ([0.0; 3], 1.0));
     }
 
-    /// The viewport menu's top level holds framing, the guides and a row
-    /// into each page; the display rows are on the pages, in groups a
-    /// separator apart — Style the wireframe then the surface, Markers the
-    /// points then each element class's overlays — every one on exactly one
-    /// page, under a first row that leads back.
+    /// The viewport menu holds framing, the guides and a row for each
+    /// submenu; the display rows are in the submenus, in groups a separator
+    /// apart — Style the wireframe then the surface, Markers the points then
+    /// each element class's overlays — every one in exactly one.
     #[test]
     fn the_viewport_menu_groups_its_display_rows() {
         use crate::app::{ViewportMenuAction as A, ViewportMenuPage as P};
@@ -2048,7 +2047,6 @@ mod tests {
         assert_eq!(
             groups(Some(P::Style)),
             vec![
-                vec![A::Back],
                 vec![A::Command("toggle_wireframe"), A::WireThicknessSlider, A::WireOpacitySlider],
                 vec![A::Shading(false), A::Shading(true), A::OpacitySlider, A::Command("toggle_show_occluded")],
             ]
@@ -2056,7 +2054,6 @@ mod tests {
         assert_eq!(
             groups(Some(P::Markers)),
             vec![
-                vec![A::Back],
                 vec![A::Command("toggle_render_points"), A::PointSizeSlider, A::GroupMarkerScaleSlider, A::PullArrowScaleSlider],
                 vec![
                     A::Command("toggle_point_markers"),
@@ -2070,47 +2067,64 @@ mod tests {
         );
     }
 
-    /// A submenu is the menu showing another page where it stands: a press
-    /// on a page's row turns to it and keeps the menu up, a switch on a
-    /// page flips, re-marks and keeps it up, the first row leads back, and
-    /// a top-level row closes the menu as it always did.
+    /// The display settings fly out beside their row: the pointer on the
+    /// row opens its submenu, beside the menu and level with the row; a
+    /// switch in it flips, is re-marked where it stands and leaves both
+    /// menus up; the pointer on the other row swaps the submenu; a row of
+    /// the menu itself closes everything, as it always did.
     #[test]
-    fn the_viewport_menu_turns_to_a_page_and_stays_open() {
+    fn the_viewport_menu_flies_its_settings_out_beside_it() {
         use crate::app::{ViewportMenuAction as A, ViewportMenuPage as P};
-        use crate::window::WindowEvent;
-        use cce_ui::widget::{context_menu, ElementState, MouseButton};
+        use crate::window::{LocalPosition, WindowEvent};
+        use cce_ui::widget::{context_menu, context_menu::submenu, ElementState, MouseButton};
         let mut state = State::new(false);
         state.show_prim_numbers = false;
         state.cursor_x = 300.0;
         state.cursor_y = 200.0;
         state.open_viewport_context_menu();
-        let press = |state: &mut State, action: A| {
-            let i = state.viewport_menu_actions.iter().position(|a| *a == action).unwrap_or_else(|| panic!("no {action:?} row"));
-            state.cursor_x = context_menu::x() + 20.0;
-            state.cursor_y = context_menu::row_y(i) + context_menu::ROW_H * 0.5;
+        let row_of = |state: &State, a: A| state.viewport_menu_actions.iter().position(|x| *x == a).unwrap_or_else(|| panic!("no {a:?} row"));
+        let move_to = |state: &mut State, x: f32, y: f32| {
+            state.cursor_x = x;
+            state.cursor_y = y;
+            state.handle_event(&WindowEvent::CursorMoved { position: LocalPosition { x: x as f64, y: y as f64 } });
+        };
+        let press = |state: &mut State| {
             state.handle_event(&WindowEvent::MouseInput { state: ElementState::Pressed, button: MouseButton::Left });
             state.handle_event(&WindowEvent::MouseInput { state: ElementState::Released, button: MouseButton::Left });
         };
-        let at = (context_menu::x(), context_menu::y());
-        press(&mut state, A::Submenu(P::Markers));
-        assert!(state.viewport_menu_open(), "turning the page keeps the menu up");
-        assert_eq!(state.viewport_menu_page, Some(P::Markers));
-        assert_eq!((context_menu::x(), context_menu::y()), at, "where it was");
-        assert_eq!(state.viewport_menu_actions[0], A::Back);
-
-        press(&mut state, A::Command("toggle_prim_numbers"));
+        let (style, markers) = (row_of(&state, A::Submenu(P::Style)), row_of(&state, A::Submenu(P::Markers)));
+        assert!(context_menu::has_submenu(style) && context_menu::has_submenu(markers));
+        assert!(!submenu::is_visible(), "nothing flies out until a row is pointed at");
+
+        move_to(&mut state, context_menu::x() + 20.0, context_menu::row_y(markers) + 12.0);
+        assert_eq!(submenu::parent_row(), Some(markers));
+        assert_eq!(submenu::x(), context_menu::x() + context_menu::w(), "beside the menu");
+        assert_eq!(submenu::row_y(0), context_menu::row_y(markers), "level with its row");
+        let actions = state.open_viewport_submenu_actions().unwrap();
+        let prims = actions.iter().position(|a| *a == A::Command("toggle_prim_numbers")).unwrap();
+        assert!(submenu::options()[prims].starts_with('○'));
+
+        // Into the submenu: its row hovers, and the menu's row stays lit.
+        move_to(&mut state, submenu::x() + 20.0, submenu::row_y(prims) + 12.0);
+        assert_eq!(submenu::hovered_item(), Some(prims));
+        assert_eq!(context_menu::hovered_item(), Some(markers));
+        assert_eq!(context_menu::row_at(state.cursor_x, state.cursor_y), None, "a row of the submenu is not a row of the menu");
+        press(&mut state);
         assert!(state.show_prim_numbers, "the switch flipped");
-        assert!(state.viewport_menu_open(), "and the page is still up");
-        let i = state.viewport_menu_actions.iter().position(|a| *a == A::Command("toggle_prim_numbers")).unwrap();
-        assert!(context_menu::options()[i].starts_with('●'), "re-marked: {}", context_menu::options()[i]);
+        assert!(state.viewport_menu_open() && submenu::is_visible(), "and both are still up");
+        assert!(submenu::options()[prims].starts_with('●'), "re-marked: {}", submenu::options()[prims]);
 
-        press(&mut state, A::Back);
-        assert!(state.viewport_menu_open());
-        assert_eq!(state.viewport_menu_page, None);
-        assert_eq!(state.viewport_menu_actions[0], A::FrameAll);
+        // Straight back onto the other row: its submenu takes the place.
+        move_to(&mut state, context_menu::x() + 20.0, context_menu::row_y(style) + 12.0);
+        assert_eq!(submenu::parent_row(), Some(style));
+        assert_eq!(state.open_viewport_submenu_actions().unwrap()[0], A::Command("toggle_wireframe"));
 
-        press(&mut state, A::Command("toggle_grid"));
-        assert!(!state.viewport_menu_open(), "a top-level row closes the menu");
+        // A row with no submenu closes it; picking that row closes the menu.
+        let grid = row_of(&state, A::Command("toggle_grid"));
+        move_to(&mut state, context_menu::x() + 20.0, context_menu::row_y(grid) + 12.0);
+        assert!(!submenu::is_visible());
+        press(&mut state);
+        assert!(!state.viewport_menu_open(), "a row of the menu closes it");
     }
 
     /// The primitive and vertex overlays read off the scene as the points'
@@ -2313,26 +2327,26 @@ mod tests {
         state.cursor_x = 300.0;
         state.cursor_y = 200.0;
         state.open_viewport_context_menu();
-        state.show_viewport_menu_page(state.viewport_menu_page_of(A::WireThicknessSlider));
-        let acts = state.viewport_menu_actions.clone();
+        let sub_actions = state.open_viewport_submenu_with(A::WireThicknessSlider);
+        let acts = sub_actions.clone();
         let i = acts.iter().position(|a| *a == A::WireThicknessSlider).expect("a Wire Thickness row");
         assert_eq!(acts[i - 1], A::Command("toggle_wireframe"), "it sits under Show Wireframe");
-        let sl = context_menu::slider(i).expect("the row is a slider");
+        let sl = context_menu::submenu::slider(i).expect("the row is a slider");
         assert_eq!((sl.value, sl.min, sl.max, sl.step), (2.0, 1.0, 8.0, 0.5));
         // Every other slider the menu carries answers the same table.
         for (k, a) in acts.iter().enumerate() {
-            assert_eq!(context_menu::slider(k).is_some(), state.viewport_menu_slider(*a).is_some(), "row {k} {a:?}");
+            assert_eq!(context_menu::submenu::slider(k).is_some(), state.viewport_menu_slider(*a).is_some(), "row {k} {a:?}");
         }
 
-        state.cursor_x = context_menu::x() + 20.0;
-        state.cursor_y = context_menu::row_y(i) + context_menu::ROW_H * 0.5;
+        state.cursor_x = context_menu::submenu::x() + 20.0;
+        state.cursor_y = context_menu::submenu::row_y(i) + context_menu::ROW_H * 0.5;
         state.handle_event(&WindowEvent::MouseWheel { delta: MouseScrollDelta::LineDelta(0.0, 2.0) });
         assert!((state.wire_width - 3.0).abs() < 1e-6, "{}", state.wire_width);
         let kdl = fs::read_to_string(crate::app::DesignSettings::file_path()).expect("saved");
         assert!(kdl.contains("wire_width (f64)3") || kdl.contains("wire_width 3"), "persisted: {kdl}");
         assert!(state.viewport_menu_open());
 
-        let band = context_menu::CONTEXT_MENU.with(|m| m.borrow().slider_band(i));
+        let band = context_menu::SUBMENU.with(|m| m.borrow().slider_band(i));
         state.cursor_x = band.x + 1.0;
         state.handle_event(&WindowEvent::MouseInput { state: ElementState::Pressed, button: MouseButton::Left });
         state.handle_event(&WindowEvent::MouseInput { state: ElementState::Released, button: MouseButton::Left });
@@ -2408,14 +2422,14 @@ mod tests {
         state.cursor_x = 300.0;
         state.cursor_y = 200.0;
         state.open_viewport_context_menu();
-        state.show_viewport_menu_page(state.viewport_menu_page_of(A::PointSizeSlider));
-        let i = state.viewport_menu_actions.iter().position(|a| *a == A::PointSizeSlider).expect("a Point Size row");
-        let sl = context_menu::slider(i).expect("a slider");
+        let sub_actions = state.open_viewport_submenu_with(A::PointSizeSlider);
+        let i = sub_actions.iter().position(|a| *a == A::PointSizeSlider).expect("a Point Size row");
+        let sl = context_menu::submenu::slider(i).expect("a slider");
         assert_eq!((sl.min, sl.max, sl.step, sl.decimals), (0.0, 0.1, 0.005, 3));
         assert!((sl.value - 0.02).abs() < 1e-6);
 
-        state.cursor_x = context_menu::x() + 20.0;
-        state.cursor_y = context_menu::row_y(i) + context_menu::ROW_H * 0.5;
+        state.cursor_x = context_menu::submenu::x() + 20.0;
+        state.cursor_y = context_menu::submenu::row_y(i) + context_menu::ROW_H * 0.5;
         state.handle_event(&WindowEvent::MouseWheel { delta: MouseScrollDelta::LineDelta(0.0, 2.0) });
         assert!((state.point_size - 0.03).abs() < 1e-5, "{}", state.point_size);
         assert!((radius(&state) - 0.06).abs() < 1e-4, "the markers re-sized: {}", radius(&state));
@@ -2451,15 +2465,15 @@ mod tests {
         state.cursor_x = 300.0;
         state.cursor_y = 200.0;
         state.open_viewport_context_menu();
-        state.show_viewport_menu_page(state.viewport_menu_page_of(A::PointMarkerSizeSlider));
-        let i = state.viewport_menu_actions.iter().position(|a| *a == A::PointMarkerSizeSlider).expect("a Point Marker Size row");
-        assert_eq!(state.viewport_menu_actions[i - 1], A::Command("toggle_point_markers"), "it sits under its switch");
-        let sl = context_menu::slider(i).expect("a slider");
+        let sub_actions = state.open_viewport_submenu_with(A::PointMarkerSizeSlider);
+        let i = sub_actions.iter().position(|a| *a == A::PointMarkerSizeSlider).expect("a Point Marker Size row");
+        assert_eq!(sub_actions[i - 1], A::Command("toggle_point_markers"), "it sits under its switch");
+        let sl = context_menu::submenu::slider(i).expect("a slider");
         assert_eq!((sl.min, sl.max, sl.step), (0.005, 0.1, 0.005));
         assert!((sl.value - 0.02).abs() < 1e-6);
 
-        state.cursor_x = context_menu::x() + 20.0;
-        state.cursor_y = context_menu::row_y(i) + context_menu::ROW_H * 0.5;
+        state.cursor_x = context_menu::submenu::x() + 20.0;
+        state.cursor_y = context_menu::submenu::row_y(i) + context_menu::ROW_H * 0.5;
         state.handle_event(&WindowEvent::MouseWheel { delta: MouseScrollDelta::LineDelta(0.0, 4.0) });
         assert!((state.point_marker_size - 0.04).abs() < 1e-5, "{}", state.point_marker_size);
         assert_eq!(state.rt_geometry_version, version, "no rebuild ran");
@@ -2501,14 +2515,14 @@ mod tests {
         state.cursor_x = 300.0;
         state.cursor_y = 200.0;
         state.open_viewport_context_menu();
-        state.show_viewport_menu_page(state.viewport_menu_page_of(A::PullArrowScaleSlider));
-        let i = state.viewport_menu_actions.iter().position(|a| *a == A::PullArrowScaleSlider).expect("a Pull Arrow Scale row");
-        let sl = context_menu::slider(i).expect("a slider");
+        let sub_actions = state.open_viewport_submenu_with(A::PullArrowScaleSlider);
+        let i = sub_actions.iter().position(|a| *a == A::PullArrowScaleSlider).expect("a Pull Arrow Scale row");
+        let sl = context_menu::submenu::slider(i).expect("a slider");
         assert_eq!((sl.min, sl.max, sl.step, sl.suffix), (0.25, 10.0, 0.25, "x"));
         assert_eq!(sl.readout(), "1.00x");
 
-        state.cursor_x = context_menu::x() + 20.0;
-        state.cursor_y = context_menu::row_y(i) + context_menu::ROW_H * 0.5;
+        state.cursor_x = context_menu::submenu::x() + 20.0;
+        state.cursor_y = context_menu::submenu::row_y(i) + context_menu::ROW_H * 0.5;
         state.handle_event(&WindowEvent::MouseWheel { delta: MouseScrollDelta::LineDelta(0.0, 15.0) });
         let k = state.pull_arrow_scale;
         assert!(k > 1.0, "the wheel raised the scale: {k}");
@@ -2540,15 +2554,15 @@ mod tests {
         state.cursor_x = 300.0;
         state.cursor_y = 200.0;
         state.open_viewport_context_menu();
-        state.show_viewport_menu_page(state.viewport_menu_page_of(A::GroupMarkerScaleSlider));
-        let i = state.viewport_menu_actions.iter().position(|a| *a == A::GroupMarkerScaleSlider).expect("a Group Marker Scale row");
-        assert_eq!(state.viewport_menu_actions[i - 1], A::PointSizeSlider, "it sits under the size it multiplies");
-        let sl = context_menu::slider(i).expect("a slider");
+        let sub_actions = state.open_viewport_submenu_with(A::GroupMarkerScaleSlider);
+        let i = sub_actions.iter().position(|a| *a == A::GroupMarkerScaleSlider).expect("a Group Marker Scale row");
+        assert_eq!(sub_actions[i - 1], A::PointSizeSlider, "it sits under the size it multiplies");
+        let sl = context_menu::submenu::slider(i).expect("a slider");
         assert_eq!((sl.min, sl.max, sl.step, sl.suffix), (0.5, 4.0, 0.05, "x"));
         assert_eq!(sl.readout(), "1.25x");
 
-        state.cursor_x = context_menu::x() + 20.0;
-        state.cursor_y = context_menu::row_y(i) + context_menu::ROW_H * 0.5;
+        state.cursor_x = context_menu::submenu::x() + 20.0;
+        state.cursor_y = context_menu::submenu::row_y(i) + context_menu::ROW_H * 0.5;
         state.handle_event(&WindowEvent::MouseWheel { delta: MouseScrollDelta::LineDelta(0.0, 15.0) });
         assert!((state.group_marker_scale - 2.0).abs() < 1e-5, "{}", state.group_marker_scale);
         assert!((radius(&state) - 0.04).abs() < 1e-5, "the markers re-sized: {}", radius(&state));