Repository navigation
Add a spatial view beside the anchor pickers - #85
Conversation
NodeModel.update() parsed the leading-space / '*' filter prefixes and built the searchable ui-name inline. Both are needed verbatim by any other search field that wants to behave like the pickers, so lift them out as parse_search_modes() and menupath_uiname() and have NodeModel call them. No behaviour change.
A popup that lays the script's anchors out as cards on a coarse grid whose cells echo where each anchor sits in the DAG, with labelled backdrops drawn as outlines around the cards they contain (issue #83). Recognising a place is faster than recalling a name. The grid is binned, not to scale: coordinates within a tolerance share a row or column, so the empty space between modules is squeezed out while the arrangement is preserved. Colliding cards stack down their own column, which keeps a card inside the column its module occupies. The tolerance widens until the grid fits its maximum extent, so a sprawling script still reads. The pickers' fuzzy search carries into the view: the filter field honours the space-prefix search modes, and non-matching cards grey out rather than disappearing so the map keeps its shape. Arrow keys move between matching cards spatially. Both modes borrow the matching picker's tabtabtab plugin, so item collection, node colours, invocation and the on-disk selection weights are shared with the pickers instead of reimplemented — a card picked here also sorts first in A / Alt+A. The grid maths and the filtering are module-level functions so they are testable without a Qt session; the widgets are only defined when Qt imports, following the pattern in colors.py.
Alt+S opens it for navigation, mirroring Anchor Find; Edit > Anchors > Spatial View (Create Link) opens it for link creation, mirroring Create Link. The leader binding takes the free S cell, which sits where the key does on the physical keyboard grid.
Covers the DAG-to-grid mapping (order preserved, nearby nodes sharing a row/column, collisions stacking down their column, the grid capped, backdrops spanning the cells of the anchors inside them), the fuzzy filter including the space-prefix modes and the selection weights, spatial arrow movement, what each mode lists, and the guards that make the command a silent no-op. The shared stubs replace tabtabtab_anchors with a bare module, so the real (Qt-free) search functions are loaded onto it here — the filter is exercised against the code that actually ships.
Adds the guide section, the README section, and the shortcut in every keyboard table, extends the space-prefix search-mode prose to cover the new search field, and adds the gui.json scenario that captures the popup. The screenshot itself needs a licensed Nuke plus nuke-screenshotter, so the figure is marked in the guide for the next 'make screenshots' run; the PDF is rebuilt with 'make pdf' as usual.
There was a problem hiding this comment.
🟡 Changes recommended
The spatial view docs introduce a placeholder for a screenshot that isn’t present/committed (and related deliverables need to be kept in sync), and there are a couple of concrete doc/UI text inconsistencies to fix.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds a new “spatial view” UI for navigating anchors/backdrops (and creating links) by laying them out on a simplified grid that mirrors DAG geometry, while reusing the existing picker search/weighting behavior.
Changes:
- Introduces
spatial_view.pyimplementing grid layout + fuzzy filtering (Qt UI gated on Qt availability) and adds unit tests for the non-Qt logic. - Refactors shared picker search parsing/labeling into
tabtabtab_anchors.parse_search_modes()andmenupath_uiname()and wires the new view into menus + leader-key bindings. - Updates user-facing docs/README and adds a screenshot capture scenario for the new popup.
File summaries
| File | Description |
|---|---|
| tests/test_spatial_view.py | New unit tests covering spatial layout, filtering, navigation, and entry-point guards. |
| tabtabtab_anchors.py | Extracts shared search parsing + menu-path UI naming helpers for reuse by the spatial view. |
| spatial_view.py | New spatial view implementation (layout/filter pure functions + Qt popup when available). |
| README.md | Documents the new Spatial View feature, shortcuts, and API entry points. |
| menu.py | Adds menu commands for Spatial View (navigate + create-link mode). |
| leader.py | Adds leader-key dispatch/binding for Spatial View. |
| docs/user-guide.md | Adds a new guide section describing Spatial View and updates shortcut references. |
| docs/screenshots/scenarios/gui.json | Adds a GUI screenshot scenario for the Spatial View popup. |
| constants.py | Adds spatial view layout/size constants and adds the leader binding tuple for S. |
Review details
- Files reviewed: 9/10 changed files
- Comments generated: 3
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
The docstring claimed the returned text had "any prefix removed", but a leading '[' is deliberately kept: it is part of the bracketed menu path in an item's ui-name, so it has to reach the matcher.
Navigate mode lists labelled backdrops alongside the anchors and filters both, so "Search anchors" undersold what the field searches. Link mode, where backdrops are context only, keeps the anchors-only wording.
Replaces the commented-out placeholder in the guide with the generated figure and rebuilds the PDF.
"Show the spatial view beside the A and Alt+A menus", off by default, persisted with the other prefs and set from a checkbox in the Preferences dialog. The pickers read it on every open, so it applies without a restart.
With the preference on, the A, Alt+A and leader Set Input pickers open with a map of the current group beside their search list. Items keep their real DAG arrangement with the blank space between them collapsed: anchors are tiles, Dot anchors circles sized by their label-size preset, and labelled backdrops frames around what they enclose (dashed when they enclose no anchor). Overlapping items are nudged apart. The map follows the picker rather than running its own search: typing greys out the items the list filters away, the highlighted row is outlined, and a click picks the item. TabTabTabWidget gains a _build_layout() hook for this, and parse_search_modes()/menupath_uiname() go back inline in NodeModel now that nothing else matches. The standalone popup's Alt+S, leader S and Anchors-menu entries are removed.
Rewrites the guide and README sections for the map beside the pickers, drops Alt+S and leader S from every keyboard table, lists the new preference, and recaptures the screenshot: the doc-only menu.py pins the preference off for the plain picker shots and opens Alt+A with it on for this one. The PDF is rebuilt.
The layout used to space leaf items globally, frame them, then push overlapping items apart half-and-half, which could swap modules or stack side-by-side ones vertically. Frames are now laid out innermost first and each moves as one block among its siblings. Along each axis items keep the DAG order of their centres and take the smallest position that honours both the compressed DAG gap and the item gap from any sibling they are separated from on that axis. A pair is separated on the axis where its DAG gap, normalised by the pair's size, is larger; rows are settled first, so items side by side in the DAG stay side by side and diagonal neighbours only move sideways if their rows still overlap. Tiles and Dots carry their DAG size for this. The guide now says nothing changes places rather than that items are nudged apart. Claude-Session: https://claude.ai/code/session_014WK3xFqDQt7o9GZgUPAzmg
validation/build_large_script.py writes, under nuke -t, a busy feature-shot comp: 211 anchors in 28 labelled modules (two with nested backdrops), three empty note backdrops, and 108 Dot anchors down six comp branches. Its map overflows an HD screen in both directions. Nodes are made with the plugin's own create_anchor_named and mark_dot_as_anchor. Claude-Session: https://claude.ai/code/session_014WK3xFqDQt7o9GZgUPAzmg
On a script whose map does not fit beside the list, typing now zooms and scrolls the map to frame the items that still match, never shrinking it below SPATIAL_MIN_FIT_ZOOM where labels stop being readable. A - / % / + / Fit overlay, Ctrl+wheel and Ctrl+= / Ctrl+- zoom by SPATIAL_ZOOM_STEP within the user range; once the user zooms, typing only scrolls the highlighted match into view until Fit, Ctrl+0 or reopening the picker hands the zoom back to the search. A minimap in the bottom-right, shown while the map overflows, outlines the visible part and pans on click or drag; the scrollbars are gone. fit_zoom and clamp_user_zoom hold the limits and are unit-tested. The guide describes the zoom, its shortcuts and the minimap; the screenshot now shows the zoom overlay and the PDF is rebuilt. Claude-Session: https://claude.ai/code/session_014WK3xFqDQt7o9GZgUPAzmg
… view Clicking the zoom buttons, the minimap or empty map moved keyboard focus to the scroll area, so typing stopped reaching the search field. The scroll area no longer takes focus. The zoom controls move to the bottom-right corner, under the minimap, where they no longer cover the top-right tiles. When the highlighted match is out of sight it is scrolled to the middle of the view rather than just to its edge. The guide and screenshot follow; the PDF is rebuilt. Claude-Session: https://claude.ai/code/session_014WK3xFqDQt7o9GZgUPAzmg
To match the Node Graph, the plain mouse wheel now zooms around the pointer (Ctrl+wheel still does), and dragging with the middle button moves the map, with a closed-hand cursor while it does. The wheel is handled on the canvas as well as the viewport, so it does not depend on the event propagating; the minimap and zoom controls swallow it so it cannot scroll the view from under them. A middle click on a map item no longer picks it: only the left button does. The guide describes the new mouse moves; the PDF is rebuilt. Claude-Session: https://claude.ai/code/session_014WK3xFqDQt7o9GZgUPAzmg
Every zoom and scroll the picker makes now glides there over SPATIAL_VIEW_ANIMATION_MS rather than jumping: fitting to the matches, wheel and button zooms, minimap clicks, and scrolling a match into view. interpolate_view zooms geometrically, so a wheel zoom keeps the point under the pointer still on every frame, and zooms made while one is gliding start from where it is heading, so quick notches add up. Whether the highlighted match needs scrolling is judged against that target view (visible_span), so a fit still gliding is not undone. Opening the picker and dragging in the minimap or with the middle button move the view at once. Painting the map no longer recomputes colours, fonts and elided names on every repaint, paints only the items in the exposed area, and the minimap draws from a picture redrawn only when the map changes. The guide says the view glides; the PDF is rebuilt. Claude-Session: https://claude.ai/code/session_014WK3xFqDQt7o9GZgUPAzmg
Without it a tagged release raises on "import spatial_view" when the spatial view preference is on. Claude-Session: https://claude.ai/code/session_014WK3xFqDQt7o9GZgUPAzmg
charlesangus
left a comment
There was a problem hiding this comment.
Reviewed by Codex (codex: available — 5h 0%, 7d 5% (cap 90%, plan plus)). Found issues in the spatial layout guarantees, Qt-less import path, Python 3.7 test compatibility, and a stale de-overlap comment.
| for item in node.body: | ||
| if isinstance(item, ast.FunctionDef) and item.name == method_name: | ||
| lines = source_text.splitlines() | ||
| return '\n'.join(lines[item.lineno - 1:item.end_lineno]) |
There was a problem hiding this comment.
minor: ast.FunctionDef.end_lineno is unavailable on Python 3.7, but the supported older Nuke range includes Python 3.7. This test helper will fail before it can verify the preference dialog on that runtime.
Suggestion: Avoid end_lineno; compute the slice end from the next sibling node's lineno, the class end, or use inspect.getsource on the loaded method where available.
There was a problem hiding this comment.
Fixed in a5928c0. The helper no longer uses end_lineno: each method ends where the next statement at its indent or shallower begins.
There was a problem hiding this comment.
Not changing: the plugin targets Nuke 16+, which ships Python 3.10 or later, so end_lineno is always available.
…ial view A frame moved as one block was ordered among its siblings by its own centre only, so an item above or beside a backdrop could land on the wrong side of the anchors inside it. Packing along an axis is now a set of least distances solved as longest paths. Between a frame and a sibling it is not kept apart from on that axis, every point the frame carries (its corners and the centres of the items inside it) must stay on the same side of the sibling's points as in the DAG, by half their compressed spacing. The distances that only push forward along the order are always met; those that point back, where a block interleaves with a sibling, are added nearest pair first and dropped only where they would contradict the rest. Laying out the 244-item validation comp goes from 573 to 3 horizontally swapped anchor pairs, taking 0.27 s instead of 0.17 s. spatial_view no longer imports tabtabtab_anchors when Qt is missing, so the pure layout functions and the SpatialPicker = None fallback work without PySide. The SPATIAL_ITEM_GAP comment no longer mentions the removed de-overlap pass. The guide's screenshot is recaptured and the PDF rebuilt. Claude-Session: https://claude.ai/code/session_014WK3xFqDQt7o9GZgUPAzmg
ae4fcb5 to
961437c
Compare
…erence Resolves the prefs.py global-declaration conflict between the two independently developed prefs (picker_scroll_enabled, spatial_view_enabled). Also fixes a semantic gap the merge surfaced: open_picker() and SpatialPicker never threaded picker_scroll_enabled through, so the spatial picker silently ignored the "scroll through all fuzzy-find results" preference and always behaved as if it were off. Wires scroll_enabled through open_picker's creation and reuse paths, mirroring how anchor.py already re-applies it to cached plain pickers, and adds regression coverage. Regenerates docs/anchors-user-guide.pdf via `make pdf` to include both PRs' prose (no screenshot changes needed). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014WK3xFqDQt7o9GZgUPAzmg
Closes #83
Adds a spatial view: a map of the current group drawn beside the anchor
pickers, so you can find an anchor by where it is as well as by name. It is a
preference, "Show the spatial view beside the A and Alt+A menus", and it's off
by default. With it on, the
A,Alt+Aand leader Set Input pickers open withthe map beside their search list. With it off, the pickers are unchanged.
(The first version of this PR was a separate
Alt+Spopup with its own search.It was reworked into the pickers, and has since gained order-keeping packing and
zoom/navigation for big scripts.)
What the map shows
Items sit where they really are in the DAG, with the blank space between them
squeezed out along each axis (gaps are scaled, then capped). Nothing changes
places: a module to the right of another in the DAG is to its right on the map,
and one above another stays above it.
sizes that follow whichever
Shift+B/N/Mlabel preset the Dot's labelsize is closest to.
that encloses no anchor is drawn as a faded, dashed box, so it never looks like
an anchor.
Packing works innermost frame first: a frame's contents are packed, then the
frame moves as one block among its siblings.
the larger (size-normalised) DAG gap, so items side by side in the DAG stay
side by side.
distances:
sibling's items as in the DAG.
pairs are kept first. A far pair is dropped only if it contradicts the rest.
On the 244-item validation comp, this takes horizontally swapped anchor pairs
from 573 to 3, and layout from 0.17 s to 0.27 s.
How it ties into the picker
The map follows the picker and doesn't run a search of its own. Typing filters
the list as usual, and the map greys out the items the list has filtered away.
The highlighted row is outlined on the map, so
Up/Downmove through the mapas well, and clicking a map item picks it exactly as choosing its row would. In
the
Apicker, backdrops are context only and can't be clicked. Item collection,colours, invocation and selection weights are all the existing plugins', so none
of that is duplicated.
Big scripts: zoom and navigation
When the map doesn't fit beside the list at a readable size:
still matches, but never below
SPATIAL_MIN_FIT_ZOOM(0.7), where labels stopbeing legible.
the pointer, and a middle-button drag pans. The − / % / + / Fit controls sit
bottom-right, and
Ctrl+=/Ctrl+-zoom too.the highlighted match is out of sight, bringing it to the middle. Fit
(
Ctrl+0) or reopening the picker hands the zoom back to the search.outlines the visible part, and a click or drag in it moves the view there.
carries on.
SPATIAL_VIEW_ANIMATION_MS(180 ms).The zoom interpolates geometrically, so the point under the pointer stays put.
Quick successive zooms add up.
per set of entries, only the exposed items are painted, and the minimap draws
from a cached picture.
What's in it
spatial_view.pyholds the layout (axis compression, backdrop containment,order-keeping packing) and the zoom maths (
fit_zoom,clamp_user_zoom,interpolate_view,visible_span) as module-level functions with no Qt and nonuke, so they're unit-tested.SpatialPickersubclassesTabTabTabWidget,MapCanvasdraws the map, andMinimap/ZoomControlsare overlays on thescroll area. They are defined only when Qt imports, following the
colors.pypattern.
constants.pyadds the zoom limits, zoom step and animation duration.tabtabtab_anchors.py:TabTabTabWidgetgains a_build_layout()hook sothe subclass can put the map beside the input and list. The earlier
parse_search_modes()/menupath_uiname()extraction is reverted, becausenothing outside
NodeModelmatches any more.anchor.py:select_anchor_and_create,select_anchor_and_navigateandpick_anchoropen the spatial picker when the preference is on, and fall backto the plain picker otherwise.
prefs.py(persisted) with a checkbox in thePreferences dialog. It's read on every open, so changing it doesn't need a
restart.
validation/build_large_script.py(nuke -t … out.nk) builds a busy compfor trying the view at scale. It has 211 anchors in 28 modules (some nested), 3
empty note backdrops and 108 Dot anchors, and makes them with the plugin's own
create_anchor_named/mark_dot_as_anchor..github/workflows/release.ymlcopiesspatial_view.pyinto the releaseZIP. Without it, a tagged release raises on
import spatial_viewwhen thepreference is on.
Alt+Sshortcut, leaderS, and the twoAnchors-menu entries.
and gliding, and the README section is rewritten.
Alt+Sand leaderSaregone from every keyboard table. The screenshot is recaptured and the PDF
rebuilt.
Verification
pytest tests/passes (714 tests). New tests cover:These tests fail on both the old push-apart code and the frame-centre-only
packing;
importing without Qt, and a stale comment. The fourth, Python 3.7 in a test,
doesn't apply to Nuke 16+.
scripted runs:
same clicks lose focus on the previous code);
Smoothness wants a look on real hardware.
ruffreports only warnings that were already there (long lines,B023, andthe complexity of
build_layout). CI doesn't lint.🤖 Generated with Claude Code
https://claude.ai/code/session_014WK3xFqDQt7o9GZgUPAzmg