Bundles

Click actions and config.py

One button, three behaviours: a plain click runs the script, Shift runs the bundle's config.py, and Alt opens the folder it lives in.

Every pyNavis button responds to three gestures, and two of them you get without writing anything. The rules are short, deliberately pyRevit-shaped, and they have one edge that regularly catches people: keyboard shortcuts do not go through them at all.

GestureWhat happensYou provide
ClickRuns script.pyscript.py
Shift+ClickRuns config.py, if the bundle has one config.py, optional
Alt+Click Opens the bundle folder in Explorer with script.py selected Nothing, this is built in
Keyboard chordRuns script.py, always shortcut: in bundle.yaml

How the modifiers are read#

At the moment the button is clicked, the runtime reads the keyboard modifier state and picks one action from it. The order of the tests is the whole specification, and two details of it are intentional.

Modifier state at the click Ctrl never read Shift Alt Is Alt held? yes Open the bundle folder, with script.py selected no Is Shift held? yes Run config.py , or toast if the bundle has none no Otherwise Run script.py Alt is tested first, so holding Alt and Shift together opens the folder. Ctrl changes nothing at all.
Three tests in a fixed order. There is no combination that reaches a fourth outcome.

Alt wins over Shift#

The Alt test comes first and returns immediately, so Alt+Shift+Click opens the folder. It does not run config.py, and it does not do both.

Ctrl is deliberately ignored#

Ctrl is not part of the decision at all. This is a design choice, not an omission: a plain click and a click made while a Ctrl-containing chord is still physically held must behave identically, so that a tool never quietly changes behaviour because of keys the user happens to be resting on. Ctrl+Click runs the primary script, exactly like a bare click.

The two entry points#

Modifier handling belongs to the ribbon. A keyboard shortcut takes a different road into the same executor and never sees a modifier test, because the chord's own Ctrl, Shift and Alt are still down at the moment it fires. If chords went through the modifier check, Ctrl+Shift+E would open a settings dialog instead of running the tool.

Two ways in, one way through Ribbon click mouse, with modifiers Modifier check Alt, then Shift, then plain Keyboard chord Ctrl+Shift+E skips the modifier check entirely ScriptExecutor.Run engine Only the top path can reach the folder or the config script. The bottom path always runs the primary.
The modifier check is a property of the ribbon button, not of the tool.
A chord with Shift in it still runs the primary script

Ctrl+Shift+E runs script.py, not config.py, even though Shift is part of it. There is no chord that reaches the config script or the folder. If a tool's settings need to be reachable from the keyboard, give the settings their own bundle rather than expecting a modifier to redirect one.

Chords are also guarded. One fires only when it is a fresh key press rather than a hold repeat, the Navisworks main window is in the foreground, and focus is not sitting in a text box. When focus cannot be identified, the runtime assumes it is a text input and does not fire, on the principle that swallowing a keystroke someone is typing is worse than missing a shortcut. Shortcuts and keytips covers the rest.

Alt+Click: the way back to your own code#

Alt+Click starts Explorer with the bundle's script.py selected. It runs nothing, needs no configuration, and works on every button including the ones inside pulldowns and stacks. It is the fastest route from a tool that is misbehaving to the file that is misbehaving.

what it actually runs
explorer.exe /select,"D:\NavisTools\MyTools.extension\...\Count.pushbutton\script.py"

It always selects script.py, even on a bundle whose interesting file is config.py; the folder is open, so the other file is one click away. If Explorer cannot be started the failure is written to the log and nothing is shown on screen, because there is nothing useful to say about it.

config.py: the secondary action#

A bundle can ship a second script beside script.py, named exactly config.py, in lower case, in the same folder. Shift+Click runs it.

folders
Count.pushbutton\
  script.py       # click
  config.py       # Shift+Click
  bundle.yaml
  icon.png

It is not a different kind of file and it gets no configuration of its own. There is no bundle.yaml for it, no separate title, no separate icon, no separate engine. It is the same tool with a second entry point.

What is different when config.py runs#

Exactly one thing: __file__. The engine, the module search paths, the other three injected globals and the output window are all the same as the primary run.

Click Shift + Click Primary action the tool does its work __file__ ...\script.py __commandpath__ the bundle folder __title__ the button title __pynavis__ the runtime host Secondary action the tool is configured __file__ ...\config.py __commandpath__ the bundle folder __title__ the button title __pynavis__ the runtime host Same engine, same search paths, same output window title. Only the highlighted row changes, so code that keys settings off __title__ or __commandpath__ agrees across both.
The config script is not a lesser environment. It is the same run with a different file.
__title__ stays the button's title

__title__ is the resolved title of the button, not of the config script, and the output window is still called pyNavis - <title>. That is what you want: a settings dialog that reports itself under the name of the tool it configures.

The convention#

Nothing forces config.py to be a settings dialog, but that is what users will expect from a Shift-click, and it is what the shipped tools do. Read the current values, present them, write them back, and say what happened.

Count.pushbutton\config.py
"""Options for Count selection."""
from pynavis import forms, settings, toast

TOOL = 'count_selection'
DEFAULTS = {'warn_above': 1000, 'include_hidden': False}

values = settings.load(TOOL, DEFAULTS)

answer = forms.ask_string(
    'Warn when the selection is larger than:',
    default=str(values['warn_above']),
    title=__title__,                     # the button's title, not this file's name
)
if answer is None:
    raise SystemExit                     # cancelled: change nothing, say nothing

try:
    values['warn_above'] = int(answer)
except ValueError:
    toast.error('That is not a whole number', 'Nothing was saved.')
    raise SystemExit

values['include_hidden'] = forms.confirm('Count hidden items too?', title=__title__)
settings.save(TOOL, values)
toast.success('Saved', 'Warning above %d items.' % values['warn_above'])

The primary script reads the same keys back:

Count.pushbutton\script.py
"""Counts the items in the current selection."""
from pynavis import selection, settings, toast

TOOL = 'count_selection'
DEFAULTS = {'warn_above': 1000, 'include_hidden': False}

values = settings.load(TOOL, DEFAULTS)
items = selection.get_items()

if len(items) > values['warn_above']:
    toast.warning('%d item(s) selected' % len(items), 'That is a large selection.')
else:
    toast.success('%d item(s) selected' % len(items))

Define TOOL and DEFAULTS once in a small module in your extension's lib\ folder and import it from both scripts once the pair grows beyond a couple of keys. Two copies of a defaults dictionary drift.

Cancel silently

A user who cancels a dialog has told you they want nothing to happen, so a config script that toasts on cancel is noise. Save and toast on the way out; return quietly on None.

When there is no config.py#

Shift+Click on a bundle without a config script shows an informational toast: Shift+Click action not defined for this tool. Nothing runs, nothing fails, and no output window opens. That message is worth recognising, because it is also what you see when the file exists but the runtime did not find it.

A new config.py needs a Reload

The presence of config.py is decided once, when the ribbon is built. Adding the file to a bundle that is already loaded does nothing until you click Reload. Editing an existing config.py needs no Reload: its contents are read fresh on every run, like script.py.

The other reason for that toast is the filename. The lookup is a plain path join for config.py in the bundle folder, so Config.py on a case-preserving share, config.PY, configure.py or a config.py one folder up are all invisible.

Buttons with no script#

A *.urlbutton and a *.linkbutton read no modifier state at all. There is no script.py behind either, so there is nothing for Shift+Click to run and no folder worth opening from the ribbon: Ctrl, Shift and Alt all behave as a plain click. A urlbutton opens its url:, a linkbutton runs its plugin:, and a failure in either is logged and toasted rather than raised.

Dock panel toggles#

A *.dockpane bundle renders as a ribbon toggle rather than a button, and the toggle reads the same modifier state with the same rules: Alt wins, Ctrl changes nothing. The one substitution is the Shift branch, because a panel has no config.py.

GestureWhat happens
ClickShows the panel when hidden, hides it when shown. The pressed state follows the panel, so closing it from its own title bar releases the toggle too.
Shift+ClickThe same toggle. There is no secondary action to reach, so Shift falls through to the plain click.
Alt+ClickOpens the bundle folder in Explorer with script.py selected, or pane.xaml when the bundle ships no script.

The toggle itself runs no Python. The bundle's script.py runs when the panel's content is built: at the panel's first open, and again on every Reload, never per click. When every panel slot is claimed the toggle cannot open the panel and warns instead of toggling; Troubleshooting lists the slot messages, and Buttons, stacks and pulldowns covers the bundle itself.

One script at a time#

Whichever entry point starts a run, the run takes a process-wide gate first. A second click while a script is still working is refused with a toast titled <tool> is still running and the detail Wait for it to finish, then try again. The name in that toast is the tool that is running, not the one you just clicked, which is usually the more useful of the two.

This exists because long scripts pump the message loop so the window can repaint, and a pump delivers queued input, including the click someone made while waiting. Without the gate a second script could start on top of the first and both would be editing the same document.

Practical consequences:

  • A config dialog cannot be opened while the primary script is running, and vice versa. They share the gate.
  • Keyboard chords are refused the same way, with the same toast.
  • Alt+Click is not affected. It runs no Python and never touches the gate, so you can always open the folder.

What the run leaves behind#

Both scripts get an output window, and both get it lazily: the window is created on the first write, so a script that only toasts never opens one. This is the reason the house rule is to report short results with pynavis.toast and keep print() for genuine output. A print() of nothing much still opens a window to show it.

If the script raises, the engine formats the traceback and writes it to that window in the error colour, which is often the first window a config script ever opens. Failures that happen before the script starts, such as an unknown engine: value in bundle.yaml, land in the same place. Any progress bar left open by a script that died is closed for you afterwards, so a crash cannot leave Navisworks unclickable.