Scripts
Talking to the user
Toasts, banners, dialogs and pickers, progress, the HTML output window and your own windows: what each one is for, every function's signature, and how they follow AutoCAD's theme.
A tool that runs and shows nothing reads as broken. A tool that opens a window to say one sentence reads as clumsy. Between those two failures sits a small set of surfaces, each with one job, and most of this page is about matching the message to the surface. Reach for the lightest one that can carry what you have to say.
Choosing a surface#
| Surface | Blocks the script? | Use it for | Not for |
|---|---|---|---|
pyacad.toast | No | The result of a finished action, in one line. | Questions; anything the user must read to carry on. |
pyacad.banner | No | The one value a tool exists to produce (a length, a count), or the instruction a tool is waiting on. | Routine confirmations a toast would carry. |
pyacad.forms | Yes | A question that must be answered; picking from a list; files and folders; a cancellable progress window. | Reporting a result nobody asked about. |
pyacad.output | No | Tables, lists, links that select objects, charts, long logs, a report to save. | One sentence. Opening a window to say one line is a defect. |
forms.WPFWindow or a *.dockpane | Your choice | A tool with many fields, a preview, or a list to work through. | Anything the four above already do. |
All of them are themed for you (see Theming), and all of them are
safe under pyacad run, where AutoCAD runs with no screen: toasts, banners and
alerts are written to the run's transcript instead, and anything that would wait for an answer
raises (see Under pyacad run).
Toast: one line, no interruption#
from pyacad import toast
toast.success('Plotted 12 layouts to PDF', r'D:\Issue\2026-10')
toast.info('Nothing selected', 'Select the blocks to count, then run this again.')
toast.warning('Plotted 10 of 12 layouts', '2 failed')
toast.error('Could not reach the xref', r'\\server\cad\base.dwg')
toast.show('warning', 'Same thing, with the level as a string')| Function | Returns |
|---|---|
toast.success(message, detail=None) | None |
toast.info(message, detail=None) | None |
toast.warning(message, detail=None) | None |
toast.error(message, detail=None) | None |
toast.show(level, message, detail=None) | None |
A toast is a small card, 300 pixels wide, in the bottom-right corner of the AutoCAD window: the message in semibold, the optional detail line under it in muted text, a border and a left rail in the level's colour (success green, error red, info blue, warning amber).
- It never takes focus. The user keeps typing at the command line while it shows.
- It goes by itself: after 4 seconds for success and info, 5 for a warning and 7 for an error, which carries something to read. Hovering holds it; a click dismisses it at once.
- Three at most. Toasts stack upwards from the corner, newest at the bottom. A fourth dismisses the oldest.
- It never raises. A toast that cannot be shown is logged; a toast is never worth failing a script over. It may be called from any thread.
- Reload clears them, along with any banner.
toast.show accepts 'success', 'error',
'info' and 'warning', trimmed and in any case. Anything else,
including a typo such as 'warn', shows as a success. Use the four named functions
and the question never comes up.
Never print a one-line status#
print() writes to the output window, and the first write opens it. A
print('Done') at the end of a script opens a whole window to hold one word. One-line
results go through pyacad.toast; print() is for output someone will
actually read.
toast.success('Renamed 14 layers')
print('Renamed 14 layers')
Writing a good toast#
- Lead with the result, and put the number in the message: Plotted 12 layouts to PDF, not Done.
- Use the detail for where or what next: the folder written to, or the one step that fixes an empty state.
- Say nothing on Cancel. The user just told you they want nothing to happen.
- Pick the level by outcome: success when everything worked, warning when part of it did, error when nothing did, info when there was nothing to do.
Banner: one value, full width#
Where a toast is a note in the corner, a banner is a readout: a bar the full width of the AutoCAD window, sitting on its bottom edge, filled solid in the level colour, with the message in large type (22 px) and an optional detail line under it. It is for the one value a tool exists to produce, legible from across the room, and for the instruction a tool is waiting on.
from pyacad import banner, units
banner.success(units.format_length(total, units.current()), '14 polylines on M-DUCT')
banner.prompt('Pick the first point of the run', 'Esc to stop') # stays until cleared
banner.clear()| Function | Stays for |
|---|---|
banner.success(message, detail=None), banner.info(...) |
8 seconds |
banner.warning(message, detail=None) | 10 seconds |
banner.error(message, detail=None) | 12 seconds |
banner.show(level, message, detail=None, seconds=None) |
None: the level's own time. 0: until cleared. Any other
number: that many seconds. |
banner.prompt(message, detail=None) |
Until replaced or cleared (an info banner with seconds=0). |
banner.clear() | Removes the banner, if one is showing. |
There is only ever one banner: a new one replaces the last. Like a toast it never takes focus, hovering holds its timer, a click dismisses it, Reload removes it, and a failure to show it is logged rather than raised. Levels and the unknown-level rule are the toast's.
Dialogs: when you need an answer#
pyacad.forms holds every prebuilt dialog. They are modal and owned by the
AutoCAD window, so they stay in front of it and the script waits on the line that showed them.
None of them touches the AutoCAD API, so they work with no drawing open.
| Function | Returns | On cancel |
|---|---|---|
forms.alert(message, title='pyACAD', copy=None) |
None | (nothing to cancel) |
forms.confirm(message, title='pyACAD') |
True on Yes | False |
forms.ask_string(prompt, default='', title='pyACAD') |
The text | None |
forms.ask_number(prompt, default=None, min_value=None, max_value=None,
title='pyACAD') | A float in range | None |
forms.ask_options(prompt, options, title='pyACAD') |
The option clicked | None |
forms.select_from_list(items, title='Select', multiselect=False, prompt=None,
checked=None) |
The value, or a list of values with multiselect | None |
forms.pick_folder(title='Select folder', initial=None) |
A folder path | None |
forms.open_file(filter='All files (*.*)|*.*', title='Open file') |
A file path | None |
forms.save_file(filter='CSV files (*.csv)|*.csv|All files (*.*)|*.*',
default_name='', title='Save file') |
A file path | None |
The rule for every one of them: test for None (or False)
and stop quietly. Never toast "cancelled".
from pyacad import forms
name = forms.ask_string('New layer name:', default='A-NOTES', title='New layer')
if name is None:
raise SystemExit # cancelled: change nothing, say nothingAn empty string is not a cancel: ask_string returns '' when the
user clears the box and presses OK, so check for both if empty is not a valid answer.
alert#
A message with one OK button. Pass copy='...' and the dialog grows a Copy
button on the left that puts that text on the clipboard without closing the dialog (it reads
Copied for a moment). Batch Plot uses it for the list of layouts that failed, so the
user can paste it into an email.
text = '\n'.join('%s: %s' % (layout, reason) for layout, reason in failures)
forms.alert('%d layouts did not plot:\n\n%s' % (len(failures), text), 'Batch plot', copy=text)confirm#
A Yes/No question. Enter picks No and Escape picks No: confirm is the dialog you show before destroying something, and an absent-minded Enter must never delete 400 layouts. Phrase the question so Yes is the action: Delete 3 layouts?, not Keep the layouts?
ask_number#
A numeric field with live validation. While the text is not a number, or is outside
min_value and max_value, the field turns red with a message such as
Enter a number between 1 and 12. and OK is disabled, so a returned value is always in
range. The result is always a float: wrap it in int() for a count.
The field parses with a dot as the decimal separator whatever the Windows locale.
position = forms.ask_number('New position for A3-02, from 1 to 12', default=4,
min_value=1, max_value=12, title='Layout manager')
if position is not None:
position = int(position)ask_options#
A one-click choice between a few options, drawn as a row of buttons. Clicking a button is
the answer; there is no OK. Escape or closing the window returns None. The
options can be any objects: their str() is the label, and the object itself comes
back.
EXPORT, COPY, CLOSE = 'Export to Excel', 'Copy totals', 'Close'
choice = forms.ask_options('Total run length: 412.6 m', [EXPORT, COPY, CLOSE], 'Length takeoff')Pass None or '' as the prompt for the bare quick-switch look: no
title bar and no question, just the buttons in a thin frame, and a click anywhere else
dismisses it like Escape.
select_from_list#
A searchable list. Items are strings, or (label, value) tuples when what you
show is not what you want back. The search box filters by substring, ignoring case. The list is
virtualised, so ten thousand layer names are as quick as ten.
from pyacad import forms, layers
# Single select: double-click or OK returns one value.
name = forms.select_from_list(layers.names(), 'Make current', prompt='Layer to make current')
# Multiselect with (label, value) pairs, opening with the saved choice ticked.
picked = forms.select_from_list([('Model', 'model'), ('A3-01', 'A3-01'), ('A3-02', 'A3-02')],
'Batch plot', multiselect=True,
prompt='Layouts to plot', checked=['A3-01'])- Single select returns one value. OK stays disabled until a row is picked, and a double-click picks and closes.
- Multiselect draws a checkbox per row, counts N selected in the
footer, and returns a list in the items' order. OK stays disabled until at least one row is
ticked, so the result is never an empty list: it is a list, or
None. checkedis a list of values to open with ticked (or, single select, the first of them picked). Values that are not among the items are ignored, so passing a stale saved choice is safe.titleis the second positional argument, beforemultiselect; the prompt line above the search box only shows when you pass one.
Files and folders#
open_file and save_file are the standard Windows dialogs; the
save dialog asks before overwriting. The filter string is the Windows format:
pairs of description|pattern, all joined by |, with
; between patterns in one entry.
path = forms.save_file(filter='Excel workbooks (*.xlsx)|*.xlsx|CSV files (*.csv)|*.csv',
default_name='blocks.xlsx', title='Export blocks')
dwgs = forms.open_file(filter='Drawings (*.dwg;*.dxf)|*.dwg;*.dxf', title='Pick a drawing')
folder = forms.pick_folder('Folder for the PDFs', initial=r'D:\Issue')pick_folder is the modern Windows folder picker. initial is used
only when that folder exists. Any failure inside it is logged and returned as
None, the same as a cancel.
A progress window with a Cancel button#
For long work that changes the drawing, forms.progress(title, label='') is a
context manager that shows a small progress window with a Cancel button.
from pyacad import forms, plot, toast
done = 0
try:
with forms.progress('Batch plot', 'Starting') as bar:
for index, (layout, path) in enumerate(targets):
bar.check() # raises Cancelled after Cancel
bar.update(float(index) / len(targets),
'Plotting %s (%d of %d)' % (layout, index + 1, len(targets)))
plot.to_pdf(layout, path)
done += 1
bar.update(1.0, 'Done')
except forms.Cancelled:
toast.info('Batch plot stopped', 'Plotted %d before you cancelled' % done)| Call | Does |
|---|---|
bar.update(fraction, label=None) |
Moves the bar (0 to 1, clamped) and, when given, replaces the label. Returns
False once the user has clicked Cancel, True otherwise. |
bar.check() |
Raises forms.Cancelled if Cancel was clicked. |
Four things about it are deliberate:
- AutoCAD is disabled while it shows. A script runs on AutoCAD's UI thread, so showing progress means letting the message loop run, and that would let the user edit the very objects the script is half way through changing. The window disables input to AutoCAD for its lifetime instead. It has no title bar and no close button.
- Cancel only raises a flag. Clicking it relabels the window
Cancelling...; your loop notices at its next
update()orcheck()and stops at a clean point. Nothing is aborted for you. - Call one of the two often. They are also what lets the Cancel click through (throttled to five times a second, so calling them on every item is cheap). A loop that calls neither cannot be cancelled.
- It cannot be leaked. Leaving the
withblock closes it and gives AutoCAD its input back, and if a script dies with one open, the runtime closes it after the run anyway.
The output window#
The output window is where anything bigger than a line goes. It is created lazily: the
first print() or pyacad.output call of a run opens it, titled
pyACAD - <tool title>, and later writes append. A script that only toasts
never opens one. Each run gets its own window, so earlier reports stay open until the user
closes them, and if the user closes the window mid-run the next write opens a fresh one.
from pyacad import output
output.print_html(html) # an HTML fragment
output.print_md(markdown) # a small markdown subset
output.print_table(rows, headers) # a styled table
output.print_code(text) # an escaped monospace block
output.print_image(path, caption=None) # png, jpg, gif or svg, embedded
output.progress(fraction, label='') # a bar at the bottom; 1.0 completes it
output.chart_bar(labels, values, title=None)
output.chart_line(labels, series, title=None) # series is {name: [values]}
output.chart_pie(labels, values, title=None)
output.chart_doughnut(labels, values, title=None)
output.set_title(text) # retitles the window
output.save(path) # everything so far, as one HTML file
link = output.element_link(handle, label) # HTML for a link that selects an object
html = output.table_html(rows, headers) # the HTML print_table emits
text = output.format_table(rows, headers) # aligned plain text with a dashed ruleThe last three return strings and print nothing. table_html and
format_table are pure Python, so they can be unit tested anywhere.
Tables#
print_table takes rows first, headers second. The headers set the column count:
short rows are padded with empty cells and anything past the last header is dropped. Every cell
is converted with str() and HTML-escaped.
rows = [['A-WALL', 1204, '38.5'], ['A-DOOR', 96, '0'], ['A-GLAZ', '', '']]
output.print_table(rows, ['Layer', 'Objects', 'Length (m)'])A column is right-aligned in tabular figures when every non-empty cell in it reads as a number once commas are removed, and at least one cell is non-empty. Alignment follows the data, not the column's position.
An 'n/a' or '-' in a numeric column makes the whole column text,
and it jumps back to the left. Use '' for a missing number: empty cells do not
count against the column.
Markdown#
print_md understands a deliberately small subset: #,
## and ### headings, **bold**, *italic*,
`code`, fenced ``` blocks, - bullet lists,
[text](url) links and paragraphs. No tables (use print_table), no
numbered or nested lists, no images. The input is HTML-escaped before it is converted, so a
layer name or attribute value containing < or & prints as
itself.
Element links#
element_link(item, label) returns the HTML for a link; embed it in whatever you
print. Clicking it makes that object the pickfirst selection in the active drawing, as if the
user had picked it, so its grips show. The item can be a handle string (as every
pyacad function returns them), an ObjectId, a Handle
or an integer.
from pyacad import output, sel
handles = sel.get(type='TEXT', layer='0')
output.print_md('## %d texts on layer 0' % len(handles))
output.print_html(' '.join(output.element_link(h, h) for h in handles[:20]))- Handles belong to a drawing. The click resolves the handle in whichever drawing is active when it is clicked. Switch drawings and the same link selects whatever object has that handle there, or nothing.
- Not inside
print_table. Table cells are escaped, so a link in a cell prints as its own source. Put links in aprint_htmlline. - A handle that no longer exists, or an erased object, does nothing.
Progress in the window#
output.progress(fraction, label='') shows a bar pinned to the bottom of the
window. 1.0 completes it, and it fades away a moment later. Unlike
forms.progress it has no Cancel button and does not disable AutoCAD: use it for
read-only work that reports as it goes. Calling it also lets the window repaint mid-run (at
most five times a second), which plain print() calls do not.
total = len(handles)
for index, handle in enumerate(handles):
if index % 100 == 0:
output.progress(float(index) / total, 'Checking %d of %d' % (index, total))
check(handle)
output.progress(1.0, 'Done')Put the count in the label. Checking 4,200 of 11,038 tells the user whether to wait; a bare percentage does not.
Charts#
Four shapes are built in, drawn as SVG that takes its colours from the window's theme.
chart_bar, chart_pie and chart_doughnut take parallel
labels and values lists; chart_line takes one
labels axis and a {name: [values]} dict of series.
counts = blocks.counts()
names = sorted(counts, key=counts.get, reverse=True)[:8]
output.chart_bar(names, [counts[n] for n in names], title='Most used blocks')- A bar chart turns horizontal past eight categories, where column labels would overlap.
- Mismatched lists are cut to the shorter one, and a value that is not a number (including
None) is drawn as 0. A chart never raises half way through a report. - Line series are coloured in name order; a series longer than the labels is cut, and does not stretch the scale.
- Pie and doughnut slices are labelled with their share; zero and negative values get no slice. All-zero data draws a "no data" card.
A chart earns its place when the shape of the data is the message. When the numbers are the message, a table reads better and copies out.
Images, code and keeping the report#
print_image(path, caption=None) embeds a PNG, JPEG, GIF or SVG file and raises
ValueError for any other extension. print_code(text) is an escaped,
horizontally scrolling monospace block, the right home for a traceback or a generated LISP
expression.
set_title(text) renames the window; called before the first write, the title
is kept until the window opens. save(path) writes everything this run has printed
as one standalone HTML file, styled for the current theme, that opens in any browser. It works
even when no window ever opened.
from pyacad import doc, forms, output, toast
output.set_title('Layer audit: ' + doc.path())
output.print_table(rows, ['Layer', 'Objects', 'Frozen'])
path = forms.save_file(filter='HTML files (*.html)|*.html', default_name='layer-audit.html',
title='Save the report')
if path:
output.save(path)
toast.success('Report saved', path)Custom HTML#
print_html takes any fragment. To look like the rest of the window, use the
classes and colour variables its stylesheet already defines. The variables are emitted once,
for the current theme, so var(--fg) is right in both.
| Variable | Is | Variable | Is |
|---|---|---|---|
--bg | page background | --fg | body text |
--muted | secondary text | --surface | table header, code background |
--line | row separators | --frame | frames around tables and code |
--accent | the Windows accent: links, progress | --row-hover | table row hover |
--err, --err-wash, --err-line |
error text, background, rail | --warn, --warn-wash, --warn-line |
warning text, background, rail |
--chart-1 to --chart-6 |
chart series colours |
Useful classes: table.pyacad (with td.num for numbers),
pre.codeblock, .pyacad-code, .pyacad-warn and
.pyacad-error (a tinted line with a coloured rail), and
a.pyacad-el.
output.print_html(
'<div style="border:1px solid var(--frame); border-radius:4px; padding:12px 14px">'
'<div style="font-size:28px; font-weight:300">11,038</div>'
'<div style="color:var(--muted)">entities scanned</div>'
'</div>')The desktop dialogs call the background Paper and the text Ink; the output
page names them --bg and --fg. var(--paper) in a
print_html fragment resolves to nothing.
Links in printed HTML: http, https and mailto links
open in the user's default browser. Anything else (file:, a relative path) is not
opened outside the window, so link to files with pyacad.script.show_in_explorer
from a button instead.
Without WebView2#
The rich window is a WebView2 browser. On a machine without the WebView2 runtime, pyACAD
opens a plain text window instead, with Copy, Clear, Save (as .txt) and Find
(Ctrl+F, F3 for the next match). Your script does not change:
every call still works, and degrades like this:
- Text and tracebacks print as they are, tracebacks in the error colour.
- HTML from
print_html,print_mdandprint_tableshows as its source; an image shows as[image]and a chart as[chart]. output.progressprints a line such as[ 50%] Checking 500 of 1000.- An element link is just its label.
If WebView2 is installed but cannot start (a locked-down profile, say), the window switches
to a read-only text box in place and carries on the same way. Its browser profile lives in
%APPDATA%\pyACAD\webview2.
Logger lines in the window#
pyacad.script.get_logger writes to the pyACAD log. Its warnings and errors also
appear in the output window as tinted lines, but only when a window is already open: logging a
warning never opens one by itself. See Logs.
Your own windows#
When chained prompts stop being enough, at about three fields, load a XAML file shipped in the bundle folder:
from pyacad import forms
win = forms.WPFWindow('rename.xaml') # a path relative to this bundle's folder
win['NewName'].Text = 'A-NOTES'
win['Ok'].Click += lambda sender, args: win.close(True)
win['Cancel'].Click += lambda sender, args: win.close(False)
if win.show_dialog():
new_name = win['NewName'].Text| Member | Does |
|---|---|
forms.WPFWindow(xaml_file) |
Loads the XAML. A relative name is looked up in the running bundle's folder; a missing
file raises IOError. A root that is not a Window is wrapped in one
sized to its content. |
win.find(name) | The element with that x:Name, or
None. |
win[name] | The same, raising KeyError when it does not
exist. |
win.show_dialog() |
Shows it modally, owned by AutoCAD and with the themed title bar, and returns its
DialogResult (True, False or
None). |
win.close(result=None) |
Closes it, setting DialogResult first when given. |
win.window | The WPF Window itself. |
The window's chrome is themed for you; its content is your XAML, so give it colours that
work in both themes (see Theming). For a tool the user keeps returning
to, a *.dockpane puts XAML in a real AutoCAD palette that stays open between runs,
switched from the ribbon; see Buttons, stacks and pulldowns.
A modal dialog's handlers run while show_dialog() waits, so they are still
inside your run. A modeless window's or a dock pane's handlers run later, outside any command:
the drawing is not locked (use doc.lock() to write), and
pyacad.script's per-tool calls answer for whichever tool ran last, so pin your
identity first with pyacad.script.bind. See
Settings and per-tool state.
Theming#
Every built-in surface follows one answer to "is AutoCAD dark?": the theme key
in config.json when set, otherwise AutoCAD's COLORTHEME system
variable (0 is dark). The full rule is in Icons, light or
dark. The answer is read once and kept until the next Reload, so after
switching AutoCAD's theme, press Reload.
- Dialogs and pickers draw on a solid paper background with the real Windows title bar, dark in the dark theme, and rounded corners on Windows 11.
- The Windows accent colour (Settings, Personalisation, Colours) appears in a few places only: the primary button, the selection rail, focus rings, the progress bar and output links. It is read once and refreshed on Reload.
- Toasts and banners use fixed success, error, info and warning colours per theme.
- The output window uses its own light and dark palettes, both checked for WCAG AA contrast, tracebacks included.
For your own XAML, ask the same question:
from pyacad import script
if script.is_dark_theme():
background, foreground = '#202020', '#E8E8E8'
else:
background, foreground = '#FFFFFF', '#1A1A1A'Ask at the moment you build the window, not at import time. The C# toolkit every pyACAD
dialog is built from is public too, so an IronPython script can borrow its colours:
DesignSystem.Tokens.Current (from PyAcad.Runtime.Forms) holds the
current theme's Paper, Surface, Ink,
Muted, Line and status colours. The style
guide covers what a tool's window should look like.
Under pyacad run#
When a script runs through pyacad run there is no screen and no one to answer.
__runner__ is True (and pyacad.script.is_runner returns
it). The surfaces behave like this:
toast.*,banner.*andforms.alertwrite one line to the run's transcript, such as[success] Plotted 12 layouts: D:\Issue, and return.forms.confirm,ask_string,ask_number,ask_options,select_from_list,pick_folder,open_file,save_fileandWPFWindow.show_dialograise Dialogs are not available under pyacad run. Check __runner__ and skip the prompt.print()andpyacad.outputgo to the transcript.
from pyacad import forms, script
folder = r'D:\Issue' if script.is_runner() else forms.pick_folder('Folder for the PDFs')One tool, every surface#
A realistic tool uses each surface in its place: a dialog for the one thing it cannot guess, progress while it works, a table and a chart for the detail, and a toast for the verdict.
"""Counts model space objects per layer and lists the layers nothing is on."""
from pyacad import ents, forms, layers, output, toast
handles = list(ents.iter())
if not handles:
toast.info('Model space is empty', 'Draw something, then run this again.')
raise SystemExit
per_layer = {}
try:
with forms.progress('Layer usage', 'Reading objects') as bar:
for index, handle in enumerate(handles):
if index % 200 == 0:
bar.update(float(index) / len(handles),
'Reading %d of %d' % (index, len(handles)))
bar.check()
per_layer.setdefault(ents.get(handle)['layer'], []).append(handle)
except forms.Cancelled:
raise SystemExit # the user stopped it: say nothing
empty = [name for name in layers.names() if name not in per_layer]
busiest = sorted(per_layer, key=lambda name: len(per_layer[name]), reverse=True)
output.print_md('## %d objects on %d layers' % (len(handles), len(per_layer)))
output.print_table([[name, len(per_layer[name])] for name in busiest], ['Layer', 'Objects'])
output.chart_bar(busiest[:8], [len(per_layer[name]) for name in busiest[:8]],
title='Busiest layers')
output.print_html('First object on %s: %s' % (busiest[0],
output.element_link(per_layer[busiest[0]][0], 'select it')))
if empty:
output.print_md('### Nothing on\n' + '\n'.join('- ' + name for name in empty))
if empty:
toast.warning('%d layers have nothing on them' % len(empty), 'The list is in the report.')
else:
toast.success('Every layer is in use', '%d objects counted' % len(handles))Notice what is not there: no print(), no toast on cancel, no window opened
before there was something to put in it. And the toast comes last, so the user has the verdict
even if they never read the window.