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

commit3cfb48417d513805a08ab791f8df0f3b2b0681bd
parentca4a982164
authorLucas Galante <[email protected]>
date2026-09-24 12:44
feat: the wrangle's Code row applies on ctrl+enter, and a script error flags its line

Against cce-ui's new code editor (eeca5b7): edits stay in the row's
buffer until ctrl+enter, Escape or leaving the row, so a half-typed
script no longer fails the node on every keystroke. When an evaluation
error names the node the params pane shows and carries a `(line N`, the
line is handed to the pane, which paints it red in the gutter with a
band under the text; cleared on the next evaluation that says nothing
about that node. Rhai's line numbers survive desugar because the `@`
rewrite never adds or removes a line. The runner's undo / redo chords
reach the editor's own history ahead of the viewer tool's.

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

 CLAUDE.md          | 12 ++++++++++++
 shapeshifter.md    |  8 +++++---
 src/application.rs |  6 ++++--
 src/main.rs        | 21 +++++++++++++++++++++
 src/render.rs      | 31 +++++++++++++++++++++++++++++++
 src/slots.rs       |  6 ++++++
 6 files changed, 79 insertions(+), 5 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 1132571..3d7f374 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -771,6 +771,18 @@ below native Rust, which is fine for tens of thousands of elements per edit
 and wrong for a solver at a million per frame. That is Phase 7's step 4
 (WGSL compute through the renderer), not a reason to grow this.
 
+**The Code row applies on ctrl+enter, Escape or leaving the row — never per
+keystroke.** cce-ui's `ParametersBg` code editor (line numbers, selection,
+clipboard, tab indenting, auto-indent, undo) keeps edits in its buffer
+until one of those, because this node evaluates on every value change and
+a half-typed line would fail on every keystroke — the border is amber
+while edits are pending. **A script error's line is flagged in the row**:
+`code_error_line_for_pane` in `render.rs` reads the `(line N` out of the
+evaluation error when the node it names is the one the pane shows, and
+hands it to `set_code_error_line`; Rhai's line numbers survive `desugar`
+because the `@` rewrite never adds or removes a line. Cleared on the next
+evaluation that says nothing about that node.
+
 ### GPU compute: the springs solve is the first operator (Phase 7 step 4)
 
 `src/gpu.rs` keeps one `cce_ui::vk::ComputeDevice` per thread, opened on
diff --git a/shapeshifter.md b/shapeshifter.md
index 1e4c696..347c38c 100644
--- a/shapeshifter.md
+++ b/shapeshifter.md
@@ -600,9 +600,11 @@ to be undone to reach the fourth.
 > Group, the `@name` sugar with typed attribute creation, `ch` / `chs` / `chv`
 > / `chi` resolved through `TreeScope` before the run, topology and nearest,
 > deferred `addpoint` / `addprim` / `removepoint`, both budgets. Nine tests
-> in `main.rs`. Not yet: a code editor worth the name in the params pane
-> (the `code` row is a single-line text box), `@N` write-back feeding the
-> normal overlay, and a vertex class.
+> in `main.rs`. The params pane's code row is an editor since the same
+> day: gutter, selection, clipboard, indenting, undo, apply on ctrl+enter
+> rather than per keystroke, and the failing line flagged from the
+> evaluation error. Not yet: `@N` write-back feeding the normal overlay,
+> and a vertex class.
 
 **Step 1 — a `wrangle` node on an embedded engine.** Houdini's attribwrangle,
 the thing users actually reach for, on a scripting engine someone else
diff --git a/src/application.rs b/src/application.rs
index 75bdd99..9a632bb 100644
--- a/src/application.rs
+++ b/src/application.rs
@@ -326,7 +326,9 @@ impl Application for State {
     /// The toolkit's undo/redo routing lands here once no focused text box
     /// wanted the chord. Only the curve viewer state has a history today.
     fn undo(&mut self, needs_rebuild: &mut bool) -> bool {
-        let taken = self.viewer_tool_undo();
+        // A code row being edited owns the chord: its typing is the thing to
+        // undo, ahead of a viewer tool that may also be active.
+        let taken = self.code_editor_action(cce_ui::widget::ContextAction::Undo) || self.viewer_tool_undo();
         if taken {
             *needs_rebuild = true;
         }
@@ -334,7 +336,7 @@ impl Application for State {
     }
 
     fn redo(&mut self, needs_rebuild: &mut bool) -> bool {
-        let taken = self.viewer_tool_redo();
+        let taken = self.code_editor_action(cce_ui::widget::ContextAction::Redo) || self.viewer_tool_redo();
         if taken {
             *needs_rebuild = true;
         }
diff --git a/src/main.rs b/src/main.rs
index 289085b..e74f372 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -10849,4 +10849,25 @@ mod tests {
             );
         }
     }
+
+    /// A wrangle's error names its node and its line; the params pane is
+    /// told the line only when that node is the one it shows.
+    #[test]
+    fn a_script_error_line_reaches_the_params_pane_for_the_shown_node() {
+        use crate::app::State;
+        assert_eq!(State::error_line_number("syntax: Syntax error: Expecting ';' (line 3, position 5)"), Some(2));
+        assert_eq!(State::error_line_number("point 4: index out of range (line 1, position 9)"), Some(0));
+        assert_eq!(State::error_line_number("OpenCL nodes are retired"), None);
+        assert_eq!(State::error_line_number("(line 0, position 1)"), None, "a zero line is not a line");
+
+        let mut state = State::new(false);
+        let mut redraw = false;
+        state.apply_action(crate::app::McpAction::AddNode { template_name: "Wrangle".into(), name: Some("w".into()), x: 3.0, y: 9.0 }, &mut redraw).unwrap();
+        let slot = state.current_dir().children.iter().position(|c| c.name == "w").unwrap();
+        state.apply_action(crate::app::McpAction::Select { slot }, &mut redraw).unwrap();
+        assert_eq!(state.param_editor_selected(), Some(slot));
+        assert_eq!(state.code_error_line_for_pane("w: syntax: Syntax error (line 2, position 1)"), Some(1));
+        assert_eq!(state.code_error_line_for_pane("sphere1: something (line 2, position 1)"), None, "another node's error is not this pane's");
+        assert_eq!(state.code_error_line_for_pane("w: OpenCL nodes are retired"), None, "no line, no flag");
+    }
 }
diff --git a/src/render.rs b/src/render.rs
index 688e29d..d6f07fd 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -1192,6 +1192,11 @@ impl State {
         };
         self.sim_cache = sim_cache;
 
+        // A script error names its line; if the node it names is the one the
+        // params pane shows, the pane flags that line in its code row. Cleared
+        // whenever the evaluation says nothing about that node.
+        let flagged = ocl_error.as_deref().and_then(|e| self.code_error_line_for_pane(e));
+        self.slots.param_bg_mut().set_code_error_line(flagged);
         if let Some(e) = ocl_error {
             self.update_status_text(&format!("Node error: {}", e));
         } else {
@@ -1256,6 +1261,32 @@ impl State {
         crate::geometry::rt_scene_from_verts(&verts)
     }
 
+    /// The 0-based line a node error points at in the params pane's selected
+    /// node, when the error names that node and carries a `(line N` — the
+    /// shape Rhai's diagnostics take (`wrangle1: point 4: ... (line 3,
+    /// position 5)`). Anything else is None.
+    pub(crate) fn code_error_line_for_pane(&self, error: &str) -> Option<usize> {
+        let slot = self.param_editor_selected()?;
+        let node_name = &self.param_editor_dir().children.get(slot)?.name;
+        let rest = error.strip_prefix(node_name.as_str())?.strip_prefix(':')?;
+        Self::error_line_number(rest)
+    }
+
+    /// A clipboard, selection or history action for the params pane's code
+    /// editor, when one is open. False otherwise, so the caller's own
+    /// handling runs.
+    pub(crate) fn code_editor_action(&mut self, action: cce_ui::widget::ContextAction) -> bool {
+        let pane = self.slots.param_bg_mut();
+        pane.code_editing() && pane.code_action(action)
+    }
+
+    /// `(line N` anywhere in a diagnostic, 1-based in the text, 0-based out.
+    pub(crate) fn error_line_number(text: &str) -> Option<usize> {
+        let at = text.find("(line ")?;
+        let digits: String = text[at + 6..].chars().take_while(|c| c.is_ascii_digit()).collect();
+        digits.parse::<usize>().ok().filter(|n| *n >= 1).map(|n| n - 1)
+    }
+
     pub(crate) fn update_status_text(&mut self, text: &str) {
         if self.last_status_text != text {
             self.last_status_text = text.to_string();
diff --git a/src/slots.rs b/src/slots.rs
index e76777e..2c2896b 100644
--- a/src/slots.rs
+++ b/src/slots.rs
@@ -185,6 +185,12 @@ impl WidgetSlots {
         self.param.as_any_mut().downcast_mut::<ParametersBg>().expect("PARAM_IDX must be a ParametersBg")
     }
 
+    /// The pane as its concrete type, for what `ParamController` does not
+    /// carry — the code row's error line.
+    pub fn param_bg_mut(&mut self) -> &mut ParametersBg {
+        self.param.as_any_mut().downcast_mut::<ParametersBg>().expect("PARAM_IDX must be a ParametersBg")
+    }
+
     pub fn spreadsheet_mut(&mut self) -> &mut dyn cce_ui::widget::SpreadsheetController {
         self.spreadsheet.as_any_mut().downcast_mut::<Spreadsheet>().expect("SPREADSHEET_IDX must be a Spreadsheet")
     }