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

commite57adc87450d174e739a22cc80a1caf8236f4b12
parent2909633374
authorLucas Galante <[email protected]>
date2026-09-01 11:15
feat: the viewport can pin to one editor

The viewport's right-click menu grows a radio group (shown only while
a second editor exists): Follow Active Editor — the default, tracking
whichever editor took the last node click — or Pin: Network /
Pin: Network 2, locking the scene to that editor's level regardless of
where clicks land. viewport_editor()/viewport_editor_dir() resolve the
binding; the re-scope triggers gate on it, so navigation in the bound
editor re-scopes and navigation elsewhere does not. Closing the second
editor clears a pin to it (and hands the params pane back). The pin
rides view_state as a pane name, restored only when the loaded
arrangement actually carries the second editor.

Co-Authored-By: Claude Fable 5 <[email protected]>

 src/app.rs     | 86 ++++++++++++++++++++++++++++++++++++++++++++++++++++++----
 src/project.rs | 16 +++++++++++
 src/render.rs  | 15 +++++-----
 src/window.rs  |  4 +--
 4 files changed, 105 insertions(+), 16 deletions(-)

diff --git a/src/app.rs b/src/app.rs
index 1ff113d..f346464 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -185,6 +185,10 @@ pub struct ProjectViewState {
     /// whose tree changed shape degrades to the deepest valid ancestor.
     #[serde(default)]
     pub current_path2: Vec<usize>,
+    /// The viewport pin as a pane name ("network"/"network2"); absent or
+    /// unresolvable follows the active editor.
+    #[serde(default)]
+    pub viewport_pin: Option<String>,
 }
 
 fn default_camera() -> String {
@@ -216,6 +220,12 @@ pub enum NodeMenuAction {
 pub enum ViewportMenuAction {
     /// Move the active camera so the visible node geometry fills the view.
     FrameAll,
+    /// Follow whichever editor took the last node click (the default).
+    PinFollow,
+    /// Lock the viewport to one editor's level (CONTENT_IDX / CONTENT2_IDX).
+    PinTo(usize),
+    /// A "-" row: engraved, inert.
+    Separator,
 }
 
 #[derive(Debug, Clone, Deserialize, Serialize)]
@@ -896,6 +906,10 @@ pub struct State {
     /// writeback): CONTENT_IDX or CONTENT2_IDX — whichever took the last
     /// node click. Selection itself stays per-editor.
     pub param_editor: usize,
+    /// The viewport's pin: None follows `param_editor`; Some(CONTENT_IDX /
+    /// 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>,
     pub node_clipboard: Option<FsNode>,
     pub last_click: Option<(Instant, usize)>,
     pub last_frame: Instant,
@@ -1594,6 +1608,17 @@ impl State {
     /// fronts the next tab or empties.
     pub fn close_dock_tab(&mut self, slot: usize) {
         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;
+            }
+            if self.param_editor == crate::slots::CONTENT2_IDX {
+                self.param_editor = CONTENT_IDX;
+            }
+            self.rebuild_scene_geometry();
+            self.sync_parameters_pane();
+        }
         let f = from as usize;
         self.dock_tabs[f].retain(|&s| s != slot);
         if self.dock_panes[f] == slot {
@@ -1816,6 +1841,21 @@ impl State {
         }
     }
 
+    /// The editor whose level the VIEWPORT renders: the pin when set, else
+    /// the active (last-clicked) editor.
+    pub fn viewport_editor(&self) -> usize {
+        self.viewport_pin.unwrap_or(self.param_editor)
+    }
+
+    /// The level the viewport renders — [`Self::viewport_editor`]'s dir.
+    pub fn viewport_editor_dir(&self) -> &FsNode {
+        if self.viewport_editor() == crate::slots::CONTENT2_IDX {
+            self.dir_at(&self.current_path2)
+        } else {
+            self.current_dir()
+        }
+    }
+
     /// Truncate the second editor's path to its valid prefix — run after any
     /// structural edit, so `dir_at` clamping and the drawn breadcrumb agree.
     pub fn clamp_path2(&mut self) {
@@ -2764,8 +2804,28 @@ impl State {
 
     /// Open the viewport right-click context menu at the cursor.
     fn open_viewport_context_menu(&mut self) {
-        let options = vec!["Frame All".to_string()];
-        let actions = vec![ViewportMenuAction::FrameAll];
+        let mut options = vec!["Frame All".to_string()];
+        let mut actions = vec![ViewportMenuAction::FrameAll];
+        // The viewport's editor binding, as a radio group: follow the active
+        // editor, or pin to one. Pin rows appear only while a second editor
+        // exists — with one editor, following IS pinned.
+        if self.tab_dock_of_pane(crate::slots::NETWORK_PANEL2_IDX).is_some() {
+            options.push("-".to_string());
+            actions.push(ViewportMenuAction::Separator);
+            let mark = |on: bool| if on { "●" } else { "○" };
+            options.push(format!("{} Follow Active Editor", mark(self.viewport_pin.is_none())));
+            actions.push(ViewportMenuAction::PinFollow);
+            options.push(format!(
+                "{} Pin: Network",
+                mark(self.viewport_pin == Some(CONTENT_IDX))
+            ));
+            actions.push(ViewportMenuAction::PinTo(CONTENT_IDX));
+            options.push(format!(
+                "{} Pin: Network 2",
+                mark(self.viewport_pin == Some(crate::slots::CONTENT2_IDX))
+            ));
+            actions.push(ViewportMenuAction::PinTo(crate::slots::CONTENT2_IDX));
+        }
         let target = self.slots.viewport.id();
         cce_ui::widget::context_menu::show(self.cursor_x, self.cursor_y, options, 0, target);
         self.viewport_menu_active = true;
@@ -2798,6 +2858,15 @@ impl State {
                     ViewportMenuAction::FrameAll => {
                         self.frame_all();
                     }
+                    ViewportMenuAction::PinFollow => {
+                        self.viewport_pin = None;
+                        self.rebuild_scene_geometry();
+                    }
+                    ViewportMenuAction::PinTo(e) => {
+                        self.viewport_pin = Some(e);
+                        self.rebuild_scene_geometry();
+                    }
+                    ViewportMenuAction::Separator => {}
                 }
             }
             return true;
@@ -3453,6 +3522,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
             current_path,
             current_path2: Vec::new(),
             param_editor: CONTENT_IDX,
+            viewport_pin: None,
             node_clipboard: None,
             last_click: None,
             last_frame: Instant::now(),
@@ -5524,14 +5594,18 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
                                 // to it.
                                 if self.param_editor != crate::slots::CONTENT2_IDX {
                                     self.param_editor = crate::slots::CONTENT2_IDX;
-                                    self.rebuild_scene_geometry();
+                                    if self.viewport_pin.is_none() {
+                                        self.rebuild_scene_geometry();
+                                    }
                                 }
                                 self.sync_parameters_pane();
                             }
                             if i == CONTENT_IDX {
                                 if self.param_editor != CONTENT_IDX {
                                     self.param_editor = CONTENT_IDX;
-                                    self.rebuild_scene_geometry();
+                                    if self.viewport_pin.is_none() {
+                                        self.rebuild_scene_geometry();
+                                    }
                                 }
                                 self.sync_parameters_pane();
                                 if let Some(slot_idx) = self.graph().selected_node() {
@@ -5817,8 +5891,8 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
                         if dir_idx < dir.children.len() && dir.children[dir_idx].is_enterable() {
                             self.current_path2.push(dir_idx);
                             self.sync_nodes();
-                            // The viewport tracks the active editor's level.
-                            if self.param_editor == crate::slots::CONTENT2_IDX {
+                            // The viewport tracks its editor's level.
+                            if self.viewport_editor() == crate::slots::CONTENT2_IDX {
                                 self.rebuild_scene_geometry();
                             }
                             changed = true;
diff --git a/src/project.rs b/src/project.rs
index 75b76c7..d7a7854 100644
--- a/src/project.rs
+++ b/src/project.rs
@@ -127,6 +127,11 @@ 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,
+            },
         }
     }
 
@@ -264,6 +269,17 @@ 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,
+        };
         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;
diff --git a/src/render.rs b/src/render.rs
index 0544334..6092208 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -861,12 +861,11 @@ impl State {
         let mut sim_cache = std::mem::take(&mut self.sim_cache);
         let geom = {
             let mut sim = crate::geometry::EvalSim::new(frame, start, &mut sim_cache);
-            // The viewport shows the ACTIVE editor's level — whichever
-            // network editor took the last node click (the param_editor, so
-            // the viewport, params pane and spreadsheet agree on what is
-            // being looked at) — while name resolution stays rooted at
-            // fs_root. Navigation in either editor re-scopes this.
-            network_sphere_vertices_with_errors(&self.fs_root, self.param_editor_dir(), &mut ocl_error, &mut sim)
+            // The viewport shows ITS editor's level — the pinned one when a
+            // pin is set, else whichever editor took the last node click —
+            // while name resolution stays rooted at fs_root. Navigation in
+            // the bound editor re-scopes this.
+            network_sphere_vertices_with_errors(&self.fs_root, self.viewport_editor_dir(), &mut ocl_error, &mut sim)
         };
         self.sim_cache = sim_cache;
 
@@ -882,7 +881,7 @@ impl State {
             false
         }
 
-        let displayed_opencl = has_visible_opencl(self.param_editor_dir());
+        let displayed_opencl = has_visible_opencl(self.viewport_editor_dir());
         if let Some(e) = ocl_error {
             self.update_status_text(&format!("OpenCL Error: {}", e));
         } else if displayed_opencl {
@@ -909,7 +908,7 @@ impl State {
             let mut sim = crate::geometry::EvalSim::new(frame, start, &mut sim_cache);
             // Same scoping as the scene walk above: overlays annotate what is
             // on screen, so they walk the same current level.
-            collect_meta_overlays(&self.fs_root, self.param_editor_dir(), self.meta_marker_size, self.meta_marker_color, &mut sim)
+            collect_meta_overlays(&self.fs_root, self.viewport_editor_dir(), self.meta_marker_size, self.meta_marker_color, &mut sim)
         };
         self.sim_cache = sim_cache;
         self.meta_marker_verts = markers;
diff --git a/src/window.rs b/src/window.rs
index e6ffa5a..ad01778 100644
--- a/src/window.rs
+++ b/src/window.rs
@@ -67,8 +67,8 @@ impl State {
                     if seg < state.current_path2.len() {
                         state.current_path2.truncate(seg);
                         state.sync_nodes();
-                        // The viewport tracks the active editor's level.
-                        if state.param_editor == crate::slots::CONTENT2_IDX {
+                        // The viewport tracks its editor's level.
+                        if state.viewport_editor() == crate::slots::CONTENT2_IDX {
                             state.rebuild_scene_geometry();
                         }
                         changed = true;