graphic design tool
git clone https://git.lucas.co/cce-designer.git
feat(visualizers): edit the attribute visualizers in the params HUD
Attribute Visualizers open in the params HUD instead of the dialog: a
Visualizer dropdown picks the one edited, Add and Delete beside it, its
settings as ordinary rows (the Visualize node's names and show_when
conditions), and Done back to the node. The rows are a pseudo-node's
parameters, so the HUD's own controls serve them; picking another node
also hands the HUD back. The viewport menu's row is a plain row now.
Removed with the dialog's visualizer modes: Mode::Visualizers /
VisualizerEdit, State::vis_editing, the visualizer row builders, and the
dialog's two-slider Float2 control, which only the Manual Range used.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 83 ++++++++---------
src/app.rs | 30 ++++++-
src/dialog.rs | 253 +++-------------------------------------------------
src/main.rs | 242 +++++++++++++++----------------------------------
src/menu_page.rs | 7 +-
src/visualizer.rs | 262 +++++++++++++++++++++++++++++++-----------------------
6 files changed, 296 insertions(+), 581 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 0f3d504..7237529 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -2849,16 +2849,16 @@ viewport menu's Style and Markers flew a second menu out on hover while the
other rows below swapped the plate on a click — some with a Back row, most
with no way back — two gestures for one idea.
-The page rows: the viewport menu's **Style** and **Markers** (pages of rows)
-and **Attribute Visualizers** (the dialog); the network menu's **Add Node**
+The page rows: the viewport menu's **Style** and **Markers** (pages of rows;
+its **Attribute Visualizers** row was one too, into the dialog, until
+2026-10-06 — it is a plain row now, opening them in the params HUD); the
+network menu's **Add Node**
(the dialog); the plate rows' **Add Tab** (a page, wherever the plate rows
are); the node menu's **Rename** (the dialog). Inside the dialog the rows
that turn it into another list are marked `›` in the chord column and take
the forward swipe too (`dialog::dialog_row_leads`): the palette's Group
-Markers and Attribute Visualizers, a visualizer and Add Visualizer. The
-mark rides the row's chord TEXT (cce-ui's `PAGE_MARK`), and a label that
-begins with `BACK_MARK` (the visualizer's Back to Visualizers) wears the
-other; the dialog's row painter draws them as the `chevron-right` /
+Markers. The mark rides the row's chord TEXT (cce-ui's `PAGE_MARK`), and a
+label that begins with `BACK_MARK` wears the other; the dialog's row painter draws them as the `chevron-right` /
`chevron-left` glyphs, as the toolkit menu does, never as the characters
(since 2026-10-05, the cce-icons rule: every symbol the app draws is a
glyph). The menus' `● ` / `○ ` switch marks are cce-ui's `MARK_ON` /
@@ -2882,16 +2882,15 @@ glyph). The menus' `● ` / `○ ` switch marks are cce-ui's `MARK_ON` /
`State::dialog_trail`, the modes it turned through while up — a mode
opened while the dialog is up keeps the plate where it stands and puts
the mode it leaves on the trail, and turning to the trail's last (by a
- swipe back, Escape out of a visualizer or its Back row) takes it off.
- The palette's Group Markers and Attribute Visualizers rows now run with
- the palette still up, so it is on the trail. Opened afresh, the dialog
+ swipe back) takes it off. The palette's Group Markers row runs with the
+ palette still up, so it is on the trail. Opened afresh, the dialog
has neither. A swipe back with neither does nothing.
- **Every turn is animated** (cce-ui's `TURN_MS`, 180 ms): a page of rows
by cce-ui itself; the dialog by `dialog::DialogTurn` the same way — the
plate grows from the menu just put down (`open_dialog_from` reads its
size off the hidden context menu), and a mode turned to while it is up
- (Group Markers, a visualizer and back) slides its rows in from the side
+ (Group Markers and back) slides its rows in from the side
it came from at the plate's own size. The render arm paints a turning
dialog's content aside and replays it moved, clipped and faded
(cce-ui's `Prim::faded`, geometry and text alike), its text bounded by
@@ -3511,25 +3510,31 @@ the later over the earlier, as a chain of Visualize nodes composites.
"not in the scene" in the list; it is kept, since the scene it was made
for may come back. Not in `--thumbnail` or `--export`, which have no
display settings.
-- **They are edited in the dialog**, two modes: `Mode::Visualizers` (the
- `attribute_visualizers` command — the palette, and a row of the viewport
- menu under its pages, a page row turning the menu into the list) lists them, a switch each, and Add Visualizer;
- a press on a row's SWITCH turns it on or off and a press on the rest of
- the row, or Enter, opens it (`Dialog::activated_on_control`,
- `take_dialog_pick_at`). `Mode::VisualizerEdit` (`State::vis_editing`) is
- that visualizer's settings as rows — the Visualize node's `show_when`
- applied, so Ramp's rows or Vector's; Attribute and Group are choices
- over the scene's attributes and groups; a Manual range is one
- `Control::Float2` row (since 2026-10-06; two slider rows, From and To,
- until then) over the attribute's range in the scene with a quarter to
- spare — then Delete Visualizer and Back. Escape goes back to the list. Rows
- re-read IN PLACE (`refresh_dialog_rows_in_place`, selection and scroll
- kept), since switching the mode swaps rows under the one selected.
- A new visualizer starts on the scene's first attribute that is not
- `P`, `Cd` or `N`.
-
-`attribute_visualizers_are_edited_in_the_dialog_and_shown_on_the_scene`
-drives the dialog end to end.
+- **They are edited in the params HUD** (since 2026-10-06; until then in
+ the dialog, as its Visualizers and VisualizerEdit modes, which are gone
+ with the dialog's two-slider `Float2` control that only the Manual Range
+ used). The `attribute_visualizers` command — the palette's row, which
+ closes the palette, and a plain row of the viewport menu — sets
+ `State::vis_hud`, and the HUD shows, in place of the selected node's
+ parameters: a **Visualizer** dropdown picking the one edited (`#1 uv`),
+ **Add Visualizer** and **Delete Visualizer**, its settings — Enabled,
+ Attribute and Group as dropdowns over the scene's attributes and
+ groups, Mode, then Ramp, Range, a Manual Range `float2`, Blend and
+ Opacity, or Vector's Scale — and **Done**. The rows are a PSEUDO-NODE's
+ parameters (`visualizer_hud_params`), so `param_display`, the
+ `show_when` conditions, the separators and the controls are the HUD's
+ own; the write-back (`sync_visualizer_hud_back`, ahead of the node path
+ in `sync_parameters_to_project`) turns each changed row into the edit it
+ names, re-reading the rows when they change shape (another visualizer,
+ Mode, Range, Attribute, Add, Delete) and not during a slider drag, which
+ would drop the slider held. state.kdl is written at the frame
+ (`settings_save_pending`). Done hands the HUD back, and so does picking
+ another node (`vis_hud_from`, the node the HUD would have shown when it
+ opened). A new visualizer starts on the scene's first attribute that is
+ not `P`, `Cd` or `N`.
+
+`attribute_visualizers_are_edited_in_the_params_hud` drives the rows end
+to end.
### The Normal node writes point or vertex normals
@@ -3873,9 +3878,6 @@ rather than two:
A marked name the scene has no group for marks nothing and is kept, so
a group that comes and goes with a frame does not lose its switch.
`the_group_markers_dialog_marks_a_groups_points` is the test.
-- `Mode::Visualizers` / `Mode::VisualizerEdit` (the
- `attribute_visualizers` command) — the attribute visualizers and one
- visualizer's settings; see "Attribute visualizers".
- `Mode::AddNode` (**Tab**, in the network pane) — one list of node
templates, and a pick that instantiates at the grid cursor. Tab is what
opened it, so Tab closes it again. The query hint names the mode; there
@@ -3930,21 +3932,8 @@ selection stays where it was:
pane's) is one of these over `State::zoom_percent` (100 = Reset Zoom,
range the pitch limits), landing through `set_zoom_percent`, which zooms
about the cursor cell and re-reads the row, since `zoom` clamps.
-- `Float2` — two numbers over one range, `lo:hi`: the params pane's
- float2, as two sliders SIDE BY SIDE in the row (the rows are one
- height, so not stacked as the pane stacks them), each with its readout
- ahead of it. The control begins `FLOAT2_W` (two `SLIDER_W`) in from the
- row's right end and the label is clipped short of it. Each end is worked
- as a slider is — a press on its band jumps and drags it, the wheel over
- it turns it — and the arrows nudge the FIRST end, shift and the arrows
- the second. One `Slider` stamp paints both. A change comes out of
- `take_float2_change` and lands through `land_dialog_float2`. Only the
- visualizer's Manual Range is one; state.kdl keeps it as `manual_range=lo:hi`,
- and a file from before, with `from=` and `to=`, reads as the range.
- `a_visualizers_manual_range_is_one_float2_row` drives it by pointer,
- wheel and keys.
-- `Choice` — a fixed set (World Unit, GPU, Node Wire Style, the
- visualizer settings): the PARAMS PANE'S DROPDOWN (since 2026-10-01,
+- `Choice` — a fixed set (World Unit, GPU, Node Wire Style): the PARAMS
+ PANE'S DROPDOWN (since 2026-10-01,
the toolkit `Dropdown`), in the control band the sliders and colour
wells use. Closed, a row draws `Dialog::dropdown_stamp` — one
`Dropdown` handed each row's options and selection as it is painted,
diff --git a/src/app.rs b/src/app.rs
index 2c0229a..1f679a5 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -3430,8 +3430,13 @@ pub struct State {
/// The attribute visualizers, applied in order to the displayed scene
/// (`crate::visualizer`). Persisted in the viewport block.
pub visualizers: Vec<crate::visualizer::Visualizer>,
- /// The visualizer the dialog's VisualizerEdit page is editing.
- pub vis_editing: Option<usize>,
+ /// The params HUD shows the attribute visualizers, editing visualizer
+ /// `i` (0 with none yet), in place of the selected node's parameters —
+ /// `visualizer::State::open_visualizers_hud`. `None` is the node.
+ pub vis_hud: Option<usize>,
+ /// What the HUD would have shown when the visualizers took it: picking
+ /// another node hands the HUD back to that node's parameters.
+ pub(crate) vis_hud_from: Option<ParamPaneTarget>,
/// The half-span the Attribute node's Value row runs over, with the
/// node it is for (`value_row_span`): kept between pane syncs so the row
/// re-scales only when its value leaves it.
@@ -4962,6 +4967,11 @@ impl State {
/// rows that were not loaded from the selected node are stale, and
/// writing nothing is the only right thing to do with them.
pub fn sync_parameters_to_project(&mut self) {
+ // The HUD showing the visualizers writes back to them, not a node.
+ if self.vis_hud.is_some() {
+ self.sync_visualizer_hud_back();
+ return;
+ }
let mut file_to_open = None;
if !self.is_detached_network && self.param_pane_source == self.param_pane_target() {
if let Some(slot_idx) = self.param_editor_selected() {
@@ -5316,7 +5326,7 @@ impl State {
self.reset_parameters();
}
"Group Markers" => self.open_group_markers_dialog(),
- "Attribute Visualizers" => self.open_visualizers_dialog(),
+ "Attribute Visualizers" => self.open_visualizers_hud(),
"Rename Node" => match self.selected_slots().first().copied() {
Some(slot) => self.open_rename_dialog(slot),
None => self.update_status_text("Select a node to rename."),
@@ -5562,6 +5572,17 @@ impl State {
}
pub fn sync_parameters_pane(&mut self) {
+ // The attribute visualizers, when the HUD shows them — until another
+ // node is picked, which takes the HUD back.
+ if self.vis_hud.is_some() && self.param_pane_target() != self.vis_hud_from {
+ self.vis_hud = None;
+ }
+ if self.vis_hud.is_some() && !self.is_detached_network {
+ let rows = param_display(&self.visualizer_hud_params());
+ self.param_mut().set_display_params(&rows);
+ self.param_pane_source = None;
+ return;
+ }
// Selection reads through the param-editor accessors: whichever
// network editor took the last node click feeds the pane, at ITS
// level — no matter the tab.
@@ -8734,7 +8755,8 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
show_vertex_normals: settings.viewport.show_vertex_normals,
marked_groups: Self::marked_groups_of(&settings.viewport.marked_groups),
visualizers: crate::visualizer::decode(&settings.viewport.visualizers),
- vis_editing: None,
+ vis_hud: None,
+ vis_hud_from: None,
value_row_span: None,
float2_spans: None,
scene_attributes: Vec::new(),
diff --git a/src/dialog.rs b/src/dialog.rs
index 4c3210e..bd8ee62 100644
--- a/src/dialog.rs
+++ b/src/dialog.rs
@@ -54,14 +54,6 @@ pub enum Mode {
/// The palette TURNS INTO this list, the way it turns into the node
/// list — one plate, one filter.
Groups,
- /// The `attribute_visualizers` command: the viewport's attribute
- /// visualizers (`crate::visualizer`), a switch each, and Add
- /// Visualizer. Picking a visualizer's row — off its switch — opens its
- /// settings.
- Visualizers,
- /// One visualizer's settings (`State::vis_editing`) as rows: its
- /// controls, Delete, and Back to the list, which Escape is too.
- VisualizerEdit,
}
/// The control a row carries, drawn over its right end and worked in place —
@@ -77,11 +69,6 @@ pub enum Control {
/// over the band turns it, a press on the band jumps to the pointer.
/// A `dec` of zero snaps to whole numbers — the spinbox shape.
Slider { value: f32, min: f32, max: f32, dec: usize, step: f32, suffix: &'static str },
- /// Two numbers over one range, `lo:hi` — the params pane's `float2`:
- /// two sliders side by side, each with its readout ahead of it, worked
- /// as a [`Control::Slider`] is, an end at a time. The arrows nudge the
- /// first end, shift and the arrows the second.
- Float2 { values: [f32; 2], min: f32, max: f32, dec: usize, step: f32 },
/// One of a fixed set: picking the row opens a dropdown of the options
/// under its value ([`State::open_dialog_dropdown`]); the arrows step
/// either way without it.
@@ -98,7 +85,6 @@ impl Control {
match self {
Control::Toggle(on) => if *on { "true" } else { "false" }.to_string(),
Control::Slider { value, dec, .. } => format!("{:.*}", dec, value),
- Control::Float2 { values, dec, .. } => format!("{:.*}:{:.*}", dec, values[0], dec, values[1]),
Control::Choice { options, index } => options.get(*index).cloned().unwrap_or_default(),
Control::Color { hex, .. } => hex.clone(),
}
@@ -108,7 +94,7 @@ impl Control {
/// readout shows none.
fn quantize(&self, v: f32) -> f32 {
match self {
- Control::Slider { min, max, dec, .. } | Control::Float2 { min, max, dec, .. } => {
+ Control::Slider { min, max, dec, .. } => {
let v = v.clamp(min.min(*max), max.max(*min));
if *dec == 0 { v.round() } else { v }
}
@@ -121,7 +107,6 @@ impl Control {
fn slider_part(&self, k: usize) -> Option<(f32, f32, f32)> {
match self {
Control::Slider { value, min, max, .. } if k == 0 => Some((*value, *min, *max)),
- Control::Float2 { values, min, max, .. } if k < 2 => Some((values[k], *min, *max)),
_ => None,
}
}
@@ -131,7 +116,6 @@ impl Control {
let q = self.quantize(v);
match self {
Control::Slider { value, .. } if k == 0 => *value = q,
- Control::Float2 { values, .. } if k < 2 => values[k] = q,
_ => {}
}
}
@@ -186,7 +170,7 @@ impl Row {
/// Whether the row's control is made of sliders: a slider, or a
/// float2's two.
fn is_slider(&self) -> bool {
- matches!(self.control, Some(Control::Slider { .. } | Control::Float2 { .. }))
+ matches!(self.control, Some(Control::Slider { .. }))
}
fn is_color(&self) -> bool {
@@ -234,11 +218,6 @@ pub const SLIDER_W: f32 = 180.0;
/// would paint and never show.
const READOUT_W: f32 = 60.0;
const READOUT_GAP: f32 = 8.0;
-/// How far in from the row's right end a float2 row's control begins —
-/// twice a slider's, since it is two sliders, each with its readout.
-const FLOAT2_W: f32 = 2.0 * SLIDER_W;
-/// The gap between a float2's two halves.
-const FLOAT2_GAP: f32 = 12.0;
/// Gap between the query line and the list.
const GAP: f32 = 8.0;
@@ -355,9 +334,6 @@ pub struct Dialog {
hover_ctl: Option<usize>,
/// A row the pointer activated, drained by the app.
activated: Option<String>,
- /// Whether the press that set `activated` was on the row's control
- /// (its switch) rather than on the rest of the row.
- activated_control: bool,
/// The choice rows' closed dropdowns: one toolkit `Dropdown`, handed
/// each row's options and selection as the row is painted, as the
/// toggle and slider stamps are.
@@ -411,8 +387,6 @@ pub struct Dialog {
slider_track: (f32, f32),
/// The row and value the pointer moved a slider to, drained by the app.
slider_change: Option<(String, f32)>,
- /// The row and values the pointer moved a float2 to, drained by the app.
- float2_change: Option<(String, [f32; 2])>,
/// One toolkit colour selector per colour row, by row id — real widgets,
/// not stamps, because each carries state of its own: a hex edit in
/// progress, a picker process streaming values. Kept across
@@ -446,7 +420,6 @@ impl Dialog {
hover_row: None,
hover_ctl: None,
activated: None,
- activated_control: false,
dropdown_stamp: RefCell::new(Dropdown::new(Vec::new(), 0)),
dropdown: Box::new(Dropdown::new(Vec::new(), 0)),
dropdown_row: None,
@@ -459,7 +432,6 @@ impl Dialog {
hover_part: 0,
slider_track: (0.0, 1.0),
slider_change: None,
- float2_change: None,
colors: Vec::new(),
color_changes: Vec::new(),
});
@@ -664,12 +636,6 @@ impl Dialog {
self.activated.take()
}
- /// Whether the last activation was a press on the row's control —
- /// read before [`Self::take_activated`] or after, it is the same press.
- pub fn activated_on_control(&self) -> bool {
- self.activated_control
- }
-
/// Put the scroll back where it was after a re-rank, clamped to the
/// list the rows now make.
pub fn restore_scroll_px(&mut self, px: f32) {
@@ -702,16 +668,11 @@ impl Dialog {
self.slider_change.take()
}
- pub fn take_float2_change(&mut self) -> Option<(String, [f32; 2])> {
- self.float2_change.take()
- }
-
/// Say what row `i`'s sliders now hold, for the app to land.
fn note_slider_change(&mut self, i: usize) {
let Some(row) = self.rows.get(i) else { return };
match row.control {
Some(Control::Slider { value, .. }) => self.slider_change = Some((row.id.clone(), value)),
- Some(Control::Float2 { values, .. }) => self.float2_change = Some((row.id.clone(), values)),
_ => {}
}
}
@@ -817,11 +778,6 @@ impl Dialog {
height: TOGGLE_H,
}),
Some(Control::Slider { .. }) => Some(self.slider_rect(r)),
- Some(Control::Float2 { .. }) => {
- let parts = self.float2_parts(r);
- let (a, b) = (parts[0].1, parts[1].1);
- Some(Rect { width: b.x + b.width - a.x, ..a })
- }
Some(Control::Color { .. }) => Some(self.slider_band_rect(r)),
Some(Control::Choice { .. }) => Some(self.slider_band_rect(r)),
None => None,
@@ -841,28 +797,11 @@ impl Dialog {
Rect { x: b.x - READOUT_W - READOUT_GAP, width: b.width + READOUT_W + READOUT_GAP, ..b }
}
- /// A float2 row's two sliders, each as (band, the band with its
- /// readout lane ahead of it): the slider control's whole span — from
- /// [`FLOAT2_W`] in from the row's right end out to the band's right
- /// edge — halved, with [`FLOAT2_GAP`] between the halves.
- fn float2_parts(&self, r: Rect) -> [(Rect, Rect); 2] {
- let band = self.slider_band_rect(r);
- let x0 = r.x + r.width - 8.0 - FLOAT2_W;
- let half = ((band.x + band.width - x0 - FLOAT2_GAP) * 0.5).max(READOUT_W + READOUT_GAP + 10.0);
- let part = |k: usize| {
- let whole = Rect { x: x0 + k as f32 * (half + FLOAT2_GAP), width: half, ..band };
- let lane = READOUT_W + READOUT_GAP;
- (Rect { x: whole.x + lane, width: half - lane, ..whole }, whole)
- };
- [part(0), part(1)]
- }
-
/// A slider row's sliders, each as (band, the whole control): one for a
/// slider, two for a float2.
fn slider_parts(&self, r: Rect, row: &Row) -> Vec<(Rect, Rect)> {
match row.control {
Some(Control::Slider { .. }) => vec![(self.slider_band_rect(r), self.slider_rect(r))],
- Some(Control::Float2 { .. }) => self.float2_parts(r).to_vec(),
_ => Vec::new(),
}
}
@@ -1102,8 +1041,6 @@ impl Paint for Dialog {
Mode::AddNode => "Add Node: type to filter nodes",
Mode::Rename => "Rename: type the node's name",
Mode::Groups => "Group Markers: type to filter groups",
- Mode::Visualizers => "Attribute Visualizers: type to filter",
- Mode::VisualizerEdit => "Visualizer: type to filter its settings",
},
q_w,
);
@@ -1145,7 +1082,6 @@ impl Paint for Dialog {
Mode::AddNode => "No matching node",
Mode::Rename => "No node to rename",
Mode::Groups => "No point group in the scene",
- Mode::Visualizers | Mode::VisualizerEdit => "No matching row",
};
ctx.text_with(empty, list.x + 8.0, ty, font_size, [0x70, 0x70, 0x7c], Some(family.clone()), own);
return;
@@ -1207,7 +1143,6 @@ impl Paint for Dialog {
let chord_right = r.x + r.width - 8.0 - ctl_col;
let label_right = match &row.control {
Some(Control::Choice { .. }) => self.slider_band_rect(r).x - 12.0,
- Some(Control::Float2 { .. }) => self.float2_parts(r)[0].1.x - 12.0,
_ => chord_right - if right_w > 0.0 { right_w + 12.0 } else { 0.0 },
};
let glyph_y = r.y + (r.height - chev) * 0.5;
@@ -1273,21 +1208,6 @@ impl Paint for Dialog {
own,
);
}
- Some(Control::Float2 { values, min, max, dec, .. }) => {
- // The Slider arm twice, an end each.
- for (k, (band, _)) in self.float2_parts(r).into_iter().enumerate() {
- {
- let mut stamp = self.slider_stamp.borrow_mut();
- stamp.set_range(*min, *max);
- stamp.set_scaled_value(values[k]);
- stamp.set_hovered(self.hover_ctl == Some(i) && self.hover_part == k);
- Paint::paint(&**stamp, band, ctx);
- }
- let readout = format!("{:.*}", *dec, values[k]);
- let rw = display::measure_text_width(&readout, &family, font_size);
- ctx.text_with(readout, band.x - READOUT_GAP - rw, ty, font_size, label_color, Some(family.clone()), own);
- }
- }
Some(Control::Color { .. }) => {
if let Some(sel) = self.color_selector(&row.id) {
let band = self.slider_band_rect(r);
@@ -1446,9 +1366,6 @@ impl Input for Dialog {
return true;
}
self.activated = self.rows.get(i).map(|r| r.id.clone());
- self.activated_control = r
- .and_then(|r| self.control_rect(r, &self.rows[i]))
- .is_some_and(|c| *x >= c.x && *x < c.x + c.width && *y >= c.y && *y < c.y + c.height);
return true;
}
// Inside the plate but on no control: consumed anyway, so the
@@ -1561,7 +1478,6 @@ impl Input for Dialog {
use crate::app::State;
use crate::command::Context;
-use crate::visualizer::{VIS_ADD_ROW_ID, VIS_FIELD_PREFIX, VIS_ROW_PREFIX};
use crate::slots::DIALOG_IDX;
/// The list's zoom row: not a registry command but a control — a slider
@@ -1737,13 +1653,13 @@ pub use cce_ui::widget::context_menu::{BACK_MARK, PAGE_MARK};
const GLYPH_GAP: f32 = 6.0;
/// Whether a row of the dialog in `mode` turns it into another list: the
-/// palette's Group Markers and Attribute Visualizers, a visualizer of the
-/// list and Add Visualizer. A press runs it, as before; a side swipe forward
-/// with the pointer on it does too.
+/// palette's Group Markers. A press runs it, as before; a side swipe forward
+/// with the pointer on it does too. (Attribute Visualizers led into the
+/// dialog's visualizer list until 2026-10-06; they are edited in the params
+/// HUD now, and the row closes the palette and opens them there.)
pub fn dialog_row_leads(mode: Mode, id: &str) -> bool {
match mode {
- Mode::Commands => matches!(id, "group_markers" | "attribute_visualizers"),
- Mode::Visualizers => id.starts_with(VIS_ROW_PREFIX),
+ Mode::Commands => matches!(id, "group_markers"),
_ => false,
}
}
@@ -1811,14 +1727,7 @@ impl State {
/// Turn the open dialog to `mode`, as the row or key that leads there
/// does.
pub(crate) fn open_dialog_mode(&mut self, mode: Mode) {
- match mode {
- Mode::Visualizers => self.open_visualizers_dialog(),
- Mode::VisualizerEdit => match self.vis_editing {
- Some(i) => self.open_visualizer_editor(i),
- None => self.open_visualizers_dialog(),
- },
- other => self.open_dialog_in(other),
- }
+ self.open_dialog_in(mode);
}
/// Wire the node just added (the level's last) into the wire from the
@@ -1867,35 +1776,6 @@ impl State {
self.open_dialog_in(Mode::Groups);
}
- /// The Attribute Visualizers list: the `attribute_visualizers` command.
- pub fn open_visualizers_dialog(&mut self) {
- self.vis_editing = None;
- self.open_dialog_in(Mode::Visualizers);
- }
-
- /// Visualizer `i`'s settings, in the same plate — the list turned into
- /// the one visualizer, as the palette turns into Group Markers.
- pub fn open_visualizer_editor(&mut self, i: usize) {
- if i >= self.visualizers.len() {
- return;
- }
- self.vis_editing = Some(i);
- self.open_dialog_in(Mode::VisualizerEdit);
- }
-
- /// Rebuild the rows for a change that adds or drops some — Mode flipping
- /// Ramp's rows for Vector's — keeping the selected row and the scroll,
- /// as a control edited in place does.
- fn refresh_dialog_rows_in_place(&mut self) {
- let selected = self.slots.dialog.selected_id().map(str::to_string);
- let scroll = self.slots.dialog.scroll_px;
- self.refresh_dialog_rows();
- if let Some(i) = selected.and_then(|id| self.slots.dialog.rows.iter().position(|r| r.id == id)) {
- self.slots.dialog.selected = i;
- }
- self.slots.dialog.restore_scroll_px(scroll);
- }
-
/// What renaming the dialog's node to `typed` would do: the name it
/// has and the one it would get, or why not. None when the node is
/// gone.
@@ -1954,8 +1834,6 @@ impl State {
Mode::AddNode => "Add Node: type to filter, Enter adds at the cursor, Escape closes.",
Mode::Rename => "Rename: type the name, Enter renames, Escape closes.",
Mode::Groups => "Group Markers: Enter or a click marks a group's points in the scene, Escape closes.",
- Mode::Visualizers => "Attribute Visualizers: a switch turns one on or off, Enter or a click edits it, Escape closes.",
- Mode::VisualizerEdit => "Visualizer: change a setting in place; Escape goes back to the list.",
});
}
@@ -2116,18 +1994,6 @@ impl State {
}
rows
}
- // The visualizers, then one visualizer's settings, ranked by
- // label as every list is.
- Mode::Visualizers | Mode::VisualizerEdit => {
- let all = if self.slots.dialog.mode == Mode::Visualizers {
- self.visualizer_rows()
- } else {
- self.vis_editing.map(|i| self.visualizer_edit_rows(i)).unwrap_or_default()
- };
- let labels: Vec<&str> = all.iter().map(|r| r.label.as_str()).collect();
- let order = crate::command::fuzzy_rank(&query, &labels);
- order.into_iter().map(|i| all[i].clone()).collect()
- }
// The scene's point groups, ranked by name, a switch each and the
// member count in the chord column.
Mode::Groups => {
@@ -2312,12 +2178,6 @@ impl State {
/// moved, and re-ranking would throw the selection back to the top of a
/// list the user is still working down.
pub(crate) fn refresh_dialog_controls(&mut self) {
- if matches!(self.slots.dialog.mode, Mode::Visualizers | Mode::VisualizerEdit) {
- // Labels follow the settings ("mass — Ramp, Heat"), and the edit
- // page's rows follow its Mode and Range, so these re-read whole.
- self.refresh_dialog_rows_in_place();
- return;
- }
if self.slots.dialog.mode == Mode::Groups {
let ids: Vec<String> = self.slots.dialog.rows.iter().map(|r| r.id.clone()).collect();
for id in ids {
@@ -2661,13 +2521,7 @@ impl State {
}
match &event.logical_key {
Key::Named(NamedKey::Escape) => {
- // A visualizer's settings are a page of the list: Escape
- // goes back to it, and from there closes.
- if self.slots.dialog.mode == Mode::VisualizerEdit {
- self.open_visualizers_dialog();
- } else {
- self.close_dialog();
- }
+ self.close_dialog();
return true;
}
Key::Named(NamedKey::Tab) => {
@@ -2751,13 +2605,6 @@ impl State {
let Some(v) = self.slots.dialog.rows[i].slider_value() else { return };
self.land_dialog_slider(&id, v);
}
- Control::Float2 { values, step, .. } => {
- let i = self.slots.dialog.selected;
- let k = self.modifiers.shift_key() as usize;
- self.slots.dialog.set_slider_part(i, k, values[k] + dir as f32 * step);
- let Some(Control::Float2 { values, .. }) = self.slots.dialog.selected_control().cloned() else { return };
- self.land_dialog_float2(&id, values);
- }
Control::Choice { options, index } => {
if options.is_empty() {
return;
@@ -2771,15 +2618,11 @@ impl State {
}
/// A choice row's new option, from its dropdown or an arrow key: a
- /// setting row writes its setting, a visualizer's row its field.
+ /// setting row writes its setting.
pub(crate) fn land_dialog_choice(&mut self, id: &str, value: &str) {
if let Some(label) = id.strip_prefix(SETTING_ROW_PREFIX) {
let label = label.to_string();
self.apply_setting(&label, value);
- } else if let (Some(field), Some(i)) = (id.strip_prefix(VIS_FIELD_PREFIX), self.vis_editing) {
- let field = field.to_string();
- self.set_visualizer_field(i, &field, value, true);
- self.refresh_dialog_controls();
}
}
@@ -2930,32 +2773,7 @@ impl State {
/// arrow key is a single landing and saves at once, as the menu's wheel
/// does. A spin row lands its whole number over the row's unit. A row the landing
/// does not know falls through to `apply_setting`.
- /// A float2 row's new values, from the pointer or an arrow key. Only a
- /// visualizer's Manual Range is one; it lands as its sliders do.
- pub(crate) fn land_dialog_float2(&mut self, id: &str, v: [f32; 2]) {
- let (Some(field), Some(i)) = (id.strip_prefix(VIS_FIELD_PREFIX), self.vis_editing) else { return };
- let field = field.to_string();
- let save = !self.slots.dialog.slider_dragging();
- self.set_visualizer_field(i, &field, &format!("{}:{}", v[0], v[1]), save);
- if let Some(row) = self.visualizer_edit_rows(i).into_iter().find(|r| r.id == id) {
- self.slots.dialog.set_control(id, row.control);
- }
- }
-
pub(crate) fn land_dialog_slider(&mut self, id: &str, v: f32) {
- if let (Some(field), Some(i)) = (id.strip_prefix(VIS_FIELD_PREFIX), self.vis_editing) {
- // In place: the rows do not change under a drag, and a rebuild
- // would drop the scroll it is being dragged at. The scene is
- // re-presented, not re-evaluated; state.kdl is written on the
- // release, or now for a wheel notch or an arrow.
- let field = field.to_string();
- let save = !self.slots.dialog.slider_dragging();
- self.set_visualizer_field(i, &field, &v.to_string(), save);
- if let Some(row) = self.visualizer_edit_rows(i).into_iter().find(|r| r.id == id) {
- self.slots.dialog.set_control(id, row.control);
- }
- return;
- }
if id == ZOOM_ROW_ID {
self.set_zoom_percent(v);
} else if let Some(s) = setting_of_row(id) {
@@ -3000,52 +2818,10 @@ impl State {
/// does nothing; the controls re-read, and the selection stays where it
/// was — by Enter or by a click, since both arrive here.
pub(crate) fn take_dialog_pick(&mut self, id: String) {
- self.take_dialog_pick_at(id, false);
- }
-
- /// [`Self::take_dialog_pick`], told whether the press was on the row's
- /// CONTROL: in the visualizer list the switch turns a visualizer on or
- /// off, and the rest of the row opens it.
- pub(crate) fn take_dialog_pick_at(&mut self, id: String, on_control: bool) {
let mode = self.slots.dialog.mode;
if mode == Mode::Commands && id == ZOOM_ROW_ID {
return;
}
- if mode == Mode::Visualizers {
- if id == VIS_ADD_ROW_ID {
- let i = self.add_visualizer();
- self.open_visualizer_editor(i);
- } else if let Some(i) = id.strip_prefix(VIS_ROW_PREFIX).and_then(|i| i.parse::<usize>().ok()) {
- if on_control {
- let on = !self.visualizers.get(i).is_some_and(|v| v.enabled);
- self.set_visualizer_enabled(i, on);
- self.refresh_dialog_controls();
- } else {
- self.open_visualizer_editor(i);
- }
- }
- return;
- }
- if mode == Mode::VisualizerEdit {
- let (Some(field), Some(i)) = (id.strip_prefix(VIS_FIELD_PREFIX), self.vis_editing) else { return };
- match field {
- "back" => self.open_visualizers_dialog(),
- "delete" => {
- self.delete_visualizer(i);
- self.open_visualizers_dialog();
- }
- _ => match self.slots.dialog.rows.iter().find(|r| r.id == id).and_then(|r| r.control.clone()) {
- Some(Control::Toggle(on)) => {
- let field = field.to_string();
- self.set_visualizer_field(i, &field, if on { "false" } else { "true" }, true);
- self.refresh_dialog_controls();
- }
- Some(Control::Choice { .. }) => self.open_dialog_dropdown(&id),
- _ => {}
- },
- }
- return;
- }
// A group's row is its switch: flipped in place, the list stays up.
if mode == Mode::Groups {
if let Some(name) = id.strip_prefix(GROUP_ROW_PREFIX) {
@@ -3130,7 +2906,7 @@ impl State {
Ok(said) | Err(said) => self.update_status_text(&said),
}
}
- Mode::Groups | Mode::Visualizers | Mode::VisualizerEdit => {}
+ Mode::Groups => {}
Mode::AddNode => {
// A free cursor cell a wire runs through is a place in that
// chain: the new node is spliced into the wire, as a node
@@ -3167,18 +2943,13 @@ impl State {
pub(crate) fn drain_dialog_clicks(&mut self) -> bool {
let mut changed = false;
if let Some(id) = self.slots.dialog.take_activated() {
- let on_control = self.slots.dialog.activated_on_control();
- self.take_dialog_pick_at(id, on_control);
+ self.take_dialog_pick(id);
changed = true;
}
if let Some((id, v)) = self.slots.dialog.take_slider_change() {
self.land_dialog_slider(&id, v);
changed = true;
}
- if let Some((id, v)) = self.slots.dialog.take_float2_change() {
- self.land_dialog_float2(&id, v);
- changed = true;
- }
for (id, hex) in self.slots.dialog.take_color_changes() {
if let Some(s) = setting_of_row(&id) {
self.apply_setting(s.label, &hex);
diff --git a/src/main.rs b/src/main.rs
index 4a9a8ec..adea491 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -2669,8 +2669,8 @@ mod tests {
/// it, turns the menu into the page where it stands, under a back band;
/// a switch on the page flips, is re-marked in place and leaves the page
/// up; a press on the band or a swipe back turns back. The visualizers'
- /// row turns the menu into the dialog, which a swipe back turns back
- /// into the menu at its corner. A row of the menu itself still closes it.
+ /// row is a row of the menu, not a page: it closes the menu and opens
+ /// them in the params HUD. A row of the menu itself still closes it.
#[test]
fn the_viewport_menu_turns_into_its_pages_and_back() {
use crate::app::{ViewportMenuAction as A, ViewportMenuPage as P};
@@ -2702,7 +2702,8 @@ mod tests {
};
let (style, markers) = (row_of(&state, A::Page(P::Style)), row_of(&state, A::Page(P::Markers)));
let vis = row_of(&state, A::Command("attribute_visualizers"));
- assert!([style, markers, vis].iter().all(|&i| context_menu::leads_to_page(i)), "the three rows are page rows");
+ assert!([style, markers].iter().all(|&i| context_menu::leads_to_page(i)), "the two pages are page rows");
+ assert!(!context_menu::leads_to_page(vis), "the visualizers are the HUD's, not a page");
assert!(!context_menu::leads_to_page(row_of(&state, A::FrameAll)));
move_to(&mut state, corner.0 + 20.0, context_menu::row_y(markers) + 12.0);
@@ -2734,24 +2735,21 @@ mod tests {
press(&mut state);
assert_eq!(state.viewport_menu_page, None, "a press on the back band turns back");
- // Into the dialog, and back.
- move_to(&mut state, corner.0 + 20.0, context_menu::row_y(vis) + 12.0);
- press(&mut state);
- assert!(!state.viewport_menu_open());
- assert!(state.dialog_visible() && state.slots.dialog.mode == crate::dialog::Mode::Visualizers);
- assert_eq!(state.slots.dialog.anchor, Some(corner), "the dialog took the menu's corner");
- let (dx, dy, _, _) = state.positions[crate::slots::DIALOG_IDX];
- move_to(&mut state, dx + 30.0, dy + 30.0);
- swipe(&mut state, 80.0);
- assert!(!state.dialog_visible(), "a swipe back closes the dialog");
- assert!(state.viewport_menu_open() && state.viewport_menu_page.is_none(), "and turns it back into the menu");
- assert_eq!((context_menu::x(), context_menu::y()), (dx, dy), "at the dialog's corner");
-
// A row of the menu itself runs and closes it.
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);
press(&mut state);
assert!(!state.viewport_menu_open(), "a row of the menu closes it");
+
+ // The visualizers' row too, and the HUD shows them.
+ state.cursor_x = 300.0;
+ state.cursor_y = 200.0;
+ state.open_viewport_context_menu();
+ let vis = row_of(&state, A::Command("attribute_visualizers"));
+ move_to(&mut state, context_menu::x() + 20.0, context_menu::row_y(vis) + 12.0);
+ press(&mut state);
+ assert!(!state.viewport_menu_open() && !state.dialog_visible());
+ assert!(state.vis_hud.is_some(), "the visualizers are in the params HUD");
}
/// The primitive and vertex overlays read off the scene as the points'
@@ -8359,12 +8357,11 @@ mod tests {
/// following the geometry through a rebuild and persisted with the
/// display settings.
/// Attribute visualizers: added, edited, switched and deleted in the
- /// dialog, applied to the displayed scene with no node in the graph,
- /// and kept with the display settings.
+ /// params HUD — the pane's own rows, worked as a node's are — applied
+ /// to the displayed scene with no node in the graph, kept with the
+ /// display settings, and handed back to the node by Done or a pick.
#[test]
- fn attribute_visualizers_are_edited_in_the_dialog_and_shown_on_the_scene() {
- use crate::dialog::Mode;
- use crate::visualizer::{VIS_ADD_ROW_ID, VIS_FIELD_PREFIX, VIS_ROW_PREFIX};
+ fn attribute_visualizers_are_edited_in_the_params_hud() {
let mut state = State::new(false);
state.resize(1600.0, 900.0, 1.0);
state.rebuild_positions();
@@ -8374,174 +8371,79 @@ mod tests {
let colours = |state: &State| state.rt_sphere_verts.iter().map(|v| v.color).collect::<Vec<_>>();
let plain = colours(&state);
let nodes_before = serde_json::to_string(&state.fs_root).unwrap();
+ let keys = |state: &State| state.param().node_params().into_iter().map(|r| r.0).filter(|k| !k.is_empty()).collect::<Vec<_>>();
+ // The pane reporting a row at a value, as a press or a drag does.
+ let pane = |state: &mut State, key: &str, value: &str| {
+ let rows: Vec<(String, String, String)> = state
+ .param()
+ .node_params()
+ .into_iter()
+ .map(|(k, v, t)| if k == key { (k, value.to_string(), t) } else { (k, v, t) })
+ .collect();
+ assert!(rows.iter().any(|r| r.0 == key), "no {key} row in {:?}", rows.iter().map(|r| &r.0).collect::<Vec<_>>());
+ state.param_mut().set_display_params(&rows);
+ state.sync_parameters_to_project();
+ };
- // The list, empty but for Add Visualizer.
+ // In the HUD, not the dialog: Add and Done, nothing to edit yet.
assert!(state.run_command("attribute_visualizers"));
- assert_eq!(state.slots.dialog.mode, Mode::Visualizers);
- assert_eq!(state.slots.dialog.rows.iter().map(|r| r.id.as_str()).collect::<Vec<_>>(), vec![VIS_ADD_ROW_ID]);
+ assert!(!state.dialog_visible());
+ assert_eq!(state.vis_hud, Some(0));
+ assert_eq!(keys(&state), ["Add Visualizer", "Done"]);
- // Add one: the list turns into its settings.
- state.take_dialog_pick(VIS_ADD_ROW_ID.to_string());
+ // Add one: it is edited, on the first attribute worth showing.
+ pane(&mut state, "Add Visualizer", "clicked");
assert_eq!(state.visualizers.len(), 1);
- assert_eq!(state.slots.dialog.mode, Mode::VisualizerEdit);
- assert_eq!(state.vis_editing, Some(0));
- let field = |f: &str| format!("{VIS_FIELD_PREFIX}{f}");
- let has = |state: &State, f: &str| state.slots.dialog.rows.iter().any(|r| r.id == field(f));
- assert!(has(&state, "ramp") && has(&state, "opacity") && !has(&state, "scale"), "Ramp's rows");
+ let k = keys(&state);
+ assert!(k.contains(&"Visualizer".to_string()) && k.contains(&"Ramp".to_string()) && !k.contains(&"Scale".to_string()), "Ramp's rows: {k:?}");
// On N, a Ramp recolours the scene, which gained no node.
- state.set_visualizer_field(0, "attribute", "N", true);
+ pane(&mut state, "Attribute", "N");
+ assert_eq!(state.visualizers[0].attribute, "N");
assert_ne!(colours(&state), plain, "the ramp is on the scene");
assert_eq!(serde_json::to_string(&state.fs_root).unwrap(), nodes_before, "no node in the graph");
- // Mode's dropdown, Down, Enter: Vector's rows, and lines drawn.
- let mode_row = state.slots.dialog.rows.iter().position(|r| r.id == field("mode")).unwrap();
- state.slots.dialog.selected = mode_row;
- let lines_before = state.overlay_normal_verts.len();
- state.dialog_key_input(&key_press(Key::Named(NamedKey::Enter)));
- assert!(state.dialog_dropdown_open(), "a choice opens its dropdown");
- assert_eq!(state.slots.dialog.dropdown.options, vec!["Ramp".to_string(), "Vector".to_string()]);
- assert_eq!(state.slots.dialog.dropdown.selected, 0);
- state.dialog_key_input(&key_press(Key::Named(NamedKey::ArrowDown)));
- state.dialog_key_input(&key_press(Key::Named(NamedKey::Enter)));
+ // Manual range: its float2 row appears, and takes two ends.
+ pane(&mut state, "Range", "Manual");
+ assert!(keys(&state).contains(&"Manual Range".to_string()));
+ pane(&mut state, "Manual Range", "-0.5:0.5");
+ assert_eq!(state.visualizers[0].manual_range, [-0.5, 0.5]);
+
+ // Vector: Scale's row in place of Ramp's.
+ pane(&mut state, "Mode", "Vector");
assert!(state.visualizers[0].is_vector());
- assert!(has(&state, "scale") && !has(&state, "ramp"), "Vector's rows");
- assert_eq!(state.slots.dialog.selected_id(), Some(field("mode").as_str()), "the selection stays");
- assert!(state.overlay_normal_verts.len() > lines_before, "the vectors are drawn");
+ let k = keys(&state);
+ assert!(k.contains(&"Scale".to_string()) && !k.contains(&"Ramp".to_string()), "Vector's rows: {k:?}");
assert_eq!(colours(&state), plain, "a Vector leaves the colours");
- // Escape goes back to the list, which names it.
- state.dialog_key_input(&key_press(Key::Named(NamedKey::Escape)));
- assert!(state.dialog_visible());
- assert_eq!(state.slots.dialog.mode, Mode::Visualizers);
- let id = format!("{VIS_ROW_PREFIX}0");
- let row = state.slots.dialog.rows.iter().find(|r| r.id == id).expect("its row").clone();
- assert!(row.label.starts_with("N — Vector"), "{}", row.label);
- assert_eq!(row.toggle(), Some(true));
+ // Its switch turns it off; a second one is picked from the list.
+ pane(&mut state, "Enabled", "false");
+ assert!(!state.visualizers[0].enabled);
+ pane(&mut state, "Add Visualizer", "clicked");
+ assert_eq!((state.visualizers.len(), state.vis_hud), (2, Some(1)));
+ pane(&mut state, "Visualizer", "#1 N");
+ assert_eq!(state.vis_hud, Some(0));
- // Kept with the display settings.
+ // Kept with the display settings, at the frame.
+ state.save_settings();
let kdl = fs::read_to_string(crate::app::DesignSettings::file_path()).expect("saved");
let kept = crate::visualizer::decode(&crate::app::DesignSettings::from_kdl_str(&kdl).viewport.visualizers);
assert_eq!(kept, state.visualizers);
- // Its switch turns it off; the rest of the row opens it.
- state.take_dialog_pick_at(id.clone(), true);
- assert!(!state.visualizers[0].enabled);
- assert_eq!(state.slots.dialog.mode, Mode::Visualizers);
- let lines_off = state.overlay_normal_verts.len();
- assert!(lines_off < lines_before + 1, "off draws nothing");
- state.take_dialog_pick_at(id, false);
- assert_eq!(state.slots.dialog.mode, Mode::VisualizerEdit);
-
- // Delete, and the list is empty again.
- state.take_dialog_pick(field("delete"));
- assert!(state.visualizers.is_empty());
- assert_eq!(state.slots.dialog.mode, Mode::Visualizers);
- assert_eq!(colours(&state), plain);
- state.close_dialog();
- }
+ // Delete, then Done: the HUD is the node's again.
+ pane(&mut state, "Delete Visualizer", "clicked");
+ assert_eq!(state.visualizers.len(), 1);
+ pane(&mut state, "Done", "clicked");
+ assert_eq!(state.vis_hud, None);
- /// A visualizer's Manual Range is the node's float2, and the dialog
- /// edits it as one: a row of two sliders side by side, each end worked
- /// on its own — pressed and dragged, turned by the wheel, nudged by the
- /// arrows (shift for the second). A drag lands live and saves on the
- /// release. A settings file from before, which kept From and To, reads
- /// as the range.
- #[test]
- fn a_visualizers_manual_range_is_one_float2_row() {
- use crate::dialog::Control;
- use crate::slots::DIALOG_IDX;
- use crate::visualizer::{decode, VIS_ADD_ROW_ID, VIS_FIELD_PREFIX};
- use crate::window::{LocalPosition, WindowEvent};
- use cce_ui::widget::{ElementState, MouseButton, MouseScrollDelta};
- let mut state = State::new(false);
- state.resize(1600.0, 900.0, 1.0);
- state.rebuild_positions();
- state.apply_layout();
- state.rebuild_scene_geometry();
+ // Picking a node also hands the HUD back.
assert!(state.run_command("attribute_visualizers"));
- state.take_dialog_pick(VIS_ADD_ROW_ID.to_string());
- state.set_visualizer_field(0, "attribute", "N", true);
- state.set_visualizer_field(0, "range", "Manual", true);
- state.refresh_dialog_controls();
- let (lo, hi) = {
- let a = state.scene_attributes.iter().find(|a| a.name == "N").unwrap();
- (a.min, a.max)
- };
- assert_eq!(state.visualizers[0].manual_range, [lo, hi], "Manual starts at what Auto showed");
-
- let id = format!("{VIS_FIELD_PREFIX}manual_range");
- let i = state.slots.dialog.rows.iter().position(|r| r.id == id).expect("a Manual Range row");
- let ids: Vec<&str> = state.slots.dialog.rows.iter().map(|r| r.id.as_str()).collect();
- assert!(!ids.iter().any(|r| r.ends_with(":from") || r.ends_with(":to")), "no From and To rows: {ids:?}");
- let control = |state: &State| match state.slots.dialog.rows[i].control.clone() {
- Some(Control::Float2 { values, min, max, .. }) => (values, min, max),
- other => panic!("not a float2: {other:?}"),
- };
- let (values, min, max) = control(&state);
- assert_eq!(values, [lo, hi]);
- let at = |t: f32| min + t * (max - min);
-
- let bands = state.slots.dialog.slider_bands(state.positions[DIALOG_IDX], i);
- assert_eq!(bands.len(), 2, "two sliders");
- assert!(bands[0].x + bands[0].width < bands[1].x, "side by side");
- let point = |state: &mut State, k: usize, t: f32| {
- let b = bands[k];
- let (px, py) = (b.x + b.width * t, b.y + b.height * 0.5);
- state.handle_event(&WindowEvent::CursorMoved { position: LocalPosition { x: px as f64, y: py as f64 } });
- };
- let saved = || {
- let kdl = fs::read_to_string(crate::app::DesignSettings::file_path()).expect("saved");
- decode(&crate::app::DesignSettings::from_kdl_str(&kdl).viewport.visualizers)[0].manual_range
- };
- let close = |a: f32, b: f32| (a - b).abs() < (max - min) * 0.02;
-
- // The first end, pressed and dragged: live, saved on the release.
- point(&mut state, 0, 0.25);
- state.handle_event(&WindowEvent::MouseInput { state: ElementState::Pressed, button: MouseButton::Left });
- assert!(state.slots.dialog.slider_dragging());
- point(&mut state, 0, 0.1);
- let [a, b] = state.visualizers[0].manual_range;
- assert!(close(a, at(0.1)) && b == hi, "{a} {b}");
- assert_eq!(control(&state).0, state.visualizers[0].manual_range, "the row shows what the visualizer holds");
- assert_eq!(saved(), [lo, hi], "not written mid-drag");
- state.handle_event(&WindowEvent::MouseInput { state: ElementState::Released, button: MouseButton::Left });
- assert_eq!(saved(), state.visualizers[0].manual_range, "written on the release");
-
- // The second end, on its own band; the first stays.
- point(&mut state, 1, 0.9);
- state.handle_event(&WindowEvent::MouseInput { state: ElementState::Pressed, button: MouseButton::Left });
- state.handle_event(&WindowEvent::MouseInput { state: ElementState::Released, button: MouseButton::Left });
- let [a2, b2] = state.visualizers[0].manual_range;
- assert!(a2 == a && close(b2, at(0.9)), "{a2} {b2}");
-
- // The wheel over an end turns that end, up being more.
- point(&mut state, 0, 0.5);
- state.handle_event(&WindowEvent::MouseWheel { delta: MouseScrollDelta::LineDelta(0.0, 1.0) });
- let [a3, b3] = state.visualizers[0].manual_range;
- assert!(a3 > a2 && b3 == b2, "{a3} {b3}");
-
- // The arrows nudge the first end; with shift, the second.
- state.slots.dialog.selected = i;
- state.dialog_key_input(&key_press(Key::Named(NamedKey::ArrowRight)));
- let [a4, b4] = state.visualizers[0].manual_range;
- assert!(a4 > a3 && b4 == b3, "{a4} {b4}");
- state.modifiers = ModifiersState { shift: true, ..Default::default() };
- state.dialog_key_input(&key_press(Key::Named(NamedKey::ArrowLeft)));
- state.modifiers = ModifiersState::default();
- let [a5, b5] = state.visualizers[0].manual_range;
- assert!(a5 == a4 && b5 < b4, "{a5} {b5}");
- assert_eq!(saved(), [a5, b5], "a key's landing saves at once");
-
- // What it draws is the node's Manual Range.
- let node = state.visualizers[0].as_node();
- assert_eq!(crate::geometry::node_param_vec2(&node, "manual_range", [0.0, 0.0]), [a5, b5]);
-
- // A settings file from before kept the two ends apart.
- let old = decode("attribute=N|mode=Ramp|range=Manual|from=2|to=5");
- assert_eq!(old[0].manual_range, [2.0, 5.0]);
- assert_eq!(decode(&crate::visualizer::encode(&old)), old);
- state.close_dialog();
+ let mut redraw = false;
+ let slot = geo(&state.fs_root).children.iter().position(|c| c.node_type == "sphere").unwrap();
+ state.apply_action(McpAction::Select { slot }, &mut redraw).unwrap();
+ state.sync_parameters_pane();
+ assert_eq!(state.vis_hud, None);
+ assert!(keys(&state).contains(&"Radius".to_string()), "the sphere's rows");
}
/// A visualizer is the Visualize node's reading: the same settings give
diff --git a/src/menu_page.rs b/src/menu_page.rs
index fd32600..b12487a 100644
--- a/src/menu_page.rs
+++ b/src/menu_page.rs
@@ -67,7 +67,7 @@ impl ViewportMenuAction {
/// Whether the row turns the menu: its pages, and the visualizers'
/// editor, which is the dialog.
pub fn leads_to_page(self) -> bool {
- matches!(self, ViewportMenuAction::Page(_) | ViewportMenuAction::Command("attribute_visualizers"))
+ matches!(self, ViewportMenuAction::Page(_))
}
}
@@ -195,11 +195,6 @@ impl State {
PageTurn::Into(n) => match origin {
MenuOrigin::Viewport => match self.viewport_menu_actions.get(n).copied() {
Some(ViewportMenuAction::Page(page)) => self.show_viewport_menu_page(Some(page), Some(at)),
- Some(ViewportMenuAction::Command("attribute_visualizers")) => {
- self.close_viewport_menu();
- self.vis_editing = None;
- self.open_dialog_from(Mode::Visualizers, origin, at);
- }
_ => return false,
},
MenuOrigin::Network => match self.network_menu_actions.get(n).copied() {
diff --git a/src/visualizer.rs b/src/visualizer.rs
index a4811f5..1692db8 100644
--- a/src/visualizer.rs
+++ b/src/visualizer.rs
@@ -14,13 +14,15 @@
//! `attribute_visualizers` command). Several apply in order, the later over
//! the earlier, as a chain of Visualize nodes composites.
//!
-//! They are edited in the dialog: [`crate::dialog::Mode::Visualizers`] lists
-//! them with a switch each, and [`crate::dialog::Mode::VisualizerEdit`] is
-//! one visualizer's settings as rows.
+//! They are edited in the params HUD (`State::vis_hud`, since 2026-10-06;
+//! they were the dialog's Visualizers / VisualizerEdit modes): a Visualizer
+//! dropdown picks the one edited, Add and Delete beside it, its settings as
+//! ordinary rows under it, and Done back to the selected node. The rows are
+//! a pseudo-node's parameters (`visualizer_hud_params`), so the HUD's own
+//! row building, `show_when` and controls serve them unchanged.
use crate::app::{FsNode, ParamDef, State};
use crate::detail::Detail;
-use crate::dialog::{Control, Row};
pub const MODES: [&str; 2] = ["Ramp", "Vector"];
pub const RAMPS: [&str; 4] = ["Grayscale", "Heat", "Spectrum", "Viridis"];
@@ -58,7 +60,7 @@ pub struct Visualizer {
/// `Manual` over Manual Range.
pub range: String,
/// The ramp's two ends under a Manual range: the node's float2, edited
- /// in the dialog as one (`Control::Float2`).
+ /// in the params HUD as one `float2` row.
pub manual_range: [f32; 2],
pub blend: String,
pub opacity: f32,
@@ -290,110 +292,11 @@ pub fn scene_attributes(geom: &Detail) -> Vec<SceneAttribute> {
out
}
-/// A visualizer in [`crate::dialog::Mode::Visualizers`]: the prefix, then
-/// its index.
-pub const VIS_ROW_PREFIX: &str = "vis:";
-/// The list's Add Visualizer row.
-pub const VIS_ADD_ROW_ID: &str = "vis:add";
-/// A setting of the visualizer being edited, in
-/// [`crate::dialog::Mode::VisualizerEdit`]: the prefix, then the field.
-pub const VIS_FIELD_PREFIX: &str = "visfield:";
-
/// The Attribute and Group choices' word for "nothing chosen".
const NO_ATTRIBUTE: &str = "(none)";
const ALL_POINTS: &str = "(all points)";
-fn choice(options: &[&str], current: &str) -> Control {
- let options: Vec<String> = options.iter().map(|s| s.to_string()).collect();
- let index = options.iter().position(|o| o.eq_ignore_ascii_case(current)).unwrap_or(0);
- Control::Choice { options, index }
-}
-
-fn row(field: &str, label: &str, control: Option<Control>) -> Row {
- Row { id: format!("{VIS_FIELD_PREFIX}{field}"), label: label.to_string(), chord: String::new(), control, truncate_head: false }
-}
-
impl State {
- /// The list's rows: each visualizer, a switch each, and Add Visualizer.
- /// A visualizer whose attribute the scene does not have says so in the
- /// chord column, since it draws nothing.
- pub(crate) fn visualizer_rows(&self) -> Vec<Row> {
- let mut rows: Vec<Row> = self
- .visualizers
- .iter()
- .enumerate()
- .map(|(i, v)| Row {
- id: format!("{VIS_ROW_PREFIX}{i}"),
- label: v.label(),
- chord: if self.scene_attributes.iter().any(|a| a.name == v.attribute) { String::new() } else { "not in the scene".to_string() },
- control: Some(Control::Toggle(v.enabled)),
- truncate_head: false,
- })
- .collect();
- rows.push(Row::plain(VIS_ADD_ROW_ID, "Add Visualizer", ""));
- rows
- }
-
- /// The rows of visualizer `i`'s settings, those that apply to its mode:
- /// Ramp's ramp, range and blend, Vector's scale — as the Visualize
- /// node's `show_when` conditions have them.
- pub(crate) fn visualizer_edit_rows(&self, i: usize) -> Vec<Row> {
- let Some(v) = self.visualizers.get(i) else { return Vec::new() };
- let mut rows = vec![row("enabled", "Enabled", Some(Control::Toggle(v.enabled)))];
-
- let mut attrs: Vec<String> = self.scene_attributes.iter().map(|a| a.name.clone()).collect();
- if !v.attribute.is_empty() && !attrs.contains(&v.attribute) {
- attrs.push(v.attribute.clone());
- }
- if attrs.is_empty() {
- attrs.push(NO_ATTRIBUTE.to_string());
- }
- let index = attrs.iter().position(|a| *a == v.attribute).unwrap_or(0);
- rows.push(row("attribute", "Attribute", Some(Control::Choice { options: attrs, index })));
- rows.push(row("mode", "Mode", Some(choice(&MODES, &v.mode))));
- if v.is_vector() {
- rows.push(row("scale", "Scale", Some(Control::Slider { value: v.scale, min: 0.0, max: 10.0f32.max(v.scale), dec: 2, step: 0.05, suffix: "" })));
- } else {
- rows.push(row("ramp", "Ramp", Some(choice(&RAMPS, &v.ramp))));
- rows.push(row("range", "Range", Some(choice(&RANGES, &v.range))));
- if v.is_manual() {
- let (lo, hi) = self.visualizer_value_range(v);
- let step = ((hi - lo) / 100.0).max(1e-4);
- rows.push(row("manual_range", "Manual Range", Some(Control::Float2 { values: v.manual_range, min: lo, max: hi, dec: 3, step })));
- }
- rows.push(row("blend", "Blend", Some(choice(&BLENDS, &v.blend))));
- rows.push(row("opacity", "Opacity", Some(Control::Slider { value: v.opacity, min: 0.0, max: 1.0, dec: 2, step: 0.05, suffix: "" })));
- }
-
- let mut groups: Vec<String> = vec![ALL_POINTS.to_string()];
- groups.extend(self.scene_groups.iter().map(|(g, _)| g.clone()));
- if !v.group.is_empty() && !groups.contains(&v.group) {
- groups.push(v.group.clone());
- }
- let index = if v.group.is_empty() { 0 } else { groups.iter().position(|g| *g == v.group).unwrap_or(0) };
- rows.push(row("group", "Group", Some(Control::Choice { options: groups, index })));
- rows.push(row("delete", "Delete Visualizer", None));
- rows.push(row("back", &format!("{} Back to Visualizers", crate::dialog::BACK_MARK), None));
- rows
- }
-
- /// The span a Manual range's sliders cover: the attribute's range in the
- /// scene with a quarter of it to spare each side, and the values in hand
- /// whatever they are.
- fn visualizer_value_range(&self, v: &Visualizer) -> (f32, f32) {
- let (mut lo, mut hi) = match self.scene_attributes.iter().find(|a| a.name == v.attribute) {
- Some(a) => {
- let pad = ((a.max - a.min) * 0.25).max(if a.max > a.min { 0.0 } else { 1.0 });
- (a.min - pad, a.max + pad)
- }
- None => (0.0, 1.0),
- };
- let [a, b] = v.manual_range;
- lo = lo.min(a).min(b);
- hi = hi.max(a).max(b);
- (lo, hi)
- }
-
/// Add a visualizer, on, on the first attribute of the scene that is
/// not its colour or its position — what is worth looking at — and
/// return its index.
@@ -418,15 +321,6 @@ impl State {
}
}
- pub(crate) fn set_visualizer_enabled(&mut self, i: usize, on: bool) {
- if let Some(v) = self.visualizers.get_mut(i) {
- if v.enabled != on {
- v.enabled = on;
- self.visualizers_changed(true);
- }
- }
- }
-
/// Write one of visualizer `i`'s settings from the text its row holds —
/// a choice's option, a toggle's `true`/`false`, a number.
pub(crate) fn set_visualizer_field(&mut self, i: usize, field: &str, value: &str, save: bool) {
@@ -463,3 +357,145 @@ impl State {
}
}
}
+
+/// The HUD's visualizer view: its rows' parameter names.
+const HUD_PICK: &str = "visualizer";
+const HUD_ADD: &str = "add_visualizer";
+const HUD_DELETE: &str = "delete_visualizer";
+const HUD_DONE: &str = "done";
+
+impl State {
+ /// Show the attribute visualizers in the params HUD, editing the first
+ /// (or `i`), in place of the selected node's parameters.
+ pub(crate) fn open_visualizers_hud(&mut self) {
+ self.vis_hud = Some(self.vis_hud.unwrap_or(0).min(self.visualizers.len().saturating_sub(1)));
+ self.vis_hud_from = self.param_pane_target();
+ if !self.show_parameters {
+ self.execute_menu_action("Show Parameters Pane");
+ }
+ self.sync_parameters_pane();
+ self.update_status_text("Attribute Visualizers: in the parameters; Done goes back to the node.");
+ }
+
+ /// Back to the selected node's parameters.
+ pub(crate) fn close_visualizers_hud(&mut self) {
+ if self.vis_hud.take().is_some() {
+ self.sync_parameters_pane();
+ }
+ }
+
+ /// What the picker calls visualizer `i`.
+ fn visualizer_pick_label(&self, i: usize) -> String {
+ let v = &self.visualizers[i];
+ let attr = if v.attribute.is_empty() { NO_ATTRIBUTE } else { &v.attribute };
+ format!("#{} {attr}", i + 1)
+ }
+
+ /// The HUD's visualizer view as a pseudo-node's parameters: the picker,
+ /// Add and Delete, the edited visualizer's settings under the Visualize
+ /// node's names and `show_when` conditions, and Done. With none yet,
+ /// Add and Done alone.
+ pub(crate) fn visualizer_hud_params(&self) -> Vec<ParamDef> {
+ let p = |name: &str, ty: String, text: String, label: &str| ParamDef::new(name.to_string(), ty, text).with_label(label);
+ let grouped = |mut d: ParamDef, g: &str| {
+ d.group = g.to_string();
+ d
+ };
+ let mut out = Vec::new();
+ let editing = self.vis_hud.filter(|&i| i < self.visualizers.len());
+ if let Some(i) = editing {
+ let labels: Vec<String> = (0..self.visualizers.len()).map(|k| self.visualizer_pick_label(k)).collect();
+ out.push(grouped(p(HUD_PICK, format!("choice:{}", labels.join(",")), labels[i].clone(), "Visualizer"), "pick"));
+ }
+ out.push(grouped(p(HUD_ADD, "button".into(), String::new(), "Add Visualizer"), "pick"));
+ if let Some(i) = editing {
+ out.push(grouped(p(HUD_DELETE, "button".into(), String::new(), "Delete Visualizer"), "pick"));
+ let v = &self.visualizers[i];
+ out.push(grouped(p("enabled", "toggle".into(), v.enabled.to_string(), "Enabled"), "settings"));
+ let mut attrs: Vec<String> = self.scene_attributes.iter().map(|a| a.name.clone()).collect();
+ if !v.attribute.is_empty() && !attrs.contains(&v.attribute) {
+ attrs.push(v.attribute.clone());
+ }
+ if attrs.is_empty() {
+ attrs.push(NO_ATTRIBUTE.to_string());
+ }
+ let attr = if v.attribute.is_empty() { attrs[0].clone() } else { v.attribute.clone() };
+ out.push(grouped(p("attribute", format!("choice:{}", attrs.join(",")), attr, "Attribute"), "settings"));
+ out.push(grouped(p("mode", format!("choice:{}", MODES.join(",")), v.mode.clone(), "Mode"), "settings"));
+ out.push(grouped(p("ramp", format!("choice:{}", RAMPS.join(",")), v.ramp.clone(), "Ramp").with_show_when("mode == Ramp"), "settings"));
+ out.push(grouped(p("range", format!("choice:{}", RANGES.join(",")), v.range.clone(), "Range").with_show_when("mode == Ramp"), "settings"));
+ out.push(grouped(
+ p("manual_range", "float2".into(), format!("{}:{}", v.manual_range[0], v.manual_range[1]), "Manual Range").with_show_when("mode == Ramp && range == Manual"),
+ "settings",
+ ));
+ out.push(grouped(p("blend", format!("choice:{}", BLENDS.join(",")), v.blend.clone(), "Blend").with_show_when("mode == Ramp"), "settings"));
+ out.push(grouped(p("opacity", "slider:0:1".into(), format!("{:.2}", v.opacity), "Opacity").with_show_when("mode == Ramp"), "settings"));
+ out.push(grouped(p("scale", format!("slider:0:{}", 10.0f32.max(v.scale)), format!("{:.2}", v.scale), "Scale").with_show_when("mode == Vector"), "settings"));
+ let mut groups: Vec<String> = vec![ALL_POINTS.to_string()];
+ groups.extend(self.scene_groups.iter().map(|(g, _)| g.clone()));
+ if !v.group.is_empty() && !groups.contains(&v.group) {
+ groups.push(v.group.clone());
+ }
+ let group = if v.group.is_empty() { ALL_POINTS.to_string() } else { v.group.clone() };
+ out.push(grouped(p("group", format!("choice:{}", groups.join(",")), group, "Group"), "where"));
+ }
+ out.push(grouped(p(HUD_DONE, "button".into(), String::new(), "Done"), "done"));
+ out
+ }
+
+ /// The HUD's rows written back into the visualizers: each row whose
+ /// value differs from what the view shows becomes the edit it names.
+ /// A change that adds or drops rows (another visualizer, Mode, Range,
+ /// Add, Delete) re-reads the view; a slider being dragged does not,
+ /// which would drop the slider held.
+ pub(crate) fn sync_visualizer_hud_back(&mut self) {
+ let shown = self.visualizer_hud_params();
+ let rows = self.param().node_params();
+ let mut reread = false;
+ let mut changed = false;
+ for (key, value, _) in rows {
+ let Some(def) = shown.iter().find(|d| d.shown_name() == key) else { continue };
+ if def.text() == value {
+ continue;
+ }
+ let i = self.vis_hud.unwrap_or(0);
+ match def.name.as_str() {
+ HUD_PICK => {
+ if let Some(k) = (0..self.visualizers.len()).find(|&k| self.visualizer_pick_label(k) == value) {
+ self.vis_hud = Some(k);
+ }
+ reread = true;
+ }
+ HUD_ADD if value == "clicked" => {
+ let k = self.add_visualizer();
+ self.vis_hud = Some(k);
+ reread = true;
+ }
+ HUD_DELETE if value == "clicked" => {
+ self.delete_visualizer(i);
+ self.vis_hud = Some(i.min(self.visualizers.len().saturating_sub(1)));
+ reread = true;
+ }
+ HUD_DONE if value == "clicked" => {
+ self.close_visualizers_hud();
+ return;
+ }
+ HUD_ADD | HUD_DELETE | HUD_DONE => {}
+ field => {
+ // Saved at the frame, not per motion of a drag.
+ self.set_visualizer_field(i, field, &value, false);
+ changed = true;
+ if matches!(field, "mode" | "range" | "attribute") {
+ reread = true;
+ }
+ }
+ }
+ }
+ if changed {
+ self.settings_save_pending = true;
+ }
+ if reread {
+ self.sync_parameters_pane();
+ }
+ }
+}