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#

SuffixOn the ribbonClicking itAutoCAD command
*.pushbuttonOne large buttonRuns script.pyYes
*.stack2 or 3 small buttons in one columnEach row runs its own scriptEach row
*.pulldownA large button that opens a menuOpens the menuEach menu row
*.nobuttonNothingNot clickable: chord or typed name onlyYes
*.urlbuttonOne large buttonOpens url: in the browserNo
*.linkbuttonOne large buttonSends the command named in plugin:No
*.toggleOne large button with on and off artRuns script.py, which flips the stateYes
*.smartbuttonOne large button that styled itselfRuns script.pyYes
*.splitbuttonA large split button and menuHeader reruns the last child usedEach menu row
*.splitpushbuttonA large split button and menuHeader always runs the first childEach menu row
*.dockpaneA large toggle buttonShows or hides an AutoCAD paletteNo
*.slideoutNothing of its ownIts contents sit in the panel's flyoutIts 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.

.pushbutton .stack (3) .stack (2) .pulldown Layers Report Freeze Thaw Lock Isolate Restore More Merge Layers Purge Empty Layers Layer Standard the pulldown's children The pulldown draws its large icon; every stack row and every menu row draws the small one.
Four panel items, eight tools, eight commands. The three-row stack takes the same height as one large button and holds three times as much.

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.

folders
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.png

Only 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:.

Ship the small icons anyway

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.

folders
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.png
A stack's bundle.yaml holds only the order

The 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:

%APPDATA%\pyACAD\logs\pyACAD.log
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.

Do
State.stack\
  Freeze.pushbutton\
  Thaw.pushbutton\
  Lock.pushbutton\
Don't
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.

folders
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.py
More.pulldown\bundle.yaml
title: More
tooltip: Merge layers, purge the empty ones, or apply the office layer standard.
layout:
  - Merge_Layers
  - Purge_Empty_Layers
  - Layer_Standard

What a pulldown's bundle.yaml can say#

KeyOn a pulldown
titleRead. Falls back to the folder name, underscores turned into spaces and any legacy prefix stripped.
tooltipRead. There is no docstring to fall back to, so without this key the button has no tooltip.
keytipRead. Overrides the generated Alt-navigation letters.
layoutRead. The order of the menu rows. Unlisted children follow in name order.
anything elseNot 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.

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:, 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.

folders
Utility.panel\
  Rebuild_Index.nobutton\
    script.py
    bundle.yaml         # a shortcut and a memorable command
Rebuild_Index.nobutton\bundle.yaml
title: Rebuild index
tooltip: Rebuilds the block index this session caches.
command: PYREINDEX
shortcut: Ctrl+Alt+R

Everything 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).

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.

folders
Help.panel\
  Office_Standards.urlbutton\
    bundle.yaml
    icon.png
Office_Standards.urlbutton\bundle.yaml
title: Office\nStandards
tooltip: The CAD standards manual on the intranet.
url: https://intranet.example.com/cad-standards

Without 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.

Known issue on AutoCAD 2025 and 2026

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.

Layer_Walk.linkbutton\bundle.yaml
title: Layer\nWalk
tooltip: AutoCAD's LAYWALK, on our panel.
plugin: LAYWALK
  • The value is uppercased and cut down to letters, digits, _ and -, so -LAYER works 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: or context:, 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.

folders
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
Ortho_Snap.toggle\script.py
"""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 fileFalls back to
icon.off.pngicon.png
icon.off.dark.pngicon.off.png (after its own fallback)
icon.on.pngicon.png
icon.on.dark.pngicon.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.

Grid.toggle\bundle.yaml
title: Hide\nGrid
title_on: Show\nGrid
tooltip: Turns the drawing grid off.
tooltip_on: Turns the drawing grid back on.
Session-scoped, and settable only by the toggle itself

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.

Xref_Status.smartbutton\script.py
"""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:

MemberDoes
__button__.TitleRead/write. The caption.
__button__.EnabledRead/write. False greys the button. A context: rule on the same bundle overrides it at the next re-evaluation.
__button__.TooltipWrite-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>].

The build-time run happens per ribbon build, not per click

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 headerOpens 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 menuOpens the menu
folders
Output.panel\
  Plot.splitbutton\
    bundle.yaml                # title, tooltip, keytip, layout
    icon.png
    To_PDF.pushbutton\
      script.py
    To_DWF.pushbutton\
      script.py
Plot.splitbutton\bundle.yaml
title: Plot
tooltip: Plots the current layout. The arrow picks the format.
keytip: PL
layout:
  - To_PDF
  - To_DWF

The 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.

folders
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.png
Block_Browser.dockpane\bundle.yaml
title: Block\nBrowser
tooltip: A docked list of the drawing's block definitions with counts.
keytip: BB

The 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__:

MemberDoes
__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.
Block_Browser.dockpane\script.py
"""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 += refresh
Event handlers run outside command context

script.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.

folders
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.

The panel's layout list Resulting panel Layers.panel\bundle.yaml layout: 1 - Report 2 - Snapshot 3 - State 4 - Visibility 5 - More Layers 1 Report 2 Snapshot 3 Freeze Thaw Lock 4 Isolate Restore 5 More One list over mixed kinds. Moving the State line below the Visibility line swaps the two stacks.
Five entries, five panel items, same order. A stack sits between two large buttons purely by where you list 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. The light or dark set is chosen from AutoCAD's theme when the ribbon is built; after changing the theme, click Reload.

Where it rendersIcon file usedDrawn at
Large button on a panel (pushbutton, toggle, smartbutton, url, link, dock pane) icon.png / icon.dark.png32 logical pixels
Row in a stackicon.small.png / icon.small.dark.png16 logical pixels
The pulldown or split button itselficon.png / icon.dark.png32 logical pixels
Row in a menuicon.small.png / icon.small.dark.png16 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#

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, or a split button if one is the usual choice
A tool with a long nameA menu row: menus have horizontal room, stacks do not
A tool people run from the keyboard or from LISP onlyA nobutton with a command:
A mode the user switches on and offA toggle
A panel of controls that stays open while the user worksA dock pane
Rarely used tools you still want on the panelA slideout
Options 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.

ControlIdDerived from
Script button (pushbutton, toggle, smartbutton, rows)PYACAD_BTN_<hash> The lowercased full path of its script.py
Url or link buttonPYACAD_BTN_<hash>The lowercased bundle folder path
Pulldown or split buttonPYACAD_PULL_<hash>The lowercased folder path
Dock pane togglePYACAD_PANE_<hash>The lowercased folder path
TabPYACAD_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. Only layout: is read. Move everything else onto the children.
  • shortcut: on a pulldown. Not read. Put it on the child the chord should run.
  • A .pulldown inside a .pulldown. There are no submenus. Flatten it, or use a *.slideout if the goal was to get it off the panel face.
  • A .slideout inside 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.