Practice

Style guide for tools

The conventions every shipped tool follows, in code, in look and in behaviour, so that twenty tools written by ten people still feel like one product.

Every pyACAD tool is written by a different person, at a different time, for a different job, and nothing enforces consistency between them. What the user sees is not your tool but the pile: a ribbon tab of buttons and palettes that all claim to belong together, and the first one that looks or behaves differently makes the whole tab feel improvised.

This page is the contract the shipped tools are written to. The first part is how the code is written, the second how a tool looks (taken from the pyACAD design system v2.2), the third how it behaves, which matters most and is the easiest to get wrong.

Code conventions#

The bundle#

  • One bundle, one job. A tool that does two things is two bundles, or one bundle with a config.py for its settings.
  • Name folders the way the button reads. Layer_Mapper.pushbutton shows as "Layer Mapper" with no title: at all. Order with a layout: list in the container's bundle.yaml, not with numeric prefixes.
  • The docstring is the tooltip. Start script.py with a docstring of one or two full sentences saying what the tool does and how it reports, or put the same in tooltip:. Mention the Shift+Click action when there is a config.py. A tooltip is the only manual most users read.
  • Pin the command name when anything calls it. The derived command (PY plus the title) changes when the title does. If a macro, a script file or AutoLISP calls your tool, set command: in bundle.yaml. See AutoCAD commands and AutoLISP.
  • Four icons. icon.png and icon.dark.png at 96 by 96, icon.small.png and icon.small.dark.png at 32 by 32; a missing variant falls back to icon.png. The shipped icons are generated by a script and never edited by hand, which is what keeps the set consistent. See Icons.

The script#

  • Imports go at module level, straight after the docstring: from pyacad import with the modules in alphabetical order.
  • Top-level code, no if __name__ == '__main__': wrapper. Helper functions are fine; something has to call them.
  • Handles, not ObjectIds. The library speaks in handles, hex strings such as '2F3A', because a handle is stable for the life of the drawing and an ObjectId is only valid for the session. Store and pass handles; convert at the last moment.
  • Points are tuples, (x, y, z) floats. doc.as_point() accepts (x, y) too and fills in zero.
  • Every database touch goes through doc.transaction(). Keep a transaction short and never hold one open across a dialog.
  • Split the pure half from the API half. Planning, matching, formatting and validation are plain functions that take and return plain data, with no AutoCAD import; only the small part that reads or writes the drawing touches the API. The library is built this way (layers.plan_mapping(), xrefs.plan_repath(), plot.output_name()), which is what lets it be tested without AutoCAD and lets a tool show the user exactly what will change before it changes anything.
  • Guard prompts with __runner__ in any script that may run under pyacad run.
  • Keep settings in config.py and the settings store, never in module globals. See Settings and per-tool state.
Layer_Mapper.pushbutton\script.py
# -*- coding: utf-8 -*-
"""Applies a layer standard from a CSV: renames or merges matching layers, after you
tick what to apply."""
from pyacad import forms, layers, toast

path = forms.open_file('CSV files (*.csv)|*.csv', 'Layer standard')
if path:                                            # None: cancelled, say nothing
    with open(path) as fh:
        mapping = layers.parse_mapping(fh.read())   # pure: text in, rows out
    plan = layers.plan_mapping(layers.names(), mapping)   # pure: what would change
    ...

The look, in one paragraph#

Neutral grays, hairlines and whitespace carry the structure. The Windows accent appears in exactly three places: the primary action, the selection rail and the focus ring. Numbers are quiet tabular text, not chips. Controls are the native affordances users already know: checkbox, toggle, segmented and standard captions. Each tool gets one signature moment, the 28px weight-300 readout number pair. Solid surfaces only.

If you take one thing from that: your tool should be boring to look at and obvious to use. The interesting part is the drawing, not the chrome. The pyacad.forms dialogs and the output window already follow every rule here; the rules matter when you build a window or a pane.xaml of your own.

Accent in three places#

A tool window Merge layers A-WALL A-WALL-EXT 412 A-WALL-INT 96 A-WALL-PATT 8 Cancel Merge 1. Focus ring 2px accent, offset 1, on every focusable thing 2. Selection rail 3px accent on a neutral row fill, never a tint 3. Primary action one per window; the foreground is picked by luminance, not always white
Three uses of the accent, and no fourth. The counts on the right are muted tabular text, the rows are 32px, everything else is neutral.
The accent is not a fixed colour

It is whatever the user chose in Windows, read at runtime. It can be pale yellow, so white text on it is not automatic: pick the foreground by luminance, as the runtime's own buttons do. Any design that assumes a particular accent hue is already broken on somebody's machine.

The slop test#

Before shipping any surface, check it against the tells below. If a screenshot of your tool could pass for a template demo, it fails. The goal is an interface a Windows user trusts at once and then stops noticing.

Banned tellDo this instead
Gradients in text, fills or backgrounds. Anywhere. Solid surfaces. Paper, Surface, and hairlines between them.
Glassmorphism: blur, translucency, acrylic on content. An opaque surface fill. Content never shows through content.
Tint-soup: accent-alpha washes as default backgrounds or borders. Neutral gray backgrounds; the accent only in its three places.
Pill-soup: rounded chips carrying plain data. Text. A count is right-aligned muted tabular text at the row's edge.
Badge marketing: "Recommended" stickers, sparkles, emoji. Make the recommendation the default, and write a caption saying why.
Hero cards for what is simply a choice between two options. Two radio buttons, or a segmented control, with captions.
Centred content in rows, lists or forms. One left edge that everything aligns to. Numbers align right.
Hover-only affordances: controls invisible until pointed at. Visible at rest: expander chevrons, a real resize grip, present scrollbars.
Decorative motion: entrance animations, fades for their own sake. Motion only as state feedback, 120ms, ease-out, or none at all.
Opacity as disabled: dimming a control that still responds. A real disabled state, and a caption saying why.
Mouse-only controls: anything Tab cannot reach. Every control in the tab order, with a visible focus ring.
A control per row: one element built for every item in a list. Virtualise. A list of thousands of layers that builds thousands of controls is a defect.
Blank waiting: an empty framed box where a state belongs. Declare the empty state, the no-results state and the busy state before you ship.
Hand-drawn OS chrome: a custom titlebar imitating the real one. The real Windows titlebar. Always.
The two exceptions in the system

Shadows are banned except on a popup floating over its own window, and semantic colour is kept off borders except on the toast, which floats free of any chrome and has nothing around it to read its level against. Do not invent a third.

Type#

UseSize and weight
The readout, a number and its label as a pair, one per tool28px, 300, tabular
A window's own heading, in the body16px, 600
Group frames and section titles14px, 600
Body text, labels, list rows (the default)13px, 400
Column and rail headers12px, 500, muted
Captions, secondary columns12px, 400, muted
Row metadata and counts: the floor, nothing smaller ships11.5px, 400, muted
Script output and the console onlyConsolas 12.5px

The typeface is Segoe UI Variable, falling back to Segoe UI. Sentence case everywhere, never all caps, not even for section headers. Tabular figures wherever numbers line up.

Layout#

MeasureValue
Page padding, prose and settings windows24px
Page padding, data-dense windows (lists, the output window, the console)16px
Gap between sections24px
Gap between adjacent controls8px
List row height32px
Control height32px
Corner radius4px, everywhere
ShadowsNone, except a popup floating over its own window

Colour#

Twelve tokens plus the accent. If a colour you want is not in this table, the answer is one of these tokens, not a new colour.

TokenLightDarkUsed for
Paper#FFFFFF#202020Window and page background
Surface#F7F7F7#2B2B2BTable headers, code blocks, footers
Line#E0E0E0#3D3D3DRow separators, card edges
LineStrong#C9C9C9#4D4D4DControl borders
Frame#A6A6A6#6E6E6EGroup frames, the bold structural line
Ink#1A1A1A#E8E8E8Body text
Muted#6B6B6B#9A9A9ACaptions, counts, secondary columns
Selection#F0F0F0#333333Selected row fill. Neutral, never an accent tint.
Error#C50F1F#FF7B72Error text and rails
Success#0F7B0F#4CC44CSuccess toasts
Info#0078D4#4CC2FFInformational toasts
Warning#9D5D00#FFC83DWarning toasts and partial results
AccentThe user's Windows accent colour, read at runtime Primary action, selection rail, focus ring

Read the theme with script.is_dark_theme(), which follows AutoCAD's own light or dark setting, and paint your own windows from the right column. Read it when you build the window, not once at import time: the user can switch themes during a session.

python
from pyacad import script

dark = script.is_dark_theme()
paper = '#202020' if dark else '#FFFFFF'
ink   = '#E8E8E8' if dark else '#1A1A1A'
muted = '#9A9A9A' if dark else '#6B6B6B'
Selection is neutral

The selected row's background is Selection, a plain gray. The accent appears only as the 3px rail on its left edge. Filling the row with an accent tint is tint-soup: it fights the accent on the primary button and collapses to unreadable on a pale Windows accent.

How a tool has to behave#

The visual rules keep tools looking alike. These keep them trustworthy, which is the harder half. Each one has a failure mode a real tool has shipped with.

Report every outcome#

A tool that does its job and shows nothing is indistinguishable from a tool that crashed silently. Every path out of your script ends in something the user can see, with the single exception below.

Do
count = audit.purge()
if count:
    toast.success('Purged %d unused items' % count)
else:
    toast.info('Nothing to purge')
Don't
count = audit.purge()
if count:
    toast.success('Purged %d unused items' % count)
# nothing purged: the tool looks broken

Cancelling is silent#

The exception. When the user cancels a dialog or presses Esc at a prompt, say nothing at all. They know what they did, and "Cancelled" is a notification about their own hand.

Do
if forms.confirm('Delete 412 zero-length lines?'):
    audit.erase(handles)
    toast.success('Deleted 412 zero-length lines')
# No is not an outcome to report
Don't
if forms.confirm('Delete 412 zero-length lines?'):
    audit.erase(handles)
    toast.success('Deleted 412 zero-length lines')
else:
    toast.info('Cancelled')

Failure is a sentence, not a traceback#

Predictable failures (a missing file, a read-only drawing, a value the user typed wrong) get caught and reported in plain words that say the fix. Genuine bugs should still raise: the runtime puts the traceback in the output window and the log, which is exactly where you want it.

Do
try:
    with open(path) as fh:
        text = fh.read()
except IOError:
    toast.error('Could not read the layer standard',
                path)
else:
    apply_standard(text)
Don't
try:
    text = open(path).read()
except Exception as exc:
    forms.alert(str(exc))     # modal, and
                              # says nothing useful
    raise

One undo step per user action#

A tool started as a command is one undo group: AutoCAD opens it before your first line and closes it after your last, so a single U takes back everything, however many transactions the script used. Keep it that way. Do the whole job inside the command, and do not leave a modeless window behind that goes on editing the drawing after the script has returned: each of its edits is then a separate undo step, outside any command, and needs its own doc.lock(). A palette is different by nature; make each of its actions one coherent edit.

Inside the command, batch the work: one transaction over a thousand entities, not a thousand transactions. The library's bulk functions (ents.delete(), layers.merge(), blocks.apply_changes()) already do.

Counted progress, and a way out#

Show progress for anything that can run longer than a second or two, and offer Cancel for anything that can run longer than several seconds. Put the count in the label: "Checking 4,200 of 11,038" tells the user whether to wait or stop, and a percentage alone does not.

Do
with forms.progress('Tag audit') as p:
    for i, handle in enumerate(refs):
        p.check()
        if i % 100 == 0:
            p.update(float(i) / len(refs),
                     'Checking %d of %d'
                     % (i, len(refs)))
        check(handle)
Don't
for handle in refs:
    check(handle)
    output.progress(0.5, 'Working...')
# never moves, and no way out

When a run is cancelled part way, report the split honestly: "Deleted 3,180 of 4,200. 1,020 were left when you cancelled." That is a warning, not a success, and it must never claim work was undone when it was not.

Empty states are states#

Nothing selected, no results, no drawing open, no xrefs to manage: each of these is a real answer and needs saying, along with what the user should do about it.

Do
handles = sel.current()
if not handles:
    toast.info('Nothing selected',
               'Select the blocks to export, '
               'then run this again.')
Don't
handles = sel.current()
export(handles)        # writes an empty
                       # workbook and says
                       # "Exported 0 blocks"

Never open a window to say one line#

A bare print() opens the whole output window to show one sentence, and if that is all the script writes, the user gets a nearly empty window. It reads as a bug because it looks exactly like one.

Do
toast.success('Repathed 3 xrefs')
Don't
print('Repathed 3 xrefs')

Speak the drawing's language#

The user knows what they see in AutoCAD: layers, blocks, attributes, xrefs, layouts, model space. They do not know your variable names, and they should never meet an API type name.

Do
toast.warning('3 blocks have no TAG attribute')
Don't
toast.warning('3 BlockReference objects '
              'missing AttributeReference')

Copy rules#

  • Sentence case. "Merge layers", not "Merge Layers". (Button titles are the one place the shipped tools use title case: "Blocks to Excel".)
  • No em dashes or en dashes. A comma, a colon, a semicolon, or two sentences. This applies to every string a user can read.
  • No emoji, anywhere in the UI. Not in toasts, button labels or the output window.
  • Plain verbs. "Delete", "Rename", "Export". Not "Proceed", not "Execute", never "Utilise".
  • Errors say the fix. "Could not read the layer standard" plus the path beats "An error occurred", every time.
  • Count things. "Renamed 412 layers" beats "Renamed layers". The number is what the user wanted to know.
  • No apology, no exclamation. State what happened.
  • Say what the user sees. Layers, blocks, xrefs, layouts. Not LayerTableRecord, not BlockReference.

The same result, twice#

Fails the slop test Passes BLOCK RESULTS Great news, your drawing is tidy! 412 references Recommended fix DOOR-SINGLE on A-DOOR WINDOW-2P on A-GLAZ Hover a row for options Centred rows, tint washes, pill-soup, a "Recommended" badge, all-caps heading, hover-only actions, cheerful copy, and no count you can compare row to row. Block schedule 412 references to 2 blocks DOOR-SINGLE 316 WINDOW-2P 96 One left edge, numbers right-aligned and tabular, neutral selection with an accent rail, one 28px readout as the signature moment, and nothing else raising its voice.
The same 412 block references. The version on the right is quieter, denser, and tells you more, which is the whole argument for the system.

Before you ship#

Run your tool against this list. It takes two minutes and catches almost everything.

  • The docstring or tooltip: says what the tool does in a full sentence.
  • Anything that calls the tool by name has a stable command: to call.
  • Every exit path shows something, and cancel shows nothing.
  • Nothing selected, no results and no drawing open each have a message that says what to do next.
  • Predictable failures are caught and reported as a plain sentence with the fix.
  • Anything longer than a second shows counted progress; anything longer than several seconds can be cancelled.
  • One U takes back the whole tool.
  • Handles, not ObjectIds, are what the tool stores and passes around.
  • No print() for a one-line status, and no window opened for one sentence.
  • A script that may run under pyacad run guards its prompts with __runner__.
  • The accent appears in three places at most, and the selection is neutral.
  • Every control is reachable by Tab and shows a focus ring.
  • The tool reads well in both themes; you checked with AutoCAD light and dark.
  • Every string is sentence case, free of emoji, and free of em dashes.
  • No string names an API type the user has never heard of.
  • A screenshot of it would not look out of place beside the rest of the ribbon.