graphic design tool
git clone https://git.lucas.co/cce-designer.git
feat: the parameters pane and spreadsheet can pin to one editor
Each pane gets its own pin (params_pin / spreadsheet_pin), set from a
radio group in its plate corner menu — shown only while a second
editor exists — resolving pin-else-active exactly like the viewport's.
The four params paths read the params binding; the spreadsheet refresh
(and the group markers with it) reads its own. Closing the second
editor clears pins to it; pins ride view_state beside the viewport's.
The bug this flushed out: sync_cursor_and_selection ran on every
changed event and, because both editors share the LEFT_MENUBAR focus
domain, a click in the second editor forced pane 1's selection to
whatever sat under pane 1's grid cursor — usually nothing — wiping the
selection a pinned pane was reading. The grid cursor is pane 1's
concept: the sync now gates on the primary being the active editor.
Co-Authored-By: Claude Fable 5 <[email protected]>
src/app.rs | 82 ++++++++++++++++++++++++++++++++++++++++++-----------
src/main.rs | 57 +++++++++++++++++++++++++++++++++++++
src/plate_corner.rs | 47 ++++++++++++++++++++++++++++++
src/project.rs | 46 +++++++++++++++++++-----------
4 files changed, 199 insertions(+), 33 deletions(-)
diff --git a/src/app.rs b/src/app.rs
index f346464..2738a42 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -189,6 +189,12 @@ pub struct ProjectViewState {
/// unresolvable follows the active editor.
#[serde(default)]
pub viewport_pin: Option<String>,
+ /// The parameters pane's pin, same encoding.
+ #[serde(default)]
+ pub params_pin: Option<String>,
+ /// The spreadsheet's pin, same encoding.
+ #[serde(default)]
+ pub spreadsheet_pin: Option<String>,
}
fn default_camera() -> String {
@@ -910,6 +916,12 @@ pub struct State {
/// CONTENT2_IDX) locks the scene to that editor's level regardless of
/// where clicks land. Set from the viewport's right-click menu.
pub viewport_pin: Option<usize>,
+ /// The parameters pane's pin — same shape, set from its plate's corner
+ /// menu. Pinned, the pane shows and edits the pinned editor's selection
+ /// no matter where clicks land.
+ pub params_pin: Option<usize>,
+ /// The spreadsheet's pin — same shape, set from its plate's corner menu.
+ pub spreadsheet_pin: Option<usize>,
pub node_clipboard: Option<FsNode>,
pub last_click: Option<(Instant, usize)>,
pub last_frame: Instant,
@@ -1610,8 +1622,10 @@ impl State {
let Some(from) = self.tab_dock_of_pane(slot) else { return };
// A closed editor cannot hold the viewport or the params pane.
if slot == crate::slots::NETWORK_PANEL2_IDX {
- if self.viewport_pin == Some(crate::slots::CONTENT2_IDX) {
- self.viewport_pin = None;
+ for pin in [&mut self.viewport_pin, &mut self.params_pin, &mut self.spreadsheet_pin] {
+ if *pin == Some(crate::slots::CONTENT2_IDX) {
+ *pin = None;
+ }
}
if self.param_editor == crate::slots::CONTENT2_IDX {
self.param_editor = CONTENT_IDX;
@@ -1812,9 +1826,9 @@ impl State {
node
}
- /// The selected slot in the editor the parameters pane follows.
- pub fn param_editor_selected(&self) -> Option<usize> {
- if self.param_editor == crate::slots::CONTENT2_IDX {
+ /// One editor's selected slot (CONTENT_IDX / CONTENT2_IDX).
+ pub fn editor_selected_of(&self, editor: usize) -> Option<usize> {
+ if editor == crate::slots::CONTENT2_IDX {
use cce_ui::widget::GraphController as _;
self.slots.content2.selected_node()
} else {
@@ -1822,18 +1836,18 @@ impl State {
}
}
- /// The level that editor is showing — where its selection resolves.
- pub fn param_editor_dir(&self) -> &FsNode {
- if self.param_editor == crate::slots::CONTENT2_IDX {
+ /// One editor's displayed level.
+ pub fn editor_dir_of(&self, editor: usize) -> &FsNode {
+ if editor == crate::slots::CONTENT2_IDX {
self.dir_at(&self.current_path2)
} else {
self.current_dir()
}
}
- /// [`Self::param_editor_dir`], mutable — the param writeback target.
- pub fn param_editor_dir_mut(&mut self) -> &mut FsNode {
- if self.param_editor == crate::slots::CONTENT2_IDX {
+ /// [`Self::editor_dir_of`], mutable.
+ pub fn editor_dir_of_mut(&mut self, editor: usize) -> &mut FsNode {
+ if editor == crate::slots::CONTENT2_IDX {
let p2 = self.current_path2.clone();
self.dir_at_mut(&p2)
} else {
@@ -1841,6 +1855,32 @@ impl State {
}
}
+ /// The editor the parameters pane is bound to: its pin, else the active
+ /// (last-clicked) editor.
+ pub fn params_editor(&self) -> usize {
+ self.params_pin.unwrap_or(self.param_editor)
+ }
+
+ /// The editor the spreadsheet is bound to — same resolution.
+ pub fn spreadsheet_editor(&self) -> usize {
+ self.spreadsheet_pin.unwrap_or(self.param_editor)
+ }
+
+ /// The selected slot in the editor the parameters pane follows.
+ pub fn param_editor_selected(&self) -> Option<usize> {
+ self.editor_selected_of(self.params_editor())
+ }
+
+ /// The level that editor is showing — where its selection resolves.
+ pub fn param_editor_dir(&self) -> &FsNode {
+ self.editor_dir_of(self.params_editor())
+ }
+
+ /// [`Self::param_editor_dir`], mutable — the param writeback target.
+ pub fn param_editor_dir_mut(&mut self) -> &mut FsNode {
+ self.editor_dir_of_mut(self.params_editor())
+ }
+
/// The editor whose level the VIEWPORT renders: the pin when set, else
/// the active (last-clicked) editor.
pub fn viewport_editor(&self) -> usize {
@@ -3221,13 +3261,14 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
let path_strs = self.current_path_names();
self.path_mut().set_path(&path_strs);
- // The spreadsheet (and the group markers below) follow the SAME
- // selection the parameters pane does: whichever editor took the
- // last node click, at its own level.
+ // The spreadsheet (and the group markers with it) read the
+ // SPREADSHEET's binding: its pin when set, else the active editor —
+ // exactly the parameters pane's rule with its own pin.
let mut selected_node = None;
if !self.is_detached_network {
- if let Some(slot_idx) = self.param_editor_selected() {
- let dir = self.param_editor_dir();
+ let se = self.spreadsheet_editor();
+ if let Some(slot_idx) = self.editor_selected_of(se) {
+ let dir = self.editor_dir_of(se);
if slot_idx < dir.children.len() {
selected_node = Some(&dir.children[slot_idx]);
}
@@ -3523,6 +3564,8 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
current_path2: Vec::new(),
param_editor: CONTENT_IDX,
viewport_pin: None,
+ params_pin: None,
+ spreadsheet_pin: None,
node_clipboard: None,
last_click: None,
last_frame: Instant::now(),
@@ -4695,7 +4738,12 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
}
pub fn sync_cursor_and_selection(&mut self) {
- if self.focused_pane != LEFT_MENUBAR_IDX {
+ // The grid cursor is PANE 1's concept: both network editors share
+ // the LEFT_MENUBAR focus domain, so without the param_editor gate a
+ // click in the second editor ran this and forced pane 1's selection
+ // to whatever sat under its cursor cell — wiping the selection a
+ // pinned params pane or spreadsheet was reading.
+ if self.focused_pane != LEFT_MENUBAR_IDX || self.param_editor != CONTENT_IDX {
return;
}
let dir = self.current_dir();
diff --git a/src/main.rs b/src/main.rs
index 2eb98e1..25425a2 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -701,6 +701,63 @@ mod tests {
}
+ /// Pinning: the spreadsheet bound to pane 1 keeps reading pane 1's
+ /// selection while clicks land in the second editor, and the cursor-
+ /// selection sync no longer wipes pane 1's selection on second-editor
+ /// interactions (the regression that made pins look broken).
+ #[test]
+ fn test_pane_pins_bind_selection_sources() {
+ use crate::app::Dock;
+ use crate::slots::{CONTENT2_IDX, CONTENT_IDX, LEFT_MENUBAR_IDX, NETWORK_PANEL2_IDX};
+ use cce_ui::widget::GraphController as _;
+ let mut state = State::new(false);
+ state.add_dock_tab(Dock::Left, NETWORK_PANEL2_IDX);
+
+ let sphere = state.fs_root.children.iter().position(|c| c.name == "Sphere 1").unwrap();
+ let camera = state.fs_root.children.iter().position(|c| c.name == "Camera 1").unwrap();
+
+ // Pane 1 selects the sphere; the spreadsheet pins to pane 1.
+ state.graph_mut().set_selected_node(Some(sphere));
+ state.spreadsheet_pin = Some(CONTENT_IDX);
+ // A click in the second editor selects the camera and takes the
+ // active-editor role.
+ state.slots.content2.set_selected_node(Some(camera));
+ state.param_editor = CONTENT2_IDX;
+
+ // The params pane (unpinned) follows the second editor...
+ assert_eq!(state.param_editor_selected(), Some(camera));
+ // ...the spreadsheet's binding stays on pane 1's sphere.
+ let se = state.spreadsheet_editor();
+ assert_eq!(se, CONTENT_IDX);
+ assert_eq!(state.editor_selected_of(se), Some(sphere));
+
+ // The cursor-selection sync must NOT wipe pane 1's selection while
+ // the second editor is active — the grid cursor is pane 1's concept.
+ state.focused_pane = LEFT_MENUBAR_IDX;
+ state.grid_cursor_col = -50;
+ state.grid_cursor_row = -50;
+ state.sync_cursor_and_selection();
+ assert_eq!(
+ state.graph().selected_node(),
+ Some(sphere),
+ "second-editor activity must not clear pane 1's selection"
+ );
+
+ // And the spreadsheet refresh keys off the pinned selection.
+ state.show_spreadsheet = true;
+ state.sync_nodes();
+ let sphere_id = state.fs_root.children[sphere].id.clone();
+ assert_eq!(
+ state.last_spreadsheet_node_name.as_deref(),
+ Some(sphere_id.as_str()),
+ "spreadsheet must refresh against the PINNED editor's selection"
+ );
+
+ // A params pin binds the pane the other way.
+ state.params_pin = Some(CONTENT_IDX);
+ assert_eq!(state.param_editor_selected(), Some(sphere));
+ }
+
/// Pane state rides save files: visibility through the meta→View subnet
/// params (synced at save, applied on load), collapse and splitter
/// proportions through view_state. A fresh State loading the file must
diff --git a/src/plate_corner.rs b/src/plate_corner.rs
index 9767c77..da2d34c 100644
--- a/src/plate_corner.rs
+++ b/src/plate_corner.rs
@@ -64,6 +64,10 @@ pub enum PlateMenuAction {
SplitTab,
/// Remove a closable pane (the second network editor) from the docks.
CloseTab,
+ /// Bind this pane to whichever editor takes the last node click.
+ PinFollow,
+ /// Bind this pane to one editor (CONTENT_IDX / CONTENT2_IDX).
+ PinTo(usize),
/// A "-" row: engraved, inert — keeps `plate_menu_actions` aligned with
/// the option rows so a click on the line dispatches nothing.
Separator,
@@ -178,6 +182,30 @@ impl State {
}
}
+ // Selection binding — the params pane and spreadsheet can pin to
+ // one editor (the viewport's right-click radio, on the plates that
+ // follow selection). Only while a second editor exists: with one,
+ // following IS pinned.
+ if (idx == PARAM_IDX || idx == SPREADSHEET_IDX)
+ && self.tab_dock_of_pane(NETWORK_PANEL2_IDX).is_some()
+ {
+ separate(&mut options, &mut actions);
+ let pin = if idx == PARAM_IDX { self.params_pin } else { self.spreadsheet_pin };
+ let mark = |on: bool| if on { "●" } else { "○" };
+ options.push(format!("{} Follow Active Editor", mark(pin.is_none())));
+ actions.push(PlateMenuAction::PinFollow);
+ options.push(format!(
+ "{} Pin: Network",
+ mark(pin == Some(crate::slots::CONTENT_IDX))
+ ));
+ actions.push(PlateMenuAction::PinTo(crate::slots::CONTENT_IDX));
+ options.push(format!(
+ "{} Pin: Network 2",
+ mark(pin == Some(crate::slots::CONTENT2_IDX))
+ ));
+ actions.push(PlateMenuAction::PinTo(crate::slots::CONTENT2_IDX));
+ }
+
// Tabs — only on docked plates (the playbar's strip is not a dock).
// The dock's other tabs switch to the front; panes docked elsewhere
// can be pulled in as tabs; a pane sharing its dock can move back
@@ -316,10 +344,29 @@ impl State {
PlateMenuAction::BackToMain => self.open_plate_menu(idx),
PlateMenuAction::SplitTab => self.split_dock_tab(idx),
PlateMenuAction::CloseTab => self.close_dock_tab(idx),
+ PlateMenuAction::PinFollow => self.set_pane_pin(idx, None),
+ PlateMenuAction::PinTo(e) => self.set_pane_pin(idx, Some(e)),
PlateMenuAction::Separator => {}
}
}
+ /// Apply a plate's selection-binding pick and refresh what it feeds.
+ fn set_pane_pin(&mut self, idx: usize, pin: Option<usize>) {
+ match idx {
+ PARAM_IDX => {
+ self.params_pin = pin;
+ self.sync_parameters_pane();
+ }
+ SPREADSHEET_IDX => {
+ self.spreadsheet_pin = pin;
+ // The spreadsheet refresh lives in sync_nodes, keyed by the
+ // bound selection's node id — rebinding changes the key.
+ self.sync_nodes();
+ }
+ _ => {}
+ }
+ }
+
/// Spreadsheet span: full width sets both tuck insets to their maxima
/// (the rect derivation clamps to the usable span), between-panes clears
/// them. The neighbors' heights follow through the existing tuck interlock.
diff --git a/src/project.rs b/src/project.rs
index d7a7854..0ca3f2a 100644
--- a/src/project.rs
+++ b/src/project.rs
@@ -127,11 +127,31 @@ impl State {
splitters,
dock_tabs,
current_path2: self.current_path2.clone(),
- viewport_pin: match self.viewport_pin {
- Some(crate::slots::CONTENT2_IDX) => Some("network2".to_string()),
- Some(_) => Some("network".to_string()),
- None => None,
- },
+ viewport_pin: Self::pin_name(self.viewport_pin),
+ params_pin: Self::pin_name(self.params_pin),
+ spreadsheet_pin: Self::pin_name(self.spreadsheet_pin),
+ }
+ }
+
+ /// A pin as its saved pane name.
+ fn pin_name(pin: Option<usize>) -> Option<String> {
+ match pin {
+ Some(crate::slots::CONTENT2_IDX) => Some("network2".to_string()),
+ Some(_) => Some("network".to_string()),
+ None => None,
+ }
+ }
+
+ /// The inverse: a saved pin name, honored only when its editor exists.
+ fn pin_from_name(&self, name: Option<&str>) -> Option<usize> {
+ match name {
+ Some("network") => Some(crate::slots::CONTENT_IDX),
+ Some("network2")
+ if self.tab_dock_of_pane(crate::slots::NETWORK_PANEL2_IDX).is_some() =>
+ {
+ Some(crate::slots::CONTENT2_IDX)
+ }
+ _ => None,
}
}
@@ -269,17 +289,11 @@ impl State {
// a stale save must degrade to the deepest valid ancestor.
self.current_path2 = vs.current_path2.clone();
self.clamp_path2();
- // The viewport pin: "network2" only holds if the loaded arrangement
- // actually carries the second editor.
- self.viewport_pin = match vs.viewport_pin.as_deref() {
- Some("network") => Some(crate::slots::CONTENT_IDX),
- Some("network2")
- if self.tab_dock_of_pane(crate::slots::NETWORK_PANEL2_IDX).is_some() =>
- {
- Some(crate::slots::CONTENT2_IDX)
- }
- _ => None,
- };
+ // Pins: "network2" only holds if the loaded arrangement actually
+ // carries the second editor.
+ self.viewport_pin = self.pin_from_name(vs.viewport_pin.as_deref());
+ self.params_pin = self.pin_from_name(vs.params_pin.as_deref());
+ self.spreadsheet_pin = self.pin_from_name(vs.spreadsheet_pin.as_deref());
if let Some((f1, f2)) = vs.splitters {
if self.width > 1.0 && f1 > 0.02 && f2 < 0.98 && f1 < f2 {
self.splitter_layout.splitter1_x = f1 * self.width;