Bundles

Buttons, stacks and pulldowns

The three things a panel holds most often, what each one costs you in space, and nine narrower kinds for when a plain button is not quite the shape you need.

A panel has room for a handful of large buttons before it starts pushing other panels off the ribbon. Stacks and pulldowns exist to buy that space back: a stack trades icon size for three tools in one column, a pulldown trades one click for an unlimited menu.

Pushbuttons, stacks and pulldowns are folders under a *.panel and the three you will use for almost everything. A panel also understands nine narrower suffixes: *.nobutton for a keyboard-only command, *.urlbutton and *.linkbutton for pointing at something outside your own script, *.toggle for on/off state, *.smartbutton for a button that configures its own appearance, *.splitbutton/*.splitpushbutton for a pulldown whose header also runs a child, *.dockpane for a real Navisworks dock panel instead of a script that runs and returns, and *.slideout for the tools that belong in the panel's flyout rather than on its face. More button kinds covers all nine; a folder inside a panel whose name has a dot but is not one of these twelve suffixes is logged as a typo and counted into the N bundle folders were skipped toast pyNavis shows after every scan.

The three panel items#

 *.pushbutton*.stack*.pulldown
Renders as One large button 2 or 3 small buttons in one column One large button that opens a menu
Children None Exactly 2 or 3 *.pushbutton 1 or more *.pushbutton
Has its own bundle.yaml Yes No, it is never read Yes, but only title, tooltip, keytip
Has its own icon Yes No Yes
Runs a script Yes, script.py No, its children do No, its children do
Gets a keytip Yes Its children do Yes, its children do not
Icon slot used Large (32 logical) Small (16 logical) Large for itself, small for every menu row
Panel width One narrow column One wide column One narrow column

What a panel looks like#

This is the shipped Memory panel: a large button, a three-row stack, a two-row stack and a pulldown with its menu open. Drawn roughly to scale, because the relative sizes are the whole reason to choose one over another.

.pushbutton .stack (3) .stack (2) .pulldown Memory Memorize Add Subtract Intersect Prev Next Memory Show Contents Save as Set Purge the pulldown's children The pulldown draws its large icon; every stack row and every menu row draws the small one.
Four panel items, six tools. The three-row stack occupies the same vertical space as one large button and holds three times as much.

Pushbuttons#

A *.pushbutton placed directly on a panel is a large button: 32-logical icon on top, label underneath, one click runs script.py. It is the default and it is what a tool should be unless you have a reason otherwise.

folders
Memory.panel\
  01_Memorize.pushbutton\
    script.py              # required
    config.py              # optional, Shift+Click
    bundle.yaml            # title, tooltip, engine, shortcut, keytip
    icon.png               # 96x96
    icon.dark.png
    icon.small.png         # 32x32, used if this button ever moves into a stack
    icon.small.dark.png

Only script.py is required. Without it the folder is dropped and the reason is logged, '<dir>' has no script.py - skipped., which is also one of the lines that feeds the warning toast after a scan. A missing or misnamed script is still the first thing to check when a button does not appear.

Ship the small icons anyway

A top-level button uses the large slot, but a bundle you may later move into a stack or a pulldown will need the small art. Generating all four variants costs nothing and means reorganising a panel never degrades an icon. See Icons.

Stacks#

A *.stack renders as two or three small buttons filling one panel column. Each row is a full button with its own script, icon, tooltip, shortcut and keytip; the stack itself is only a container.

folders
Memory.panel\
  03_Set.stack\                    # no script.py, no bundle.yaml, no icon
    01_Add.pushbutton\
      script.py
      icon.small.png
    02_Subtract.pushbutton\
      script.py
      icon.small.png
    03_Intersect.pushbutton\
      script.py
      icon.small.png
A stack has no bundle of its own

The parser never opens bundle.yaml inside a .stack folder and never looks for an icon there. The folder name is not shown anywhere either: it exists purely to group and to sort. Put your metadata on the children.

Only pushbuttons, and the parser says so

A stack collects *.pushbutton folders and nothing else. Any other bundle-looking folder inside it is logged, '<dir>' is ignored: a stack holds only *.pushbutton folders., so a toggle or a nested stack you dropped in there is not a silent miss. Pulldowns and the two split buttons enforce the same rule with the same message.

The 2-or-3 rule#

The underlying ribbon control holds two or three rows and nothing else. Rather than let it render wrong, pyNavis drops the stack at parse time and says so:

%APPDATA%\pyNavis\logs\pyNavis.log
Stack 'D:\NavisTools\...\03_Set.stack' holds 1 button(s); a stack needs 2 or 3 - skipped.

The count is of usable children, so a stack of three where one child lost its script.py survives as a two-row stack, and a stack of two where one child broke disappears entirely. That is the usual way a working stack vanishes after an edit.

Do
03_Set.stack\
  01_Add.pushbutton\
  02_Subtract.pushbutton\
  03_Intersect.pushbutton\
Don't
03_Set.stack\
  01_Add.pushbutton\
  02_Subtract.pushbutton\
  03_Intersect.pushbutton\
  04_Invert.pushbutton\    # 4 rows: the whole stack is dropped
  05_Grow.stack\           # nested stacks are logged and ignored

When to use a stack#

  • Two or three tools that belong to one idea and are used at similar frequency, such as Add, Subtract, Intersect.
  • Tools whose meaning survives a 16-pixel icon and a short label. A stack row is horizontal, so a long title makes the column wide and eats the space you were trying to save.
  • Never for a single tool: a one-button stack does not render.

Pulldowns#

A *.pulldown is a large button with no default action. Clicking it opens a menu of its children. It is the only item that scales past three tools without consuming panel width, so it is where the long tail goes.

folders
Memory.panel\
  05_Memory.pulldown\
    bundle.yaml                    # title, tooltip, keytip only
    icon.png                       # 96x96, the face of the button
    icon.dark.png
    icon.small.png                 # 32x32
    icon.small.dark.png
    01_Show_Contents.pushbutton\
      script.py
    02_Save_as_Set.pushbutton\
      script.py
    03_Purge.pushbutton\
      script.py
05_Memory.pulldown\bundle.yaml
title: Memory
tooltip: Inspect the memory, promote it to a set, or delete stored memory files

What a pulldown's bundle.yaml can say#

KeyOn a pulldown
titleRead. Falls back to the folder name with the prefix stripped.
tooltipRead. There is no docstring to fall back to, so without this key the button has no tooltip.
keytipRead. Overrides the assigned Alt-navigation letters.
shortcutIgnored. A pulldown has no action to trigger, so there is nothing for a chord to run.
engineIgnored. A pulldown runs no code. Put engine: on the children.

The children are ordinary pushbuttons and lose nothing by being in a menu. Each may declare its own engine, its own shortcut, its own config.py, and each responds to Shift+Click and Alt+Click exactly as a top-level button does.

Menu rows get no keytip

Alt-navigation assigns letters to top-level buttons, stack rows and the pulldown itself, but not to a pulldown's children: once the menu is open you arrow through it. If a buried tool needs to be reachable from the keyboard, give it a shortcut: instead. Shortcuts explains both mechanisms.

The empty-pulldown rule#

One usable child is enough. Zero is not:

%APPDATA%\pyNavis\logs\pyNavis.log
Pulldown 'D:\NavisTools\...\05_Memory.pulldown' has no usable pushbuttons - skipped.

A pulldown folder that contains a .stack or another .pulldown and nothing else counts as empty: only *.pushbutton children are collected, and each nested container is logged as '<dir>' is ignored: a pulldown holds only *.pushbutton folders. before the pulldown itself is dropped for being empty.

More button kinds#

Nine more panel-item suffixes, each a narrow variation on a pushbutton, a pulldown or the panel itself rather than a new concept. All nine sort into the panel by folder name exactly like the three above, and every icon rule from Icons applies unless a section below says otherwise.

nobutton: a command with no ribbon presence#

A *.nobutton is parsed exactly like a pushbutton, script.py and bundle.yaml and all, but the ribbon never draws it. It exists to be reached only by a keyboard shortcut: or a user binding from the Shortcuts editor.

folders
Utility.panel\
  Rebuild_Index.nobutton\
    script.py
    bundle.yaml         # give it a shortcut, since there is no button to click
Rebuild_Index.nobutton\bundle.yaml
title: Rebuild index
tooltip: Rebuilds the property index this session caches.
shortcut: Ctrl+Alt+R

Everything else about it is an ordinary bundle: the same bundle.yaml keys, the same min_host_version/max_host_version gating, the same engine resolution. It still counts toward the tools listed for shortcut binding, so it appears in the Shortcuts editor beside every ordinary button.

Two things do not carry over, because both need a control to act on. context: is parsed but never evaluated: greying is applied to rendered buttons, and a nobutton is never rendered, so a chord fires it whatever the document holds. And there is no way to reach config.py, because Shift+Click needs a button and a keyboard chord always runs the primary script. If a nobutton needs options, give them their own bundle.

Give it a shortcut, or nothing can reach it

A nobutton with no author shortcut: still loads. Without one, and without a user override added in the Shortcuts editor, there is no way to run it at all.

A *.urlbutton ships no script.py. Its whole job is a url: key in bundle.yaml; clicking it opens that address in the default browser and runs nothing else.

folders
Help.panel\
  Documentation.urlbutton\
    bundle.yaml
    icon.png
Documentation.urlbutton\bundle.yaml
title: Documentation
url: https://example.com/docs

If the address cannot be opened, the failure is logged and toasted rather than raised into the ribbon. bundle.yaml has the full key table.

linkbutton: runs another Navisworks plugin#

A *.linkbutton also ships no script. Its required key is plugin:, the target add-in's plugin id in Id.DeveloperId form. Use it to put someone else's plugin on your own panel instead of sending users hunting through the Add-Ins tab.

Legacy_Tool.linkbutton\bundle.yaml
title: Legacy Tool
plugin: LegacyTool.ACME

A plugin id that is not registered, or that resolves to something other than an add-in plugin, toasts a warning naming the id instead of failing silently. bundle.yaml has the full key table.

toggle: a button with on/off state#

A *.toggle is a pushbutton that also tracks a session-scoped boolean and swaps its icon to match. script.py and bundle.yaml work exactly as they do on a plain pushbutton; the additions are two extra icon pairs and two functions in pynavis.script.

folders
Snap.toggle\
  script.py
  bundle.yaml
  icon.on.png          # 96x96, shown while the toggle is on
  icon.on.dark.png
  icon.off.png         # 96x96, shown while the toggle is off, and at startup
  icon.off.dark.png
Snap.toggle\bundle.yaml
title: Grid snap
tooltip: Turns grid snapping on and off for this session.
shortcut: Ctrl+Shift+G
Snap.toggle\script.py
"""Turns grid snapping on and off."""
from pynavis import script, toast

on = not script.get_toggle_state()
script.set_toggle_state(on)
toast.info('Snap ' + ('on' if on else 'off'))
Icon fileFalls back to
icon.off.pngicon.png
icon.off.dark.pngicon.off.png
icon.on.pngicon.png
icon.on.dark.pngicon.on.png

Every toggle starts off. script.get_toggle_state() reads the current state; script.set_toggle_state(on) sets it and repaints the icon immediately.

Session-scoped, not saved, and not settable from outside the toggle

Toggle state lives in memory for the running session, keyed by the bundle's own path. It survives Reload, because Reload rebuilds the ribbon without resetting state, but it does not survive a restart, and there is no way to set it from anywhere except the toggle's own script.py or config.py: get_toggle_state and set_toggle_state act on whichever bundle is currently running, and neither a hook nor startup.py runs as a particular bundle. A toggle that must come back on after a restart needs its own pynavis.settings entry, read by its own script.py on the first click of a new session.

smartbutton: sets its own title, tooltip and icon at build time#

A *.smartbutton runs script.py twice: once while the ribbon is being built, so the script can configure its own appearance before anyone sees it, and once per click, exactly like a pushbutton. A global, __selfinit__, tells the script which run it is in.

ClashCount.smartbutton\script.py
if __selfinit__:
    __button__.Title = 'Clash (0)'
    __button__.Enabled = True
else:
    from pynavis import toast
    toast.info('Clicked!')

On the build-time run, __selfinit__ is True and __button__ is a small proxy over the live ribbon control:

MemberDoes
__button__.TitleRead/write. The button caption.
__button__.EnabledRead/write. Greys the button out when set to False.
__button__.TooltipWrite-only. The hover tooltip.
__button__.SetIcon(path)Points both the light and dark icon slots at the given PNG.

On a normal click, __selfinit__ is False and __button__ does not exist at all: guard on __selfinit__, exactly as the example does, or a click-time run raises NameError.

The build-time run's scope is smaller than a click's in the other direction too: it gets only __file__, __selfinit__, __button__ and __pynavis__, so __commandpath__ and __title__ do not exist there. A script that touches either outside the click branch works on every click and then fails its selfinit run. Derive the bundle folder from __file__ in the selfinit branch instead; pynavis.script.get_command_path() is no substitute, because a build-time run sets no command context and would read back whichever command ran last. Anything a selfinit run prints lands in the log, labelled [selfinit <bundle key>], never in an output window.

The selfinit run happens once per Reload, not once per click

The build-time run fires while the ribbon is under construction, before the button is clickable, and nothing repeats it until the next Reload rebuilds the tab. It is the place to set an initial appearance from saved state, not to poll a value that changes while pyNavis is running; drive that from a hook instead. A selfinit run that raises is logged, and the button still renders with its folder-derived title and still runs normal clicks.

splitbutton and splitpushbutton: a pulldown whose header also runs a child#

Both are pulldowns in every other respect: a folder of *.pushbutton children, their own bundle.yaml reading title, tooltip and keytip, their own icon set. The only difference from a plain *.pulldown is what clicking the large header itself does.

 *.pulldown*.splitbutton*.splitpushbutton
Clicking the headerOpens the menu Runs the last child that was run, this session Always runs the first child, by folder order
Menu arrowOnly way in Opens the menu, same as a plain pulldown Opens the menu, same as a plain pulldown
folders
Export.panel\
  01_Points.splitbutton\
    bundle.yaml                # title, tooltip, keytip
    icon.png
    01_CSV.pushbutton\
      script.py
    02_IFC.pushbutton\
      script.py
01_Points.splitbutton\bundle.yaml
title: Export points
tooltip: Exports the selection. The arrow picks the format.
keytip: EP

A splitbutton with nothing run yet this session falls back to the first child, the same as a splitpushbutton always does, so the very first click always has a defined target.

dockpane: a real Navisworks dock panel#

A *.dockpane is not a script that runs and returns: it opens a real Navisworks dock panel whose content is pane.xaml, and its ribbon button is a toggle that follows whether the panel is open, not a button you click to fire an action. It follows in both directions and at all times: Navisworks closing the panel itself (its close button, a workspace change) releases the toggle, and a Reload that rebuilds the ribbon while the panel stays open seeds the new button already pressed.

folders
Live_View.dockpane\
  pane.xaml     # required - a plain element, not a &lt;Window&gt;
  script.py     # optional, runs once when the panel's content is built
  bundle.yaml   # optional
  icon.png, icon.dark.png, icon.small.png, icon.small.dark.png
  icon.on.png, icon.on.dark.png   # optional - the pressed state while the panel is open
Live_View.dockpane\bundle.yaml
title: Live view
tooltip: A docked summary of the current selection.
engine: ironpython
keytip: LV

If script.py exists it runs once, when the panel's content is built, with the same five globals as any other bundle plus a sixth, __pane__:

MemberDoes
__pane__.Find(name) The named element from pane.xaml, or None.
__pane__.Content The root element pane.xaml produced.
__pane__.Title The panel's resolved title, read-only.
__pane__.BundleKey The panel's own bundle key, for calling pynavis.panes on itself.
__pane__.Visible Read/write. Shows or hides the panel.
Live_View.dockpane\script.py
from pynavis import selection

label = __pane__.Find('Summary')
label.Text = 'Selected: %d' % len(selection.get_items())
panes.show()/hide()/toggle() default to the wrong panel from an event handler

pynavis.panes resolves a missing bundle_key to whichever pyNavis command most recently ran, which is correct while script.py itself is running but wrong from inside an event handler wired up in it (a button's Click, a timer): the user may have clicked other tools by the time it fires. From inside a panel's own event handler, pass the bundle key explicitly, or read/write __pane__.Visible instead.

There is no config.py, no Shift+Click and no Alt+Click-to-config for a dockpane. Alt+Click still opens the bundle folder; a plain click toggles the panel open and shut instead of running a script that returns.

Five panel slots ship with pyNavis, and a new *.dockpane bundle claims a free one on the next Reload, the same as any other new bundle. The claim is recorded in config.json under panes.assignments and kept for life: Navisworks remembers a dock pane's position, size and open state per slot, so holding the same slot is what lets your panel come back where the user docked it, and lets a reinstalled extension pick its old slot and layout straight back up.

The claim key does not include the extension's name

A claim is keyed by the bundle's path relative to its own extension folder, so two different extensions can produce the identical key: a Tools.tab/Main.panel/Live_View.dockpane in each of two installed extensions collides. The runtime cannot tell the two claims apart; it logs Two dockpane bundles share the key '<key>' - their extensions will cross slot ownership. Bundle keys must be unique. and the two panes end up fighting over one slot and one saved dock layout. Name your tab, panel or dockpane folders distinctively enough that the relative path is yours alone.

Claiming a slot is instant; minting more of them (via the Panel slots button on the pyNavis tab, or pynavis.panes.add_slots(count)) needs a restart, because Navisworks only discovers dock panels by scanning types at startup. Until that restart the count can be changed again freely; once the generated assembly has loaded, it is locked for every later session, and changing the count then means closing Navisworks and deleting PyNavisPanes.dll from the pyNavis bundle by hand, which is what the error tells you. A dockpane that loses the scramble for a slot still renders its ribbon toggle; clicking it toasts instead of doing nothing, and the toast tells apart genuine overflow (every slot claimed; add more, then restart) from a slot minted this session that just needs the restart it already announced. bundle.yaml has the full key table.

The panel's title bar shows your bundle's title:, but Navisworks' View > Windows menu always lists the slot name, "pyNavis Panel N", because that menu reads the docking library's own labels rather than the window caption. The same menu lists every slot whether a bundle has claimed it or not, so a clean install shows five entries and generating extra slots adds more; opening an unclaimed one gives a panel reading "Panel slot N is not in use". Neither is something a bundle can change.

slideout: the tools behind the panel title#

A *.slideout is the one panel item that is not a button at all and has no bundle of its own. It is a marker folder: everything inside it is emitted after a panel break, so the host hides it until the user clicks the panel title along the bottom of the panel, the same flyout the native Tags panel uses. It costs no ribbon width, which is the point.

folders
Export.panel\
  01_Points.pushbutton\      # on the face of the panel
  02_Mesh.pushbutton\
  99_More.slideout\          # no bundle.yaml, no icon, no script.py
    01_Settings.pushbutton\
      script.py
    02_Formats.pulldown\
      bundle.yaml
      01_CSV.pushbutton\
        script.py
    03_Docs.urlbutton\
      bundle.yaml
 Behaviour
Children Any of the other panel item kinds, not just *.pushbutton. Stacks, pulldowns, toggles, smartbuttons, split buttons, and url and link buttons all work inside one. A *.dockpane is the one exception, see below.
Its own bundle.yaml No, it is never read. Nor is an icon, nor a script.py.
Title Never shown. The flyout is labelled by the panel, not by the slideout folder.
Ordering Its children keep folder order inside the flyout. Where the .slideout folder itself sorts among the panel's other folders does not matter, because its contents always render after the break.
Several per panel Allowed. Their contents concatenate in folder order into one flyout; a panel has only one break.
Nesting One level. A .slideout inside a .slideout is logged and its contents are dropped.
%APPDATA%\pyNavis\logs\pyNavis.log
Slideout 'D:\NavisTools\...\99_More.slideout\Extra.slideout' sits inside another slideout - skipped.

Buttons in the flyout are ordinary buttons. They get keytips, they take shortcut:, context: and the host version keys, and Shift+Click and Alt+Click behave exactly as they do on the panel face. Their bundle key keeps the .slideout folder in the path, so moving a tool into or out of a slideout orphans any user shortcut override that pointed at it.

A panel can be entirely a slideout

If every item on a panel sits inside a .slideout, the panel still renders: it just has an empty face and a full flyout. That is a reasonable shape for a panel of rarely used maintenance tools that you still want grouped under their own name.

A dockpane can live in a slideout

A *.dockpane inside a .slideout claims a panel slot exactly like one on the face of the panel; only its ribbon toggle moves into the flyout. Its bundle key includes the slideout folder, so T.tab/P.panel/More.slideout/S.dockpane is the key you will see under panes.assignments.

Mixing them on one panel#

Panel items are sorted once, by folder name, before anything knows what type they are. The three types interleave freely and are never grouped, so the panel order is exactly the order you see in Explorer.

Sorted folder names Resulting panel Memory.panel 1 01_Memorize.pushbutton 2 02_Recall.pushbutton 3 03_Set.stack 4 04_Step.stack 5 05_Memory.pulldown Memory 1 Memorize 2 Recall 3 Add Subtract Intersect 4 Prev Next Clear 5 Memory One sort, over mixed types. Renaming 03_Set.stack to 06_Set.stack moves it to the end of the panel.
Five folders, five panel items, same order. Because the sort runs before the type is known, a stack can sit between two large buttons purely by how you number it.

Icons at each size#

The size an item renders at decides which icon it asks for, which is why the four-variant icon set exists.

Where it rendersIcon file usedDrawn at
Large pushbutton on a panelicon.png / icon.dark.png32 logical pixels
Row in a stackicon.small.png / icon.small.dark.png16 logical pixels
The pulldown button itselficon.png / icon.dark.png32 logical pixels
Row in a pulldown menuicon.small.png / icon.small.dark.png16 logical pixels

Every missing variant falls back to icon.png, so a bundle that ships only the one file still renders everywhere. It is loaded at the slot's logical size rather than being scaled from the large art, which keeps it from clipping, but a 96×96 drawing rarely reads well at 16 pixels. A menu of pulldown rows is where that shows worst.

Themes are resolved at build time

Whether the light or dark art is used is decided once, when the ribbon is built. If you change the Navisworks theme, click Reload to pick up the other set.

Choosing between the three#

SituationUse
A tool people reach for every dayA large pushbutton, at the left of the panel
Two or three variations on one actionA stack
Four or more related toolsA pulldown
One rarely used toolA pulldown row, or the far right of a panel
A tool with a long nameA pulldown row: menus have horizontal room, stacks do not
Settings for an existing toolNot a button at all: add config.py and let Shift+Click open it. See Click actions.

Identity on the ribbon#

Each rendered control gets a deterministic id, which is worth knowing when you read a log line or wonder why a duplicate got dropped.

ControlIdDerived from
ButtonPYNAVIS_BTN_<hash>The lowercased full path of its script.py
PulldownPYNAVIS_PULL_<hash>The lowercased pulldown folder path
Dock pane togglePYNAVIS_PANE_<hash>The lowercased dockpane folder path
TabPYNAVIS_TAB_<name>Extension name plus tab name, sanitised. See Anatomy.

Because button ids come from the script path, the same bundle copied to two extension roots produces two different ids and renders twice. Tab ids come from names, so the duplicate tab is caught and dropped. That asymmetry is why an accidentally duplicated extension usually shows up as a missing tab rather than a doubled one.

Common mistakes#

  • A stack with four children. Split it into two stacks, or promote it to a pulldown.
  • bundle.yaml inside a .stack folder. Never read. Move the keys down onto the children.
  • An icon inside a .stack folder. Never read either.
  • shortcut: on a pulldown. Ignored. Put it on the child you actually want the chord to run.
  • A .pulldown nested inside a .pulldown. There are no submenus. Flatten it, or use a second pulldown on the panel. Put it in a *.slideout instead if the goal was to get it off the panel face.
  • A .slideout inside a .slideout. One level only; the inner one is logged and its contents never render.
  • Relying on a large icon in a menu. Draw the 32×32 variants for anything that lives in a stack or a pulldown.