Nodal cable editing, missing-files search, preview toggle, Patternist ports, device search - #2295
Merged
Merged
Conversation
jcelerier
force-pushed
the
workshop/nodal-ui-batch
branch
from
September 21, 2026 15:09
2521664 to
8a1b21f
Compare
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
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
force-pushed
the
workshop/nodal-ui-batch
branch
from
September 21, 2026 15:24
8a1b21f to
38571a7
Compare
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A batch of ten requested items. Depends on celtera/avendish#208 — the submodule pointer is bumped to it, and
Crousti/Executor.hppreferences the flag it adds, so score will not build against the previous pointer. Companion addon changes in ossia/GBAP#37.Nodal graph
ReplaceCablemacro. 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.[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_PREVIEWcould 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.TexgenNodereallocates its texture on a render-size change and patches the SRBs that exist then, but leftm_samplers[0].texturedangling — andaddOutputPassbindsm_samplersinto passes built later, whichshift+Deletecreates by moving the removed process's address onto its source.ImagesNode::OnTheFlyRendererhad 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
QuantificationWidgetalready uses, so they reachget_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::writereadaccent/slidea second time afterload_value_outlethad 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
Radius→Aspectrename 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
insertProcessInCablehardcodesImmediateGluttonrather than runningonCreateCable's cycle walk — pre-existing, but the new gesture makes it easier to reach.test_integration_js_remove_cableandtest_live_edit.cable-stormare red on this branch and were verified red onmasterbeforehand.🤖 Generated with Claude Code
https://claude.ai/code/session_01H4N6KsxzvT1o9qoPvBThyz