Skip to content

Node settings improvements#259

Open
tobyspark wants to merge 6 commits into
Fabric-Project:feature/Satin-2.0from
tobyspark:feat-node-settings-improvements
Open

Node settings improvements#259
tobyspark wants to merge 6 commits into
Fabric-Project:feature/Satin-2.0from
tobyspark:feat-node-settings-improvements

Conversation

@tobyspark

@tobyspark tobyspark commented May 17, 2026

Copy link
Copy Markdown
Contributor

From #256, here is the follow-on work. I can’t write to that PR, hence this. Documented in #256 and commit messages here.

tobyspark and others added 6 commits May 17, 2026 01:31
Stop re-parsing on every keystroke. Typing "sin(x" used to add the
`x` port, fail to parse the next half-formed expression, drop ports,
re-add them — every character. Now:

- The TextField writes to a local @State buffer instead of the node's
  expression, so the AppKit field never gets a re-write of its own
  text and the cursor / selection survives the eval. (Direct binding
  to an @observable property rebuilds the binding on each keystroke,
  which the macOS TextField representable treats as an external
  change and pushes back into NSTextField — whose stringValue setter
  selects-all by default.)
- Each buffer change reschedules a 1s debounce task. Trailing edit
  fires `commit()`. Enter / defocus pre-empts the debounce and
  commits immediately. The buffer is the source of truth until then;
  `node.stringExpression` is left alone so external observers see
  the user's intended state, not every intermediate keystroke.
- `commit()` registers a single coalesced undo step capturing the
  full edit range. The symmetric undo→redo pattern bounces correctly
  on repeated ⌘Z / ⌘⇧Z; both sides re-run `commitExpression()` so
  the variable ports follow the restored expression.
- External edits (undo / redo / scripted) sync the buffer via
  `.onChange(of: node.stringExpression)` when the field isn't
  focused; while the user is typing the buffer stays authoritative.

Node-side: `stringExpression` is now a plain `@Observable` `var`
(no `@ObservationIgnored`, no `didSet`) so the View can
`.onChange` it. Eval is invoked explicitly via the new public
`commitExpression()` from the View, the decoder, and the
convenience initialiser — same call sites as before, just spelled
out instead of riding `didSet`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Swap hand-rolled `Task` + `.onChange(of: buffer)` debounce for `.task(id: buffer)`; flatten nested undo/redo helper into a static self-recursive `registerReversibleEdit`. No behaviour change.
`shouldUpdateConnections` is a dirty flag consumed by two things: `GraphRenderer.executeAndDraw` snapshots and clears it to drive `syncNodesToScene()`, and SwiftUI `.id(...)` modifiers on GraphCanvas / NodeSelectionInspector use it to force a rebuild on change.

Most mutation sites already wrote `= true`; the port-mutating sites (addDynamicPort, removePort, NodePort.connect/disconnect, the rename alert, the port context menu, the double-click-disconnect in GraphCanvas) used `.toggle()`. Pairs of toggles in a single batch — e.g. renaming a math-expression variable = 1 remove + 1 add — cancelled out, landing on the same Bool value and silently skipping both the scene sync AND the `.id(...)` rebuild. Result: the new port existed in the registry, but the inspector kept showing the old one until something else forced a re-render (e.g. clicking the node, which toggles `isSelected` and re-evaluates NodeView).

Sweep `.toggle()` → `= true` across all 8 sites; add a contract comment to the declaration in Graph.swift forbidding `.toggle()`.
The previous commit fixed the rename-doesn't-update symptom by making `Graph.shouldUpdateConnections` monotonic. SwiftUI views still pick up port changes via `.id(currentGraph.shouldUpdateConnections)` on GraphCanvas / NodeSelectionInspector — a workaround that forces a rebuild on dirty-flag change, not first-class observation.

The underlying gap: `Node.ports` is computed from `@ObservationIgnored registry`, so SwiftUI can't see port mutations as observation events. The `.id(...)` hack is what bridges the gap.

Mark PortRegistry @observable. Reads of `Node.ports` go through `registry.all()` → `ordered`; with the registry observable, any view that reads `node.ports` re-renders naturally when ports are added, removed, or reordered. No `.id(...)` indirection needed for the port case, no extra signal in the mutating node.

`shouldUpdateConnections` and the `.id(...)` modifiers stay — they still cover connection-topology changes I haven't audited, and the scene-sync consumer in GraphRenderer still needs the dirty flag.
Repro: open a math-expression popover, click the field (arrows = cursor), click on the graph (arrows = selected node), click back into the field — arrows still moved the selected node.

The editor's `FabricEditorInputFocus` is hand-maintained: every site that might change focus pokes the binding (canvas tap → .canvas, node tap → .canvas, popover open → .nodeSettings, popover close → .canvas, etc.). There was no symmetric trigger for "user clicks back into a popover field while the popover is still open", so the canvas's `.onKeyPress` handler kept claiming arrow keys via its `inputFocus == .canvas` guard.

Invert that one transition through SwiftUI: GraphCanvas injects a setter closure into the Environment; popover text fields call it from their `@FocusState`'s `.onChange` when they gain focus, pushing `inputFocus = .nodeSettings`. Defaults to a no-op so descendants without a GraphCanvas ancestor don't need optional chaining, matching the SwiftUI `dismiss` / `openURL` pattern.

Tried `@FocusedValue` first — cleaner in principle, but FocusedValues don't bridge across the popover's NSWindow boundary on macOS, so the GraphCanvas observer never saw the field's focus state. Environment values do propagate to popover content; the rationale is in EditorInputFocus.swift.

Applied to MathExpressionView only — the verified repro. Other text fields in other settings views (StringScannerNode, StringFormatterNode, LiveImageNode, etc.) will have the same bug; each needs the same `@Environment(\.setEditorInputFocus)` + onChange call when its field focuses. Not done here to keep the change scoped.
The previous commit wired the focus-reclaim mechanism into one settings view (Math Expression). Apply it to every other settings view that has a TextField in a popover: OSCReceiveNode (address + port), TimelineNode (track name + duration), StringScannerNode (format string), StringFormatterNode (format string).

Wrap the per-field plumbing — `@Environment(\.setEditorInputFocus)` + `@FocusState` + onChange — in a reusable `.reclaimsNodeSettingsFocus()` modifier so each call site is one line. Math Expression keeps its manual implementation because it also needs the `@FocusState` for the commit-on-defocus path; the modifier note records that double-`.focused` on the same field is tolerated by SwiftUI.
@tobyspark tobyspark changed the base branch from main to feature/Satin-2.0 May 17, 2026 16:27

@vade vade left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR #259 — Performance Review: EditorInputFocus.swift & related

Architectural choice: environment closure vs. FocusedValue

The rationale is correct. FocusedValue doesn't cross NSWindow boundaries on macOS; SwiftUI environment values do. Given the popover-in-own-NSWindow constraint, this is the right architectural call.


Primary concern: closure equality + self-reinforcing invalidation

The author flagged this themselves in the comment but incorrectly rated it as "tolerable":

// Note: the Environment value is a bare closure. Closures aren't
// Equatable, so each GraphCanvas body re-eval injects a "new"
// closure and SwiftUI invalidates descendant readers. Tolerable
// here (GraphCanvas re-evals infrequently, popover content is cheap).

The claim that GraphCanvas re-evals infrequently is wrong in this context. GraphCanvas.body re-evals any time inputFocus changes — and inputFocus changes precisely when a user focuses a TextField, which calls setEditorInputFocus(.nodeSettings), which mutates
inputFocus. This creates a self-reinforcing loop:

TextField gains focus
→ setEditorInputFocus(.nodeSettings) called
→ GraphCanvas.inputFocus mutates
→ GraphCanvas.body re-evals
→ new closure injected into environment
→ all .setEditorInputFocus readers invalidated
→ ReclaimsNodeSettingsFocusModifier.body re-evals

SwiftUI prevents infinite cycling (focused state didn't change, so .onChange doesn't re-fire), but the unnecessary re-eval of every .setEditorInputFocus reader happens on every focus event. Additionally shouldUpdateConnections = true is now set on
connect/disconnect/rename operations, which also drive GraphCanvas re-evals.

Your intermediate-view concern is partially mitigated by SwiftUI's dependency tracking — views that don't read @Environment(.setEditorInputFocus) are not re-evaluated just because an ancestor injects a new value. However, all views that do read it are
unnecessarily invalidated, including anything using .reclaimsNodeSettingsFocus().

Fix: wrap in an always-equal struct (DismissAction pattern)

The author mentioned this but deferred it. It should be applied now. The pattern is exactly how SwiftUI implements DismissAction, OpenURLAction, etc.:

// EditorInputFocus.swift
public struct EditorInputFocusSetter {
fileprivate let action: @mainactor (FabricEditorInputFocus) -> Void
func callAsFunction(_ focus: FabricEditorInputFocus) { action(focus) }
}

extension EditorInputFocusSetter: Equatable {
// Always equal: GraphCanvas is the permanent owner and the action
// always targets the same instance. Prevents descendant invalidation
// on every GraphCanvas body re-eval.
static func == (lhs: Self, rhs: Self) -> Bool { true }
}

private struct EditorInputFocusSetterKey: EnvironmentKey {
static let defaultValue = EditorInputFocusSetter { _ in }
}

extension EnvironmentValues {
var setEditorInputFocus: EditorInputFocusSetter {
get { self[EditorInputFocusSetterKey.self] }
set { self[EditorInputFocusSetterKey.self] = newValue }
}
}

In GraphCanvas.swift, use a weak capture to avoid a retain cycle:

.environment(.setEditorInputFocus, EditorInputFocusSetter { [weak self] focus in
self?.inputFocus = focus
})

Call sites don't change — EditorInputFocusSetter with callAsFunction means you still call it as setEditorInputFocus(.nodeSettings).


Secondary concern: ReclaimsNodeSettingsFocusModifier creates a second @focusstate

Every use of .reclaimsNodeSettingsFocus() adds an independent @focusstate private var focused: Bool inside the modifier, alongside whatever @focusstate the call site already has. The comment says "SwiftUI tolerates multiple .focused modifiers" — this is true,
but it means:

  • Two separate FocusState subscribers tracking the same field
  • On focus change, both fire .onChange(of:) handlers independently
  • If a call site uses the modifier AND has its own @focusstate for commit-on-defocus (like MathExpressionView), the modifier's internal state is redundant

MathExpressionView is correctly not using .reclaimsNodeSettingsFocus() — it handles focus directly and inline because it needs the defocus event for commit(). That's the right call. The other nodes (OSCReceive, Timeline, StringFormatter, StringScanner) apply it
to TextFields without their own FocusState, which is clean.


The other changes in this PR

┌──────────────────────────────────────────────────────┬─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Change │ Assessment │
├──────────────────────────────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ shouldUpdateConnections.toggle() → = true everywhere │ Correct. Toggle was a latent bug where paired ops (rename = remove + add) cancelled silently. │
├──────────────────────────────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
@observable on PortRegistry │ Correct. Automatic observation replaces the brittle toggle pattern. More fine-grained than the old .id(shouldUpdateConnections) rebuild. │
├──────────────────────────────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ MathExpressionView buffer + .task(id:) debounce │ Sound. .task(id:) cancellation is the idiomatic debounce. The buffer-breaks-AppKit-select-all explanation is accurate. Undo coalescing (all keystrokes since last commit → one ⌘Z) is the right UX. │
└──────────────────────────────────────────────────────┴─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘


Summary

The architecture is correct. The one real issue is the bare-closure environment value — the author's own analysis identified the problem but underestimated how often GraphCanvas re-evals in the focus path. Apply the always-equal struct wrapper now. Everything
else in the PR is solid.

resorting to Objective-C runtime features or unsafe reflection.
*/

@Observable

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I dont think @observable with private variables does anything? unless you want ordered to be specifically surfaced as public private(set) ?

}
self.node?.graph?.undoManager?.setActionName("Connect Ports")
self.node?.graph?.shouldUpdateConnections.toggle()
self.node?.graph?.shouldUpdateConnections = true

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice catch(es) on this failure!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing like using something in anger.

/// here (GraphCanvas re-evals infrequently, popover content is
/// cheap). If more callers appear, consider wrapping in a
/// `DismissAction`-style struct.
private struct EditorInputFocusSetterKey: EnvironmentKey

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see my main comment / concerns from claude review of this approach

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants