Bundles
Click actions and config.py
One button, three gestures: a click runs the script, Shift+Click runs the bundle's config.py, Alt+Click opens its folder. Every run goes through an AutoCAD command sent to the drawing.
Every pyACAD button answers three gestures, and two of them come free. Under all of them sits one design decision that shapes everything else on this page: a click does not run your script. It sends the tool's AutoCAD command to the drawing, and AutoCAD runs the script the way it runs any command.
| Gesture | What happens | You provide |
|---|---|---|
| Click | Sends the tool's command, which runs script.py |
script.py |
| Shift+Click | Sends the tool's CFG command, which runs config.py |
config.py, optional |
| Alt+Click | Opens the bundle folder in Explorer with script.py selected |
Nothing, this is built in |
| Keyboard chord | Sends the tool's command, always the primary one | shortcut: in bundle.yaml |
| Typing the command | Runs script.py (or config.py
for the CFG twin) | Nothing, every tool has one |
How the modifiers are read#
At the moment a ribbon button is clicked, the runtime reads the keyboard modifier state and picks one action. The order of the tests is the whole specification.
Alt wins over Shift#
The Alt test comes first, 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. Ctrl+Click is a plain click. That keeps a click made while a Ctrl chord is still physically held identical to a bare click.
Chords never see the modifiers#
A keyboard chord does not go through these tests at all: it dispatches the primary command
directly. If it did, the Shift in Ctrl+Shift+M would open
config.py instead of running the tool. See
Shortcuts and keytips.
A click becomes a command#
Every pushbutton-shaped bundle is registered as an AutoCAD command at boot and on every
Reload: command: in bundle.yaml names it, otherwise it is
PY plus the title's letters and digits uppercased (Batch Plot is
PYBATCHPLOT), and a bundle with a config.py gets a twin with
CFG on the end (PYBATCHPLOTCFG). The naming rules and name conflicts are
covered in Commands, macros and AutoLISP.
The click handler only sends that name to the active drawing:
_.PYBATCHPLOT↵The leading underscore asks for the global (non-localised) command name, and the dot
bypasses any UNDEFINE, so a user who undefines a name cannot trap a pyACAD tool.
The string is echoed, so the command line shows what ran, as if it had been typed. AutoCAD
then starts the command, and only then does pyACAD run the script.
Because the script runs inside a real command:
- The drawing is locked for you. The command is registered as modal, so
AutoCAD holds the document lock for the whole run. A script started this way never needs
doc.lock(). - One undo step. AutoCAD opens an undo group around the command, so
Ctrl+Z (or
U) takes back everything the tool did in one step, however many entities it touched. This was checked in AutoCAD 2026. - The pickfirst selection survives. The command uses the pick set, so objects selected before the click are still selected when the script asks for them.
- One route for everything. Clicking, a chord, typing the name, a CUI
macro, a script file and
(pyacad "PYBATCHPLOT")from AutoLISP all arrive at the same command. A click passes no arguments, so__args__is an empty list.
When another command is waiting for input, the click sends two Escapes ahead of the name,
like a CUI macro that starts with ^C^C, so the tool never types into that
command's prompt. At the idle Command: prompt it sends the bare name, so objects
you selected first stay selected for the tool.
With no drawing open#
A command needs a drawing to run in. When AutoCAD has no document open (every drawing closed, the Start tab showing) the send fails, nothing runs, and an information toast says Open a drawing first. with the tool's title underneath. Chords behave the same.
The direct-run fallback#
A bundle can end up without a command: its name was already taken by another pyACAD
bundle (first wins), AutoCAD already answers to the name (a LISP c: function or
another add-in's command), or the session has no command system at all. Those bundles are
reported in a toast at boot or Reload (One command was not registered) and still
work from the ribbon: the click runs the script directly, inside a document lock taken on the
active drawing when there is one.
What a direct run does not get is everything the command gave: there is no undo group
around it, it is not in the command history, and it cannot be typed or called from LISP. Fix
the conflict with a unique command: in bundle.yaml rather than living
with the fallback.
What happens once the script starts#
The same steps run whichever gesture started the tool:
- Host version gate. A bundle with
min_host_version:ormax_host_version:outside this AutoCAD's release year warns with a toast such as Requires AutoCAD 2024 or newer (this is 2022). and stops. - One script at a time. If another pyACAD script is still running, the new one is refused with the toast <running tool> is still running, Wait for it to finish, then try again. Long scripts pump the message loop to repaint, and a pump delivers queued clicks; the gate stops a second script starting on top of the first.
- The
before-commandhooks run, then the script, then theafter-commandhooks, even when the script failed. See Hooks. - Output appears lazily: the output window opens on the first
print()orpyacad.outputcall, titledpyACAD - <title>. A script that only toasts never opens one. A traceback goes to that window in the error colour and to the log. - Any progress window the script left open is closed, so a crash cannot leave AutoCAD disabled.
A context: rule in bundle.yaml enables and disables the ribbon
button. It is not checked when the command is typed, sent by a chord or called from LISP, so
a script that needs a selection should still check for one and say so.
Alt+Click: the way back to your own code#
Alt+Click starts Explorer with the bundle's script.py selected. It
runs no Python, needs no drawing, ignores the one-script gate, and works on every
pushbutton-shaped button, including stack rows and pulldown menu items. It is the fastest
route from a tool that misbehaves to the file that misbehaves.
explorer.exe /select,"D:\CAD\MyTools.extension\MyTools.tab\Blocks.panel\Count.pushbutton\script.py"It always selects script.py, even when the file you want is
config.py; the folder is open, so that is one click away. If Explorer cannot be
started the failure is logged and nothing shows on screen.
config.py: the secondary action#
A bundle can ship a second script beside script.py, named exactly
config.py. Shift+Click sends its command, and typing the
CFG twin (PYCOUNTCFG) does the same.
Count.pushbutton\
script.py # click, chord, PYCOUNT
config.py # Shift+Click, PYCOUNTCFG
bundle.yaml
icon.pngIt is not a different kind of bundle. There is no bundle.yaml of its own, no
title, icon or engine of its own: it is the same tool with a second entry point.
What differs when config.py runs#
Only __file__, which points at config.py. The engine, the search
paths, __commandpath__, __title__ (still the button's title), the
output window title and the per-tool settings key are all the primary run's. That last one
matters: pyacad.script.get_config in config.py and in
script.py read and write the same file, so a config script that saves options is
read back by the tool with no shared constant needed.
The convention#
Nothing forces config.py to be a settings dialog, but that is what users
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 Blocks."""
from pyacad import forms, script, toast
DEFAULTS = {'warn_above': 1000}
values = script.get_config(DEFAULTS)
limit = forms.ask_number('Warn when more block references than:',
default=values['warn_above'], min_value=1, title=__title__)
if limit is not None: # None means Cancel: change nothing, say nothing
values['warn_above'] = int(limit)
script.save_config(values)
toast.success('Saved', 'Warning above %d references.' % values['warn_above'])The primary script reads the same values back:
"""Counts the block references in the drawing, every layout included."""
from pyacad import blocks, script, toast
DEFAULTS = {'warn_above': 1000}
values = script.get_config(DEFAULTS)
count = sum(blocks.counts().values())
if count > values['warn_above']:
toast.warning('%d block references' % count, 'That is more than you asked to be warned about.')
else:
toast.success('%d block references' % count)The shipped tools use config.py for exactly this. Drawing Cleanup's
config.py, for instance, calls pyacad.script.reset_config so the
next run opens with the default steps ticked again.
When there is no config.py#
Shift+Click on a bundle without one shows an information toast: Shift+Click action not defined for this tool. Nothing runs.
Whether a bundle has a config.py is decided when the ribbon and the commands
are built. Adding the file to a loaded bundle does nothing (and registers no
CFG command) until you press Reload. Editing an existing
config.py needs no Reload: it is read fresh on every run.
A bundle with a config.py can show a marker after its caption so users know
Shift+Click does something. It is off by default; ribbon.showConfigMarker in
config.json turns it on (see Settings). Mention
Shift+Click in your tooltip either way: nothing else on the button says it.
Other button kinds#
| Kind | Click | Modifiers |
|---|---|---|
*.toggle |
Sends its command like a pushbutton. Flipping the state is the script's job,
with pyacad.script.set_toggle_state. |
Same rules as a pushbutton. |
*.smartbutton |
Sends its command; the script sees __selfinit__ False and no
__button__. |
Same rules as a pushbutton. |
*.pulldown |
Opens the menu; each item is a pushbutton. | Per item. |
*.splitbutton |
The header re-runs the item last picked this session (the first item until one is). | Same rules, applied to that item. |
*.splitpushbutton |
The header always runs the first item. | Same rules, applied to the first item. |
*.urlbutton |
Opens its url: in the default browser; a failure toasts
Could not open URL. |
Ignored. |
*.linkbutton |
Sends its plugin: value as a command, the same way a pushbutton sends its
own, so it can start a native, LISP or other add-in command. No drawing: the
Open a drawing first. toast. |
Ignored. |
*.dockpane |
Shows or hides the palette. The button stays pressed while the palette is visible,
and follows it when the palette is closed from its own title bar. No Python runs on the
click: the pane's script.py runs when the palette is built, at boot and on
every Reload. |
Ignored. |