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