Skip to content

Add a spatial view beside the anchor pickers - #85

Merged
charlesangus merged 20 commits into
mainfrom
agent/issue-83
Sep 29, 2026
Merged

charlesangus merged 20 commits into
mainfrom
agent/issue-83

Conversation

@charlesangus

@charlesangus charlesangus commented Aug 31, 2026 •

Copy link
Copy Markdown
Owner

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+A and leader Set Input pickers open with
the map beside their search list. With it off, the pickers are unchanged.

(The first version of this PR was a separate Alt+S popup with its own search.
It was reworked into the pickers, and has since gained order-keeping packing and
zoom/navigation for big scripts.)

The spatial view beside the Alt+A menu

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.

  • Anchors are coloured tiles.
  • Dot anchors are circles with their label beside them. They come in three
    sizes that follow whichever Shift+B/N/M label preset the Dot's label
    size is closest to.
  • Labelled backdrops are frames around the items they enclose. A backdrop
    that encloses no anchor is drawn as a faded, dashed box, so it never looks like
    an anchor.
  • Local Dots are left out.

Packing works innermost frame first: a frame's contents are packed, then the
frame moves as one block among its siblings.

  • Separation. Each pair of siblings is kept apart on one axis, the one with
    the larger (size-normalised) DAG gap, so items side by side in the DAG stay
    side by side.
  • How each axis is packed. Each axis is solved as longest paths over least
    distances:
    • single items keep their compressed DAG spacing;
    • items kept apart on this axis also clear each other;
    • a frame keeps its corners and everything inside it on the same side of its
      sibling's items as in the DAG.
  • Contradictions. Where a frame interleaves with a sibling, the nearest
    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/Down move through the map
as well, and clicking a map item picks it exactly as choosing its row would. In
the A picker, 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:

  • Typing fits the view to the matches. It zooms and scrolls to frame what
    still matches, but never below SPATIAL_MIN_FIT_ZOOM (0.7), where labels stop
    being legible.
  • The user can take over, as in the Node Graph. The mouse wheel zooms around
    the pointer, and a middle-button drag pans. The − / % / + / Fit controls sit
    bottom-right, and Ctrl+= / Ctrl+- zoom too.
  • A user zoom sticks. Once the user zooms, typing only scrolls, and only when
    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.
  • A minimap sits above the zoom controls while the map overflows. It
    outlines the visible part, and a click or drag in it moves the view there.
  • Clicking the map or its controls keeps the search field focused, so typing
    carries on.
  • Every zoom and scroll glides over SPATIAL_VIEW_ANIMATION_MS (180 ms).
    The zoom interpolates geometrically, so the point under the pointer stays put.
    Quick successive zooms add up.
  • Painting is cheaper. Colours, fonts and elided names are worked out once
    per set of entries, only the exposed items are painted, and the minimap draws
    from a cached picture.

What's in it

  • spatial_view.py holds 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 no
    nuke, so they're unit-tested. SpatialPicker subclasses TabTabTabWidget,
    MapCanvas draws the map, and Minimap / ZoomControls are overlays on the
    scroll area. They are defined only when Qt imports, following the colors.py
    pattern.
  • constants.py adds the zoom limits, zoom step and animation duration.
  • tabtabtab_anchors.py: TabTabTabWidget gains a _build_layout() hook so
    the subclass can put the map beside the input and list. The earlier
    parse_search_modes() / menupath_uiname() extraction is reverted, because
    nothing outside NodeModel matches any more.
  • anchor.py: select_anchor_and_create, select_anchor_and_navigate and
    pick_anchor open the spatial picker when the preference is on, and fall back
    to the plain picker otherwise.
  • Preference: added in prefs.py (persisted) with a checkbox in the
    Preferences 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 comp
    for 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.yml copies spatial_view.py into the release
    ZIP. Without it, a tagged release raises on import spatial_view when the
    preference is on.
  • Removed: the standalone popup's Alt+S shortcut, leader S, and the two
    Anchors-menu entries.
  • Docs: the guide describes the map, packing, zoom, mouse controls, minimap
    and gliding, and the README section is rewritten. Alt+S and leader S are
    gone from every keyboard table. The screenshot is recaptured and the PDF
    rebuilt.

Verification

  • pytest tests/ passes (714 tests). New tests cover:
    • packing order, including items inside a backdrop against items outside it.
      These tests fail on both the old push-apart code and the frame-centre-only
      packing;
    • zoom limits;
    • the view interpolation.
  • A Codex review found four issues. Three are fixed: the packing order above,
    importing without Qt, and a stale comment. The fourth, Python 3.7 in a test,
    doesn't apply to Nuke 16+.
  • The Qt behaviour was checked in a real Nuke 17 session on the large comp, using
    scripted runs:
    • the fit / stick / Fit / reopen zoom sequence;
    • search focus after clicking the zoom buttons, the minimap and empty map (the
      same clicks lose focus on the previous code);
    • wheel zoom keeping the point under the pointer;
    • middle-drag panning, and middle-click not picking;
    • glides landing on their targets.
  • Those runs were headless under Xvfb, so they check correctness only.
    Smoothness wants a look on real hardware.
  • ruff reports only warnings that were already there (long lines, B023, and
    the complexity of build_layout). CI doesn't lint.

🤖 Generated with Claude Code

https://claude.ai/code/session_014WK3xFqDQt7o9GZgUPAzmg

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.
Copilot AI lite review requested due to automatic review settings August 31, 2026 05:20

Copilot AI 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.

🟡 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.py implementing 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() and menupath_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.

Comment thread tabtabtab_anchors.py Outdated
Comment thread spatial_view.py Outdated
Comment thread docs/user-guide.md Outdated
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.
@charlesangus charlesangus changed the title Add a spatial view of the anchors and backdrops Add a spatial view beside the anchor pickers Sep 28, 2026
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 charlesangus left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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.

Comment thread spatial_view.py Outdated
Comment thread spatial_view.py Outdated
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])

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in a5928c0. The helper no longer uses end_lineno: each method ends where the next statement at its indent or shallower begins.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Not changing: the plugin targets Nuke 16+, which ships Python 3.10 or later, so end_lineno is always available.

Comment thread constants.py Outdated
…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
…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
@charlesangus
charlesangus merged commit 8de71ee into main Sep 29, 2026
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.

Add a "spatial view" of the Anchors/Backdrops

2 participants