graphic design tool
git clone https://git.lucas.co/cce-designer.git
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));