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.
| Gesture | What happens | You provide |
|---|---|---|
| Click | Runs script.py | script.py |
| Shift+Click | Runs 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 chord | Runs 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.
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.
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.
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.
Count.pushbutton\
script.py # click
config.py # Shift+Click
bundle.yaml
icon.pngIt 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.
__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.
"""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:
"""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.
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.
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.
| Gesture | What happens |
|---|---|
| Click | Shows 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+Click | The same toggle. There is no secondary action to reach, so Shift falls through to the plain click. |
| Alt+Click | Opens 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.