Skip to content

Nodal cable editing, missing-files search, preview toggle, Patternist ports, device search - #2295

Merged
jcelerier merged 32 commits into
masterfrom
workshop/nodal-ui-batch
Sep 21, 2026
Merged

jcelerier merged 32 commits into
masterfrom
workshop/nodal-ui-batch

Conversation

@jcelerier

Copy link
Copy Markdown
Member

A batch of ten requested items. Depends on celtera/avendish#208 — the submodule pointer is bumped to it, and Crousti/Executor.hpp references the flag it adds, so score will not build against the previous pointer. Companion addon changes in ossia/GBAP#37.

Nodal graph

  • Move a cable's endpoint. Pressing near one end grabs it and re-plugs it elsewhere; every selected cable comes along, batched into one ReplaceCable macro. The zone is capped at a third of the cable so a selectable middle always remains — without the cap, anything shorter than 56 units, and every cable on a zoomed-out canvas, could only be re-plugged and never selected. Hovering it changes the cursor so the zone can be seen rather than hunted for.
  • Drop a node on a cable. Inserts it when it has an inlet and an outlet of the cable's type; otherwise leaves the cable alone and attaches one new cable on whichever side matches, so an audio→value analysis node gets fed without breaking the chain. The cable that would receive the node is highlighted during the drag.
  • Auto-connect picks the first port of the matching type instead of index 0, preferring signal ports and falling back to control ports. Chaining a MIDI-out object into a synth with [audio in, midi in] now produces a cable where it previously produced nothing.

Missing-files panel

Header sections are resizable and movable, with their layout persisted. The folder search moved off the GUI thread onto the task pool and is exhaustive — the old 250k cap and the "search stopped early" message are gone — reporting the folder it is inside every 100 ms, with a real cancel flag.

Graphics

  • SCORE_DISABLE_SHADER_PREVIEW could only be decided before startup; a toolbar button now drives the same switch at runtime, reaching both the inspector's live preview and the library thumbnails. The env var still seeds it, which is the only way to keep previews off before the first frame for a headless run.
  • Crash fix, with a stack trace: TexgenNode reallocates its texture on a render-size change and patches the SRBs that exist then, but left m_samplers[0].texture dangling — and addOutputPass binds m_samplers into passes built later, which shift+Delete creates by moving the removed process's address onto its source. ImagesNode::OnTheFlyRenderer had the same omission.

Patternist

Ports to select the current pattern and the quantization at which a change takes effect. The quantization values are ossia rates, the scale QuantificationWidget already uses, so they reach get_quantification_dates() unconverted. The node owns the pattern list and switches an index, so the audio thread moves an integer; the swap hands the old list to the GC queue rather than freeing it in the callback.

Also fixes a pre-existing defect: DataStreamWriter::write read accent/slide a second time after load_value_outlet had consumed them, so every following field was parsed from the wrong offset.

Device dialog

Search boxes under the protocol, preset and enumerator lists. Substring matches always outrank fuzzy ones and earlier positions outrank later, with rapidfuzz doing the near-miss scoring above three characters. Matching is per-item rather than through a proxy, because both lists keep filling asynchronously long after the dialog is built.

Also

Path-generator trajectories are drawn and the playhead shown; Nodes and Multi-cursor can be grabbed anywhere on the element rather than only on their label.

Notes

A full adversarial review ran over the branch before this PR and its findings are folded in — among them the cable grab zone above, a drop moving the wrong end, a right-click re-plugging, an audio-thread deallocation, and a RadiusAspect rename that would have dropped saved values and cables.

Known and deliberately not addressed here: Rose draws aliased at high ratio settings (96 samples where Nyquist wants ~512), and insertProcessInCable hardcodes ImmediateGlutton rather than running onCreateCable's cycle walk — pre-existing, but the new gesture makes it easier to reach.

test_integration_js_remove_cable and test_live_edit.cable-storm are red on this branch and were verified red on master beforehand.

🤖 Generated with Claude Code

https://claude.ai/code/session_01H4N6KsxzvT1o9qoPvBThyz

jcelerier and others added 28 commits September 21, 2026 11:10
Auto-connection used inlets()[0] / outlets()[0] and only wired them when
that exact port's type matched, so chaining a MIDI-out object into a synth
with [audio in, midi in] produced no cable at all. Pick the first port of
the matching type instead, preferring signal ports and falling back to
control ports, and move the port address to whichever port was chosen.

Dropping an object onto a cable now also attaches instead of giving up:
insertProcessInCable() splices the object in when it has both an inlet and
an outlet of the cable's type, and otherwise leaves the cable alone and
adds a single cable on whichever side matches -- an audio->value analysis
object dropped on an audio cable gets fed without breaking the cable.
Moving a node onto a cable in the nodal view goes through the same path,
in the same macro as the move so it is one undo step.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4N6KsxzvT1o9qoPvBThyz
Pressing within 10% of the end-to-end distance of one of a cable's ends
grabs that end and re-plugs it elsewhere; on a cable too short for the two
zones to be distinct, the sink wins. The gesture reuses the existing port
drag -- the same rubber-band line, magnetism and drop targets -- anchored
on the end that stays put.

Every selected cable comes along, so a bundle can be re-routed at once.
moveCableEndpoints() batches them into one ReplaceCable macro and skips the
ones whose type or direction would not allow the move.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4N6KsxzvT1o9qoPvBThyz
The two path columns were QHeaderView::Stretch, which cannot be dragged, so
the header looked frozen. All sections are Interactive and movable now, with
per-column defaults and the layout persisted in QSettings. Same for the
report view the consolidate/trim dialogs use.

The folder search ran QDirIterator synchronously on the GUI thread and gave
up after 250000 files, which is not good enough when the point is to find a
file. It walks score::for_all_files on the task pool instead -- exhaustively,
and with the llfio/fts backends that handle large directories and permission
errors -- reporting the folder it is inside every 100ms and stopping on a
real cancel flag. Whatever a cancelled walk reached is still offered.

candidates() is now given the reference's size, so the exact-size ranking it
already implements is reachable from the dialog.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4N6KsxzvT1o9qoPvBThyz
Two control inlets, the sequencer's first: an int spinbox picking the current
pattern -- an int rather than a combobox because setCurrentPattern() grows the
list on demand, which a fixed list of alternatives cannot express -- and a
combobox picking when a change takes effect.

The quantization values are ossia rates, the same scale QuantificationWidget
uses for intervals and time syncs, so they reach get_quantification_dates()
unconverted like Pattern::division already does. There is no "Parent" entry:
a process cannot resolve that sentinel. The default is one bar, where the
stock 16-step pattern loops.

The node now owns the pattern list and switches an index, so the audio thread
moves an integer rather than copying a Pattern. A pending switch is scheduled
on the quantization grid through physical_position(), and the tick is played
as two passes around it since the two patterns may differ in division. Notes
in flight are released at the switch.

Also fixes DataStreamWriter::write reading accent and slide a second time
after load_value_outlet had already consumed them, which has been parsing
every following field from the wrong offset since 9520749, and the stale
size setCurrentPattern clamped against.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4N6KsxzvT1o9qoPvBThyz
Protocols, presets and enumerator results each get a search box below their
tree. Substring matches always rank above fuzzy ones and earlier positions
above later, so short queries stay predictable; below three characters the
fuzzy pass is skipped entirely, since partial_ratio matches nearly anything.
rapidfuzz does the near-miss scoring, with one cached scorer per query.

Matching is scored per item rather than through a proxy model: both the
preset scan and the protocol enumerators keep filling their tree long after
the dialog is built, and each new item is ranked as it lands. Categories
follow their children and never stay on screen alone.

Presets were also sorted once before the async scan had delivered anything;
they are sorted per delivered batch now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4N6KsxzvT1o9qoPvBThyz
SCORE_DISABLE_SHADER_PREVIEW could only be decided before startup. An eye
button in a new top "Graphics" toolbar drives the same switch at runtime;
the environment variable still seeds it, which is the only way to keep the
preview off before the first frame for a headless or CI run.

The inspector's live preview follows the toggle in both directions: its
existing 16ms tick owns the enable state and detaches or rebuilds the render
node, so an open inspector does not have to be reselected. The library
thumbnails are created per hover and only check the flag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4N6KsxzvT1o9qoPvBThyz
Removing a process during execution could crash in setShaderResources.
Texgen reallocates its texture on a render-size change and patches the
shader resource bindings of the passes that exist at that point, but left
m_samplers[0] on the old pointer. addOutputPass binds m_samplers into
passes built later, so a new out-edge appearing mid-playback -- what
shift+Delete produces when it moves the removed process's address onto its
source -- got an SRB over a freed texture.

ImagesNode::OnTheFlyRenderer had the same omission on the imagesChanged
path; its sibling PreloadedRenderer already carried the fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4N6KsxzvT1o9qoPvBThyz
The hit test was a hardcoded +-0.02 box, exactly the drawn 16px square, so
a cursor could only be picked up by its number. The box comes from
cursorSize now, shared with paint() so the two cannot drift, and is grown by
a small margin; overlapping cursors resolve to the nearest centre. Erase
uses the same test as grab.

Also fixes the create path measuring y against width().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4N6KsxzvT1o9qoPvBThyz
The widget only drew a straight line between the selected source's two
nodes; circle and spiral were invisible and the item did not know which
path was selected. It reads the sibling Path, Radius, Ratio and Phase ports
of its own process now and precomputes one QPainterPath per source, so every
trajectory is drawn for every source rather than rebuilt during paint().

Execution feedback rides the control-output queue that already exists: a
Progress bargraph carries the normalised position, so the audio thread
enqueues a float and nothing else. The marker repaints only the region it
moved through, and clears when playback stops.

Also initialises selectedSource/selectedCursor/isSelected, which were read
on the first paint, and fixes the erase path leaving selectedSource stale
and never committing the value.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4N6KsxzvT1o9qoPvBThyz
CustomItem implemented only the three mouse handlers, so a widget's wheel()
was never called. Gated on the widget declaring one, so every other custom
widget keeps ignoring the event and the view keeps scrolling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4N6KsxzvT1o9qoPvBThyz
p_duration has no W_WRITE and is typed in flicks, so PROPERTY_COMMAND_T
cannot drive it. SetDuration stores the old and new TimeVal by hand.

The expand mode is a constructor parameter rather than something the command
reads for itself: EditionSettings lives in score-plugin-scenario, above this
library, so the caller passes it in and the existing Scale / Lock toolbar
toggle decides whether the process content is rescaled with the duration.
update() lets a spinbox drag merge into one command, as the interval's own
duration widget does.

No UI uses this yet: the layer views size themselves from the parent
interval, so a process duration that differs from it has nothing to draw
against.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4N6KsxzvT1o9qoPvBThyz
Dropping a node on a cable was only decided on release, so there was nothing
to tell the user it would happen, and an ambiguous drop -- more than one
cable under the node -- looked identical to a viable one.

The cable that would receive the node is highlighted while the node is being
dragged, using the dotted pen CableItem already draws for a drop.
canInsertProcessInCable() answers whether anything would be connected at all,
so a node with no port of the cable's type lights nothing up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4N6KsxzvT1o9qoPvBThyz
The zone was max(8px, 10% of the cable) from an end, but the innermost 5.5px
belong to the port, which starts a *new* cable there. On a short cable that
left about two usable pixels, so re-plugging an end was a matter of luck.

The zone is max(28px, 15%) now, and hovering it switches the cursor so it can
be seen rather than hunted for. endNear() and the radius it uses are exposed
so the margin the port leaves can be asserted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4N6KsxzvT1o9qoPvBThyz
The port only reached the executor, so moving it changed nothing the user
could see: the model property kept its value and the grid kept drawing the
pattern it had. It changed the sound during playback and nothing else.

The property follows the port now, so the grid, the inspector and execution
all move together, and the inspector's own "Current pattern" spinbox goes
away rather than offering a second control for the same thing. Selecting
past the end still grows the list, as the spinbox did.

Execution reads the port directly, so an automated value never comes back
through here to grow the list from the audio thread.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4N6KsxzvT1o9qoPvBThyz
The first one was a round eyeball with a filled pupil, which stares. Flatten
the lens and outline the pupil, the usual shape for a visibility toggle. The
four states keep the palette the other toolbar icons use.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4N6KsxzvT1o9qoPvBThyz
Only the folder is elided, so the count stays readable however deep it goes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4N6KsxzvT1o9qoPvBThyz
It only meant anything for Linear: every other trajectory took its size from
the Radius control and its angle from Phase, so the red node sat there
drifting with no bearing on what was drawn.

The vector from the first node to it is the shape now -- its length is the
size, its direction where the shape starts -- so a circle, a rose and a
polygon all pass through it and a spiral ends on it. Dragging it resizes and
rotates at once. Radius becomes the x/y aspect on top, 1,1 being a circle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4N6KsxzvT1o9qoPvBThyz
The port drove the property but nothing drove the port, so the value saved in
a document was always the port's default: reloading a document put the
selector back on pattern 0 whatever was playing. A document written before
the port existed carries the pattern in the property alone and had the same
disagreement on load.

setCurrentPattern writes the port back, and init() seeds it from the property
before listening, so the saved value, the grid and execution cannot drift.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4N6KsxzvT1o9qoPvBThyz
The inspector's live preview followed the button, but a library thumbnail
already on screen only noticed on the next selection change.

The switch moves to Process::PreviewSettings, which both plugins can see --
the graphics plugin owns the button and the render nodes, the library owns
the thumbnails -- and the library rebuilds its preview when it changes.
SCORE_DISABLE_SHADER_PREVIEW still seeds it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4N6KsxzvT1o9qoPvBThyz
The eye was the wrong metaphor and the hover state was missing the orange
background square the guideline asks for, so it was a black glyph on a dark
toolbar. A framed picture instead, square caps, miter joins, 4px margin, and
the hover background the other toolbuttons carry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4N6KsxzvT1o9qoPvBThyz
The grab zone was max(28px, 15%) with no upper bound, so on a cable shorter
than 56 units the two ends' zones met and the cable could only be re-plugged,
never selected -- and since these are scene units while the nodal canvas
scales, zooming out did that to every cable. Cap it at a third of the length
so a middle always remains. The test asserted a usable band on a 20-unit
cable, i.e. it encoded the bug; it now checks the middle selects at every
length.

Which end moves was decided on press and then thrown away: moveCableEndpoints
re-derived it from the direction of the port dropped on, so a drop on a port
of the anchor's own direction -- reachable, since dragEnterEvent accepts
unconditionally and the magnet only covers ~8px -- moved the opposite end and
silently unplugged a third port.

A press with any button armed the grab, and mouseMoveEvent measures against
buttonDownScreenPos(LeftButton), which is null for the others and reads as a
drag already past the threshold: a right-click near an end re-plugged it.

Also: the selection flag survived the two early returns and could narrow the
selection onto an unrelated cable; a node destroyed mid-drag left its
highlighted cable dotted for good; and firstSignalOutlet refused a process
whose outlets are all controls, where before it chained after outlets()[0].

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4N6KsxzvT1o9qoPvBThyz
in_exec ran node->patterns = std::move(p), which destroys and deallocates the
previous list in place -- on the audio thread, one operator delete per lane of
per pattern. The execution queue hands each command to the GC queue once it
has run, so a swap leaves the old list in the lambda to be freed on the UI
thread; midi_node::replace_notes does exactly that. Master moved a single
Pattern, so the list made it worse.

setCurrentPattern also grew the list to meet any index it was given, and
remote control writes a port value straight off the wire with no clamp, so a
stray OSC message could grow it without bound and then hand the whole thing to
the audio thread. Clamp to the port's own range.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4N6KsxzvT1o9qoPvBThyz
g_shader_preview_enabled was left exported and documented as the switch while
nothing read it any more; the env var now seeds PreviewSettings from its own
constructor instead, which also drops a namespace-scope initialiser whose
ordering against other translation units was unspecified. The two library
files kept an include of a header they no longer use.

The library rebuilt the wrong preview: both handlers share m_previewChild, so
re-announcing the tree selection destroyed a preset preview and cleared state
nothing had asked to clear. Rebuild whichever one is showing.

The texture-outlet preview started a 60Hz tick even with previews off, so
SCORE_DISABLE_SHADER_PREVIEW bought less than it used to; the tick now stops
when the preview does and the toggle brings it back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4N6KsxzvT1o9qoPvBThyz
The Path port is a Process::Enum, which carries the enumerator *name*, so
convert<int> read "Circle" as 0: the editor drew a straight line for five of
the six trajectories and only snapped to the right one once execution pushed
an int. indexOfValue resolves both forms.

The path generator's mouse handlers still read a node with an unchecked
get<vec2f>, a bad variant access rather than a throw, on values the drawing
path had already been taught to check.

The multi-slider grew its pick rect by 4px and used it for the right-click
delete too, so a click on apparent background removed a cursor. Deletion goes
back to the drawn box; grabbing keeps the margin.

Device lists sorted with QString::operator<, a UTF-16 code-unit compare that
puts every capitalised name before every lowercase one, replacing the
locale-aware order QTreeWidgetItem gives -- with nothing typed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4N6KsxzvT1o9qoPvBThyz
A node writes its control outputs onto outs_queue every tick they change, but
the reader is only installed when the execution-update setting is on. With it
off the queue grew for the length of the run and moodycamel allocated blocks
inside the audio callback -- for values no one would ever dequeue. The path
generator's Progress output made this easy to hit, since a playhead changes
every tick.

The node is told whether anyone is listening, at the same place the decision
to install the reader is made. Bumps avendish for the flag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4N6KsxzvT1o9qoPvBThyz
jcelerier and others added 3 commits September 21, 2026 11:10
Both files declared a namespace-scope `header_setting`. Separate translation
units normally, but a unity build concatenates them into one and the second
is a redefinition, which is what the Debian, Windows, MSVC, Flatpak and
AppImage jobs hit -- every configuration that sets CMAKE_UNITY_BUILD.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4N6KsxzvT1o9qoPvBThyz
locateFilePath treats anything without a leading slash as relative and anchors
it to the document folder, so "rtsp://camera.local/stream" became
"<project>/rtsp:/camera.local/stream", failed to stat, and the startup dialog
told the user to go and find it. Consolidation reported it the same way, and
the archive said it could not be collected.

score::isRemoteUrl applies the rule the video decoder already uses in
urlIsLiveSource: a scheme, then "://", and anything but file -- narrow enough
that a Windows drive letter or a colon in a name is not mistaken for one. The
two policies classify such a reference as Unsupported, which is what it is:
seen, reported as an external dependency, and nothing score can relocate.

Gfx::Video::Model::absolutePath had the same anchoring, so a url could not
play either; it now passes one through untouched, as resolveLibavPath already
does on the device side.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4N6KsxzvT1o9qoPvBThyz
Three things were wrong at once.

The drop handlers stored score::FilePath::relative, so a shader under the
document folder or the library arrived as "<PROJECT>:Shaders/x.fs" and every
model opened it with a bare QFile. It only ever worked in an unsaved
document, where relativize hands back an absolute path. The models resolve
through locateShaderPath now, as Gfx::Video::Model already did.

One handler could claim an extension: m_perFileExtension assigned rather than
appended, so of the four families sharing .fs exactly one survived, chosen by
hash order. The list asks every handler registered for the extension, and
each sniffs its own MODE marker and stays silent otherwise -- which also lets
raw raster have a drop handler at all, from the other plugin, without either
plugin having to name the other's process.

Vertex pairing used QString::replace, rewriting every occurrence in the path
(a directory named x.fs broke it), and RenderPipeline used baseName(), which
truncates at the first dot, so my.shader.fs looked for my.vs. Both go through
one sibling helper built on completeBaseName. Dropping a .fs and its .vs now
yields one process: the .vs half is refused by VSA when a fragment sibling
exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4N6KsxzvT1o9qoPvBThyz
@jcelerier
jcelerier force-pushed the workshop/nodal-ui-batch branch from 8a1b21f to 38571a7 Compare September 21, 2026 15:24
celtera/avendish#208 merged with a rebase, so the commit the pointer named
only ever existed on its PR branch. Point at what landed on main instead,
before that branch goes away and the submodule stops resolving.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4N6KsxzvT1o9qoPvBThyz
@jcelerier
jcelerier merged commit c92fc08 into master Sep 21, 2026
9 of 25 checks passed
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.

1 participant