Bundles
Buttons, stacks and pulldowns
All twelve bundle kinds: the three a panel holds most often, the nine narrower ones, what each costs in ribbon space, and exactly what each one does when clicked.
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 extra click for a menu of any length.
Pushbuttons, stacks and pulldowns are the three you will use for almost everything. A panel
also understands nine narrower suffixes: *.nobutton for a command with no button,
*.urlbutton and *.linkbutton for pointing at something other than
your own script, *.toggle for on/off state, *.smartbutton for a
button that sets its own appearance, *.splitbutton and
*.splitpushbutton for a menu whose header also runs a child,
*.dockpane for an AutoCAD palette, and *.slideout for tools that
belong in the panel's flyout. More button kinds covers all
nine.
At a glance#
| Suffix | On the ribbon | Clicking it | AutoCAD command |
|---|---|---|---|
*.pushbutton | One large button | Runs script.py | Yes |
*.stack | 2 or 3 small buttons in one column | Each row runs its own script | Each row |
*.pulldown | A large button that opens a menu | Opens the menu | Each menu row |
*.nobutton | Nothing | Not clickable: chord or typed name only | Yes |
*.urlbutton | One large button | Opens url: in the browser | No |
*.linkbutton | One large button | Sends the command named in plugin: | No |
*.toggle | One large button with on and off art | Runs script.py, which flips the state | Yes |
*.smartbutton | One large button that styled itself | Runs script.py | Yes |
*.splitbutton | A large split button and menu | Header reruns the last child used | Each menu row |
*.splitpushbutton | A large split button and menu | Header always runs the first child | Each menu row |
*.dockpane | A large toggle button | Shows or hides an AutoCAD palette | No |
*.slideout | Nothing of its own | Its contents sit in the panel's flyout | Its contents, as usual |
"AutoCAD command" means the bundle is registered as a command named from its title (or
command:), so it can be typed, put in a macro and called from AutoLISP; a ribbon
click sends that command rather than running the script itself. See
How pyACAD works.
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 |
| Its own bundle.yaml | Yes, every pushbutton key | Only for a layout: list |
title, tooltip, keytip, layout |
| Its own icon | Yes | No | Yes |
| Runs a script | Yes, script.py |
No, its children do | No, its children do |
| Gets a keytip | Yes | Each row does | The button does; menu rows do not |
| Icon slot used | Large (32 logical pixels) | Small (16 logical pixels) | Large for itself, small for every menu row |
What a panel looks like#
An example 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 reason to choose one over another.
Pushbuttons#
A *.pushbutton placed directly on a panel is a large button: icon on top,
caption underneath. A click sends its command, which runs script.py. It is the
default and it is what a tool should be unless you have a reason otherwise.
Layers.panel\
Report.pushbutton\
script.py # required
config.py # optional: Shift+Click, and the command PYREPORTCFG
bundle.yaml # title, tooltip, command, engine, shortcut, keytip, context, ...
icon.png # 96x96
icon.dark.png
icon.small.png # 32x32, used if this button ever moves into a stack or menu
icon.small.dark.pngOnly script.py is required. Without it the folder is dropped and the reason is
logged and toasted: '<dir>' has no script.py - skipped.
The caption can carry two small markers that pyACAD adds itself: a dot after the caption
when a keyboard chord is bound to the tool (on by default), and an arrow when the bundle has a
config.py (off by default). Both are switched in Settings. The tooltip gets the
bound chord appended in brackets, so a rebound tool shows the user's key, not yours. A large
button's caption can be broken onto two lines with a literal \n in
title:.
A top-level button uses the large slot, but a bundle you later move into a stack or a menu needs 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 pushbutton with its own script, icon, tooltip, command, shortcut and keytip; the
stack itself is only a container.
Layers.panel\
State.stack\ # no script.py, no icon, no caption
bundle.yaml # layout: Freeze, Thaw, Lock (nothing else is read)
Freeze.pushbutton\
script.py
icon.small.png
Thaw.pushbutton\
script.py
icon.small.png
Lock.pushbutton\
script.py
icon.small.pngThe parser opens bundle.yaml inside a .stack folder for one key,
layout:, and never looks for an icon there. The folder name is not shown
anywhere either: the stack exists purely to group. Put metadata on the children. Their
captions are drawn on one line beside a 16-pixel icon, so keep them short.
The 2-or-3 rule#
AutoCAD's stacked-row control holds two or three rows. Rather than let it render wrong, pyACAD drops the stack at parse time and logs why:
Stack 'D:\CadTools\...\State.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 two rows, and a stack of two where one child broke
disappears entirely. That line is in the log only, not in the after-scan toast, so a stack that
silently vanished after an edit is the case to check the log for.
State.stack\ Freeze.pushbutton\ Thaw.pushbutton\ Lock.pushbutton\
State.stack\ Freeze.pushbutton\ Thaw.pushbutton\ Lock.pushbutton\ Unlock.pushbutton\ # 4 rows: the whole stack is dropped Snap.toggle\ # not a pushbutton: reported and ignored
Pulldowns#
A *.pulldown is a large button with no action of its own. Clicking it opens a
menu of its children. It is the one item that scales past three tools without consuming panel
width, so it is where the long tail goes.
Layers.panel\
More.pulldown\
bundle.yaml # title, tooltip, keytip, layout
icon.png # 96x96, the face of the button
icon.dark.png
icon.small.png # 32x32
icon.small.dark.png
Merge_Layers.pushbutton\
script.py
Purge_Empty_Layers.pushbutton\
script.py
Layer_Standard.pushbutton\
script.pytitle: More
tooltip: Merge layers, purge the empty ones, or apply the office layer standard.
layout:
- Merge_Layers
- Purge_Empty_Layers
- Layer_StandardWhat a pulldown's bundle.yaml can say#
| Key | On a pulldown |
|---|---|
title | Read. Falls back to the folder name, underscores turned into spaces and any legacy prefix stripped. |
tooltip | Read. There is no docstring to fall back to, so without this key the button has no tooltip. |
keytip | Read. Overrides the generated Alt-navigation letters. |
layout | Read. The order of the menu rows. Unlisted children follow in name order. |
| anything else | Not read. shortcut, command, engine and context belong on the children. |
The children are ordinary pushbuttons and lose nothing by being in a menu. Each is its own
AutoCAD command, may declare its own engine, shortcut and
context (a row whose context is false is greyed in the menu), and responds to
Shift+Click and Alt+Click exactly as a top-level button does. Menu rows
draw the small icon and a one-line caption.
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:, or type its
command. Shortcuts and keytips explains both.
One usable child is enough; zero is not. A pulldown whose children all failed is dropped
with Pulldown '<dir>' has no usable pushbuttons - skipped. in the log.
More button kinds#
Nine more suffixes, each a variation on a pushbutton, a pulldown or the panel itself rather
than a new concept. All nine take their place on the panel from its layout: list
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,
config.py, bundle.yaml) but the ribbon never draws it. It is still an
AutoCAD command, so it can be typed, put in a macro or called from AutoLISP, and it is listed
in the Shortcuts editor for a chord.
Utility.panel\
Rebuild_Index.nobutton\
script.py
bundle.yaml # a shortcut and a memorable commandtitle: Rebuild index
tooltip: Rebuilds the block index this session caches.
command: PYREINDEX
shortcut: Ctrl+Alt+REverything else is ordinary: the same keys, the same engine resolution, the same
min_host_version/max_host_version gating. Two things do not carry
over. context: is parsed but never evaluated, because greying is applied to a
drawn button and a nobutton has none. And Shift+Click needs a button, so a
nobutton's config.py is reachable only through its CFG command
(PYREINDEXCFG above).
urlbutton: opens a link#
A *.urlbutton ships no script.py. Its whole job is the
url: key in bundle.yaml: clicking opens that address with the
default browser and runs nothing else.
Help.panel\
Office_Standards.urlbutton\
bundle.yaml
icon.pngtitle: Office\nStandards
tooltip: The CAD standards manual on the intranet.
url: https://intranet.example.com/cad-standardsWithout tooltip:, the tooltip is the address itself. A urlbutton is not an
AutoCAD command, takes no shortcut:, has no context:, and ignores
Shift and Alt on the click. If the address cannot be opened, the failure
is logged and toasted (Could not open URL) rather than raised.
bundle.yaml has the key table.
The current build opens the address with a call that .NET 8 does not hand to the shell, so on AutoCAD 2025 and 2026 a urlbutton is expected to show the Could not open URL toast instead of opening the browser. It works on 2021 to 2024. Until that is fixed, a pushbutton whose script starts the browser explicitly is the reliable route on the newer releases.
linkbutton: sends another command#
A *.linkbutton also ships no script. Its required key is
plugin:, which names an AutoCAD command: a native one, one defined by another
add-in, or another pyACAD tool's. Clicking sends that command to the active drawing exactly as
if it had been typed, so you can put someone else's tool on your own panel.
title: Layer\nWalk
tooltip: AutoCAD's LAYWALK, on our panel.
plugin: LAYWALK- The value is uppercased and cut down to letters, digits,
_and-, so-LAYERworks but a space, a dot or a macro does not: one command name, no arguments. - pyACAD sends it as
_.<NAME>, the form that skips command translation and redefinition, so write the plain English name without your own_or.prefix. - A name AutoCAD does not know is not detected by pyACAD: the command line simply reports an unknown command. With no drawing open the click toasts Open a drawing first.
- A linkbutton is not itself a command, takes no
shortcut:orcontext:, and ignores Shift and Alt on the click.
The key is called plugin: for historical reasons; it has always meant a
command name in pyACAD. bundle.yaml
has the 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 (and optionally its caption and tooltip) to match. script.py and
bundle.yaml work exactly as on a pushbutton, and it is an AutoCAD command like
one; the additions are on/off art and two functions in pyacad.script.
Ortho_Snap.toggle\
script.py
bundle.yaml
icon.on.png # 96x96, shown while on
icon.on.dark.png
icon.off.png # 96x96, shown while off, and at the start of every session
icon.off.dark.png"""Turns ORTHOMODE and SNAPMODE on or off together."""
from pyacad import doc, script, toast
on = not script.get_toggle_state()
doc.set_sysvar('ORTHOMODE', 1 if on else 0)
doc.set_sysvar('SNAPMODE', 1 if on else 0)
script.set_toggle_state(on)
toast.info('Ortho and snap ' + ('on' if on else 'off'))| Icon file | Falls back to |
|---|---|
icon.off.png | icon.png |
icon.off.dark.png | icon.off.png (after its own fallback) |
icon.on.png | icon.png |
icon.on.dark.png | icon.on.png (after its own fallback) |
Every toggle starts off. script.get_toggle_state() reads the current state;
script.set_toggle_state(on) sets it and repaints the button immediately. Note that
the state is pyACAD's, not AutoCAD's: the example above sets two system variables and then
records that it did, but if the user types ORTHO the button does not know.
A caption that names an action reads wrong once the toggle is pressed: "Hide Grid" on a
button whose next click shows it. Give the on state its own words with title_on
and tooltip_on in bundle.yaml; the button swaps caption and tooltip
with the icon, keeps them across a Reload, and still carries its markers. Leave them out and
one caption serves both states. Only a *.toggle reads them; anywhere else they
are logged and ignored.
title: Hide\nGrid
title_on: Show\nGrid
tooltip: Turns the drawing grid off.
tooltip_on: Turns the drawing grid back on.Toggle state lives in memory for the AutoCAD session, keyed by the bundle key. It survives a
Reload but not a restart, and only the toggle's own script.py or
config.py can set it: get_toggle_state and
set_toggle_state act on whichever bundle is running, and neither a hook nor
startup.py runs as a bundle. A toggle that must come back on after a restart
saves its flag with pyacad.settings and reapplies it on its first run of the
session.
A toggle is always a large button: stacks and menus take only pushbuttons.
smartbutton: sets its own title, tooltip and icon at build time#
A *.smartbutton runs script.py once while the ribbon is being
built, so the script can set its own appearance before anyone sees it, and then on every click
like a pushbutton. A global, __selfinit__, tells the script which run it is in.
"""Reloads every xref; the caption shows how many the drawing had at startup."""
if __selfinit__:
from pyacad import xrefs
try:
count = len(xrefs.list())
except Exception:
count = 0 # no drawing yet at ribbon build
__button__.Title = 'Xrefs (%d)' % count
else:
from pyacad import toast, xrefs
toast.success('%d xref(s) reloaded' % xrefs.reload())On the build-time run, __selfinit__ is True and
__button__ is a small proxy over the live ribbon control:
| Member | Does |
|---|---|
__button__.Title | Read/write. The caption. |
__button__.Enabled | Read/write. False greys the button.
A context: rule on the same bundle overrides it at the next
re-evaluation. |
__button__.Tooltip | Write-only. The hover tooltip. |
__button__.SetIcon(path) | Points both icon slots at one PNG.
Choosing light or dark art is the script's job (script.is_dark_theme()). |
On a click, __selfinit__ is False and __button__ does
not exist at all: guard on __selfinit__, as the example does, or a click raises
NameError. The build-time scope is smaller than a click's in the other direction
too: it has only __file__, __selfinit__, __button__,
__pyacad__ and __runner__, so __title__,
__commandpath__ and __args__ do not exist there, and
pyacad.script.get_command_path() reads back whichever command ran last. Anything
the build-time run prints goes to the log, labelled [selfinit <bundle key>].
It fires at boot, on every Reload, and when pyACAD rebuilds its tabs after a workspace switch, before the button is clickable. Nothing repeats it in between, so it is the place for an initial appearance, not for a value that changes while AutoCAD runs: at boot there may not even be a drawing open. A build-time run that raises is logged, and the button still renders with its normal title and still runs clicks.
The command name comes from the bundle's own title (from bundle.yaml,
__title__ or the folder), not from what the build-time run sets. A smartbutton is
always a large button.
splitbutton and splitpushbutton: a menu 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,
keytip and layout, their own icon set. The difference is what
clicking the large header does.
*.pulldown | *.splitbutton | *.splitpushbutton | |
|---|---|---|---|
| Clicking the header | Opens the menu | Runs the child most recently picked from the menu this session; the first child until then | Always runs the first child, in layout order |
| The arrow | (the whole button opens the menu) | Opens the menu | Opens the menu |
Output.panel\
Plot.splitbutton\
bundle.yaml # title, tooltip, keytip, layout
icon.png
To_PDF.pushbutton\
script.py
To_DWF.pushbutton\
script.pytitle: Plot
tooltip: Plots the current layout. The arrow picks the format.
keytip: PL
layout:
- To_PDF
- To_DWFThe header runs a child the same way the child's own menu row does, modifiers included:
Shift on the header runs that child's config.py. The split button
itself is not a command; each child is.
dockpane: an AutoCAD palette#
A *.dockpane is not a script that runs and returns. It is an AutoCAD palette
(a PaletteSet, the same kind of window as Properties or Tool Palettes) whose
content is your pane.xaml, and its ribbon button is a toggle that shows and hides
the palette and follows it: closing the palette from its own title bar releases the toggle,
and showing it from code presses it.
Block_Browser.dockpane\
pane.xaml # required: any WPF element; a Window root is unwrapped to its content
script.py # optional: runs once each time the palette is built
bundle.yaml # optional: title, tooltip, keytip, engine
icon.png, icon.dark.png, icon.small.png, icon.small.dark.pngtitle: Block\nBrowser
tooltip: A docked list of the drawing's block definitions with counts.
keytip: BBThe palette is created at boot and rebuilt on every Reload, so its content starts fresh each time. It docks left or right, can auto-hide, has a minimum size of 220 by 160 pixels, keeps keyboard focus so text boxes in it receive keystrokes rather than the command line, and is titled with the bundle's title. Its identity is a GUID derived from the bundle key, which is how AutoCAD remembers its size and dock side between sessions; renaming or moving the folder makes a new GUID, and the palette forgets.
If script.py exists it runs right after the XAML loads, with
__file__, __commandpath__, __title__,
__pyacad__, __runner__ (False) and one more global,
__pane__:
| Member | Does |
|---|---|
__pane__.Find(name) |
The element with that x:Name in pane.xaml, or None. |
__pane__.Content |
The root element pane.xaml produced. |
__pane__.Title |
The resolved title, read-only. |
__pane__.BundleKey |
The pane's own bundle key, read-only. |
__pane__.Visible |
Read/write. Shows or hides the palette; the ribbon toggle follows. |
"""Fills the list on demand; the Refresh button is wired here, once."""
from pyacad import blocks, doc
pane = __pane__
listing = pane.Find('Blocks')
def refresh(sender, args):
with doc.lock(): # a click in a palette is outside command context
counts = blocks.counts()
listing.Items.Clear()
for name in sorted(counts):
listing.Items.Add('%s (%d)' % (name, counts[name]))
pane.Find('Refresh').Click += refreshscript.py runs once and returns; everything after that happens in the handlers
it wired up, when the user clicks inside the palette. Those clicks are not AutoCAD commands, so
nothing has locked the drawing for you: wrap drawing access in doc.lock(), as
above. And because the script ran at build time, perhaps before any drawing was open, read
the drawing in a handler, not at the top of the script.
A dock pane is not an AutoCAD command and has no config.py. Its toggle reacts
to a plain click only: Shift and Alt do nothing different there.
shortcut: is read from its bundle.yaml but not bound to anything in
the current build, and context: is not read. If pane.xaml fails to
load, there is no palette and no toggle, and a toast names the pane; if only
script.py fails, the palette and toggle exist with the bare XAML content, and the
same toast reports it. There is no limit on how many dock panes an extension may have.
slideout: the tools behind the panel title#
A *.slideout is the one panel item that is not a control at all. Everything
inside it is placed after a panel break, so AutoCAD keeps it in the flyout that opens from the
panel title along the bottom of the panel. It costs no ribbon width, which is the point.
Drafting.panel\
bundle.yaml # layout: Count, Rename
Count.pushbutton\ # on the face of the panel
Rename.pushbutton\
Extras.slideout\ # no icon, no script.py; bundle.yaml holds only layout:
bundle.yaml # layout: Settings, Formats, Docs
Settings.pushbutton\
script.py
Formats.pulldown\
bundle.yaml
CSV.pushbutton\
script.py
Docs.urlbutton\
bundle.yaml| Behaviour | |
|---|---|
| Children | Any of the other eleven kinds: pushbuttons, stacks, pulldowns, toggles, smartbuttons, split buttons, url and link buttons, nobuttons and dock panes. |
| Its own bundle.yaml | Only for a layout: list ordering the items inside it. |
| Title | Never shown. The flyout belongs to the panel. |
| Several per panel | Allowed. Their contents join one flyout in the order the panel's
layout: list gives the slideouts. |
| Nesting | One level. A .slideout inside a .slideout is logged and its
contents are dropped. |
Bundles in the flyout are ordinary bundles: they are commands, they get keytips, they take
shortcut:, context: and the host version keys, and
Shift+Click and Alt+Click behave 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.
Mixing them on one panel#
Panel items are placed once, by the panel's layout: list, whatever their kind.
They interleave freely and are never grouped, so the panel order is exactly the order of the
list.
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. The light or dark set is chosen from AutoCAD's theme when the ribbon is built; after changing the theme, click Reload.
| Where it renders | Icon file used | Drawn at |
|---|---|---|
| Large button on a panel (pushbutton, toggle, smartbutton, url, link, dock pane) | icon.png / icon.dark.png | 32 logical pixels |
| Row in a stack | icon.small.png / icon.small.dark.png | 16 logical pixels |
| The pulldown or split button itself | icon.png / icon.dark.png | 32 logical pixels |
| Row in a menu | icon.small.png / icon.small.dark.png | 16 logical pixels |
Every missing variant falls back to icon.png, so a bundle that ships only one
file still renders everywhere; it is loaded at the slot's logical size rather than scaled from
the large art. A bundle with no icon at all renders as a caption only.
Choosing between them#
| Situation | Use |
|---|---|
| A tool people reach for every day | A large pushbutton, at the left of the panel |
| Two or three variations on one action | A stack |
| Four or more related tools | A pulldown, or a split button if one is the usual choice |
| A tool with a long name | A menu row: menus have horizontal room, stacks do not |
| A tool people run from the keyboard or from LISP only | A nobutton with a command: |
| A mode the user switches on and off | A toggle |
| A panel of controls that stays open while the user works | A dock pane |
| Rarely used tools you still want on the panel | A slideout |
| Options for an existing tool | Not 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.
| Control | Id | Derived from |
|---|---|---|
| Script button (pushbutton, toggle, smartbutton, rows) | PYACAD_BTN_<hash> |
The lowercased full path of its script.py |
| Url or link button | PYACAD_BTN_<hash> | The lowercased bundle folder path |
| Pulldown or split button | PYACAD_PULL_<hash> | The lowercased folder path |
| Dock pane toggle | PYACAD_PANE_<hash> | The lowercased folder path |
| Tab | PYACAD_TAB_<name> | Extension name plus tab folder name, sanitised. See Anatomy. |
Every pyACAD tab answers to the Alt-navigation keytip PY. Inside a tab, keytips
are assigned per tab: explicit keytip: values claim first, then each remaining
item gets the first free of its title's first letter, first two letters, or first letter plus
a digit from 2 to 9.
Common mistakes#
- A stack with four children. Split it into two stacks, or make it a pulldown.
- A toggle or smartbutton inside a stack or menu. Only pushbuttons go there; the others are reported and ignored.
- Keys on a stack's
bundle.yaml. Onlylayout:is read. Move everything else onto the children. shortcut:on a pulldown. Not read. Put it on the child the chord should run.- A
.pulldowninside a.pulldown. There are no submenus. Flatten it, or use a*.slideoutif the goal was to get it off the panel face. - A
.slideoutinside a.slideout. One level only. - Two buttons with the same short title. They derive the same command;
the second gets none. Give one a
command:. - Relying on a large icon in a menu. Draw the 32×32 variants for anything that lives in a stack or a menu.