Scripts

Talking to the user

Four surfaces carry everything a tool says: a toast, a modal dialog, the HTML output window, and a custom window. Picking the right one is most of the work.

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 the whole of this page is about matching the message to the surface.

The surfaces are ordered by weight. Reach for the lightest one that can carry what you have to say, and move up only when it genuinely cannot.

Choosing a surface#

The question If this is true Use What do you need to tell the user, and how big is it? The script cannot continue until the user answers. The action is finished and the result fits on one line. It is a table, a list, links, a log, or work the user has to watch. It is a real tool: many settings, a preview, a list to pick from. forms modal, blocks the script toast one line, self-dismissing output HTML window, scrollable a WPF window yours, built by hand
Read the conditions top to bottom and take the first that is true. Most tools only ever need the middle two.
SurfaceBlocks?Use it forDo not use it for
pynavis.toastNo The result of a finished action, in one line. Questions, anything the user must read to continue.
pynavis.formsYes A question you must have answered, and file pickers. Reporting a result the user did not ask about.
pynavis.outputNo Tables, lists, links into the model, progress, long logs. One sentence. Opening a window to say one line is a defect.
A custom WPF windowYour choice A tool with many settings, a preview, or a list to work through. Anything the three above already handle.
The fourth surface

A hand-built WPF window is out of scope for these docs, but it exists and it is not exotic: the runtime ships src\PyNavis.Runtime\Forms\DesignSystem.cs, the shared v2 toolkit of tokens, buttons, group frames, input fields and focus rings that every pyNavis dialog is built from. Build from that toolkit rather than from raw WPF, and your window will match the rest of the product. Design rules for tools covers what it has to look like.

From Python the way in is forms.WPFWindow('layout.xaml'), which loads a XAML file shipped in the bundle folder, threads the runtime's themed chrome onto it, and hands back a live window: win['NameBox'].Text to read a field, win['OkButton'].Click += ... to wire a button, win.show_dialog() to show it modally. The cookbook's XAML dialog bundle is a whole working example. Reach for it once chained prompts stop being enough, which is at about three fields.

The fourth surface also comes in a docked form: a *.dockpane bundle puts your WPF content in a real Navisworks dock panel that stays open between runs, switched by a ribbon toggle. For a tool the user keeps returning to, a panel beats a floating window. Buttons, stacks and pulldowns covers the bundle.

Toast: one line, no interruption#

A toast appears in the corner of the Navisworks window, never takes focus, and dismisses itself. It is the default way a pyNavis tool reports what it did.

python
from pynavis import toast

toast.show(level, message, detail=None)   # level: 'success' | 'error' | 'info' | 'warning'

toast.success(message, detail=None)
toast.error(message, detail=None)
toast.info(message, detail=None)
toast.warning(message, detail=None)

Every argument is passed through str(), so handing a toast an integer, a ModelItem or any .NET object is safe and never raises. detail stays None when you omit it and renders as an optional second line in muted text. All five functions return None.

Toasts stack rather than overlap. Up to three sit above the bottom-right corner at once, the newest nearest the corner, and a fourth arrival dismisses the oldest. Each dismisses itself after a few seconds, errors linger longest, and hovering one holds it open long enough to read. A script that toasts twice in quick succession therefore shows both, so you still should not narrate: one toast per outcome.

One toast, drawn Renamed 412 items. 3 names were already correct 3px semantic rail message, always present detail, optional and muted 1.5px border, same colour The four levels success green error red info blue warning amber A toast is the one surface with nothing around it to read the level against, so the colour carries the whole border.
Message line, optional muted detail line, and a semantic colour that rings the card and thickens into a rail on the left.

What a toast must never do#

  • Never ask a question. A toast has no buttons and dismisses itself, so a question in one is a question nobody can answer.
  • Never carry an action. No "Undo" link, no "Show me". If the user must be able to act on it, it belongs in a dialog or the output window.
  • Never run past one line. Two sentences of message plus a detail line is already an output window that has not admitted it yet.
  • Never toast the obvious. Closing a dialog is its own feedback. Do not toast "Cancelled" or "Closed".

The Result idiom#

Several pynavis functions return a small Result object whose fields line up exactly with toast.show. When you see one, pass it straight through rather than unpacking and re-deciding the level yourself.

python
from pynavis import memory, toast

result = memory.memorize()
toast.show(result.level, result.message, result.detail)

That is the whole reporting layer of a lot of real tools. The library already knows whether the operation succeeded, half-succeeded or failed, and it already knows how to phrase it.

Never print a one-line status#

This is a project rule, not a preference. print() in a pyNavis script writes to the output window, and writing to the output window opens it. A script whose only output is one print() therefore opens a full window to display a single sentence, and if the string is empty or the write fails you get a blank window instead, which reads as a crash.

Do
toast.success('Exported 412 items', path)
Don't
print('Exported 412 items to ' + path)

print() is still the right call when you are genuinely writing a report: many lines, a log, a traceback you want kept. Use the output window deliberately, not by accident.

Dialogs: when you need an answer#

pynavis.forms wraps the runtime's WPF dialogs. Every one of them is modal and blocks your script until the user responds.

python
from pynavis import forms

forms.alert(message, title='pyNavis')                       # -> None
forms.confirm(message, title='pyNavis')                     # -> bool, True on Yes
forms.ask_string(prompt, default='', title='pyNavis')       # -> str or None
forms.save_file(filter='CSV files (*.csv)|*.csv|All files (*.*)|*.*',
                default_name='', title='Save file')         # -> str or None
forms.open_file(filter='All files (*.*)|*.*',
                title='Open file')                          # -> str or None
forms.pick_folder(title='Select folder', initial=None)      # -> str or None

forms.ask_number(prompt, default=None, min_value=None,
                 max_value=None, title='pyNavis')           # -> float or None
forms.ask_options(prompt, options, title='pyNavis')         # -> an option or None
forms.select_from_list(items, title='Select',
                       multiselect=False, prompt=None)      # -> value, list, or None

These touch no Navisworks API at all, so they work even while a model is still loading. confirm shows Yes then No, in that order, and closing the window counts as No.

What cancel returns#

FunctionOn OK / YesOn cancel or closeCorrect test
alertNoneNone None; it is acknowledgement only.
confirmTrueFalse if forms.confirm(...):
ask_stringthe typed string, possibly '' Noneif answer is None:
save_filethe chosen pathNone if path:
open_filethe chosen pathNone if path:
pick_folderthe chosen folderNone if folder:
ask_numbera float, already in range Noneif value is None:
ask_optionsthe chosen entry from options Noneif choice is None:
select_from_list the value, or a list of values with multiselect=True None, never an empty listif picked is None:
None and '' are different answers

ask_string returns None when the user cancelled and '' when the user cleared the box and pressed OK. Those mean opposite things: one says "do nothing", the other says "set this to empty". Test with is None or with plain truthiness, never with == ''.

Do
name = forms.ask_string('New name:')
if name is None:
    pass                    # cancelled: change nothing
elif not name:
    toast.warning('A name is required')
else:
    rename(name)
Don't
name = forms.ask_string('New name:')
if name == '':
    return                  # misses cancel entirely
rename(name)                # renames to "None"

The filter string#

The parameter is literally named filter, shadowing the builtin inside the function but not in your script, so keyword use reads as forms.save_file(filter='...'). The format is the standard Windows one: pairs of description and pattern separated by pipes.

python
path = forms.save_file(
    filter='CSV files (*.csv)|*.csv|Text files (*.txt)|*.txt|All files (*.*)|*.*',
    default_name='clashes.csv',
    title='Save clash report',
)

Beyond a text box: lists, options and numbers#

Three of the dialogs exist so a tool never has to validate free text it could have asked for properly in the first place.

ask_number validates live: the dialog itself refuses OK on a value outside [min_value, max_value], so what comes back is a float already in range and your script has nothing left to re-check. Leave either bound out to leave that side open.

ask_options is a one-click choice between a handful of options, shown as buttons rather than a dropdown. It returns the chosen entry from options itself, not its index. Passing prompt=None asks for the chromeless quick-switch look: no title bar, no question, just the buttons in a hairline shell, for choices whose labels are self-explanatory. Use a prompt whenever the labels alone would leave the user guessing.

select_from_list is the searchable picker, for anything longer than a handful. Items are plain strings, or (label, value) pairs when the thing you want back is not the text shown, which is how you hand back a live object and let the dialog show its name.

python
from pynavis import clash, forms

tolerance = forms.ask_number('Proximity tolerance (m):', default=6.0, min_value=0.0)

mode = forms.ask_options('Group by:', ['Level', 'Grid', 'Model'])
state = forms.ask_options(None, ['Section state', 'Hidden items'])   # chromeless

tests = list(clash.walk_tests())
picked = forms.select_from_list([(t.DisplayName, t) for t in tests],
                                title='Pick a clash test')
A cancelled multiselect is None, not an empty list

select_from_list(..., multiselect=True) returns a list on OK and None on cancel, so the two stay distinguishable: None means "do nothing", an empty list would mean "the user deliberately chose nothing". Test with is None before you test for emptiness.

A progress window with a Cancel button#

forms.progress(title, label='') is a context manager, and it is the other progress surface: unlike output.progress it blocks the rest of Navisworks and carries its own Cancel button. Reach for it when the user started a batch on purpose and might want to stop partway through.

python
from pynavis import forms, toast

try:
    with forms.progress('Exporting', 'Starting') as p:
        for index, item in enumerate(items):
            p.check()                          # raises forms.Cancelled on Cancel
            export_one(item)
            p.update(float(index) / len(items), '%d of %d' % (index, len(items)))
except forms.Cancelled:
    toast.info('Export stopped early')
else:
    toast.success('Exported %d item(s)' % len(items))

The bound object has two methods. p.check() raises forms.Cancelled once Cancel has been clicked, and it pumps the message loop first, which is what lets the button be clicked at all: your script owns the UI thread for its whole run. p.update(fraction, label=None) reports progress and returns False once the user has cancelled, for a loop that would rather test a value than catch an exception.

Catch Cancelled outside the with block, and let label default

p.check() raises inside the block, which closes the window on its way out, then keeps propagating. Catching it inside the block leaves the window up for the rest of the script. Separately, label defaults to None, not '', and None means keep whatever the window is already showing: after a Cancel click the window relabels itself, and a plain p.update(fraction) must not wipe that out. Pass an explicit '' on the rare occasion you do want the label blank.

Cancelling is silent#

When the user cancels, the script should end with no toast, no dialog and no output window. Cancelling is not an error and it is not a result; the user already knows what they did, and telling them again is noise.

Do
path = forms.save_file()
if path:
    write_report(path)
    toast.success('Report saved', path)
# no else: cancel exits silently
Don't
path = forms.save_file()
if path:
    write_report(path)
else:
    toast.info('Cancelled')   # noise

The output window#

The output window is a WebView2 page owned by the runtime. Writing to it opens it; writing again appends. It is where anything larger than a line goes.

python
from pynavis import output

output.print_html(html)              # -> None, renders an HTML fragment
output.print_md(markdown)            # -> None, renders a small markdown subset
output.print_table(rows, headers)    # -> None, renders a styled table
output.print_code(text)              # -> None, an escaped monospace block
output.print_image(path, caption=None)   # -> None, embeds png/jpg/gif/svg
output.progress(fraction, label='')  # -> None, 0..1; 1.0 completes the bar

output.chart_bar(labels, values, title=None)       # -> None, an SVG card
output.chart_line(labels, series, title=None)      # -> None, series is {name: [values]}
output.chart_pie(labels, values, title=None)       # -> None
output.chart_doughnut(labels, values, title=None)  # -> None

output.set_title(text)               # -> None, retitles the window
output.save(path)                    # -> None, writes the page as standalone HTML

output.element_link(item, label)     # -> str, HTML for a link that selects the item
output.table_html(rows, headers)     # -> str, the HTML print_table emits
output.format_table(rows, headers)   # -> str, aligned plain text with a dashed rule

The last three are pure functions that return strings. table_html and format_table touch nothing outside themselves, which makes them unit-testable on a machine with no Navisworks on it at all.

The output window pyNavis output Clash summary Group Clashes Open Level 03 duct vs beam 412 96 Level 04 tray vs wall 96 8 Level 04 pipe vs slab 1,204 311 Worst offender: Duct-Rect-482 Grouping 4,200 of 11,038 table.pynavis one frame, hairline rows td.num right-aligned and tabular because the data is numeric a.pynavis-el clicking selects the item #pynavis-progress counted, not indeterminate removes itself at 1.0
Everything in the window is styled by the runtime's own stylesheet. Custom HTML that reuses these class names inherits the styling for free.

Markdown#

print_md understands a deliberately small subset, chosen to match what tool scripts actually write.

SupportedNot supported
#, ##, ### headers Tables. Use print_table.
**bold**, *italic*, `code` Images, blockquotes, horizontal rules.
- bullet lists Numbered lists and nested lists.
Fenced ``` code blocks Language hints on the fence.
[text](url) links, paragraphs Reference-style links, footnotes.
print_md is safe by construction

The converter HTML-escapes its whole input before it converts anything. A property value containing <script>, a family name with an ampersand in it, or a path full of angle brackets renders as literal text and can never become live markup. This matters because most of what a tool prints comes from the model, not from you.

Tables and numeric alignment#

print_table takes rows first and headers second. The header list decides the column count: rows shorter than the headers are padded with empty strings, and cells past the end of the headers are dropped.

python
rows = [
    ['Level 03 duct vs beam', 412,   96],
    ['Level 04 tray vs wall', 96,     8],
    ['Level 04 pipe vs slab', '1,204', 311],
]
output.print_table(rows, ['Group', 'Clashes', 'Open'])

A column is right-aligned and set in tabular figures when every non-empty cell in it parses as a float once commas are stripped, and at least one cell is non-empty. Alignment follows the data, not the column's position.

One stray cell changes the whole column

Writing 'n/a' or '-' into a numeric column makes the entire column text, and it silently jumps back to the left. Use '' for a missing number: empty cells are ignored by the test, so the column stays numeric.

Do
rows.append(['Level 05 unmodelled', '', ''])
Don't
rows.append(['Level 05 unmodelled', 'n/a', 'n/a'])

element_link does not print anything. It returns a string of HTML, and you embed it in whatever you print.

python
worst = clashes[0].item
output.print_html('Worst clash: ' + output.element_link(worst, worst.DisplayName))

Clicking the link selects that ModelItem in the model. The runtime renders it as a real anchor with href and tabindex, so it is reachable by Tab and shows a focus ring, which is the reason it is an anchor and not a styled span.

Progress#

progress takes a fraction from 0 to 1 and an optional label. Passing 1.0 completes the bar, which then fades and removes itself.

python
total = len(items)
for index, item in enumerate(items):
    regroup(item)
    if index % 50 == 0:
        output.progress(float(index) / total,
                        'Grouping %d of %d' % (index, total))
output.progress(1.0, 'Done')

Prefer counted progress over an indeterminate bar whenever you know the total, and put the count in the label. "Grouping 4,200 of 11,038" tells the user whether to wait or to stop; a percentage on its own does not.

Charts#

Four chart shapes are built in, drawn as theme-aware SVG cards from the same palette as the rest of the window and printed through print_html underneath, so a chart call opens or appends to the window exactly like any other print_ function.

chart_bar, chart_pie and chart_doughnut take one series: parallel labels and values lists. chart_line takes several at once, as a {name: [values]} dict sharing one labels axis. A doughnut is a pie with a hole and takes the same arguments.

python
from pynavis import clash, output

summaries = clash.summarize()
output.chart_bar([s['name'] for s in summaries],
                 [s['total'] for s in summaries],
                 title='Clashes per test')

output.chart_line(['Week 1', 'Week 2', 'Week 3', 'Week 4'],
                  {'New': [412, 380, 210, 96], 'Resolved': [0, 88, 240, 340]},
                  title='Clash trend')

A chart earns its place when the shape of the data is the message. When the numbers themselves are the message, a table reads better and copies out; do not draw both.

A longer series does not skew the scale

chart_line shares one label axis across every series. When a series carries more values than there are labels, only the leading values are drawn, and the vertical scale is computed from that same truncated set rather than from the full series, so a long tail that is never plotted cannot inflate the peak and flatten the lines you can actually see.

Images, code blocks, and keeping the report#

print_image(path, caption=None) embeds a PNG, JPEG, GIF or SVG file as a data URI with an optional caption underneath, and raises ValueError for any other extension. It pairs with export.viewpoint_image: write the current view to a file, then put it in the report.

print_code(text) is an escaped, horizontally scrolling monospace block. It is the right home for a stack trace, a generated query, or the raw markup behind an export, because the escaping means anything at all is safe to pass it.

set_title(text) retitles the window. The default is the button's own title, so call this when the report is about something more specific than the button that produced it, the document name or the test just summarised. save(path) writes everything printed so far as one standalone HTML file that opens in a browser with no pyNavis and no Navisworks running, which is what you want when the report has to leave the machine.

python
from pynavis import forms, output, toast

output.set_title('Clash summary: Level 03')
output.print_table(rows, ['Test', 'Results', 'New'])

path = forms.save_file(filter='HTML files (*.html)|*.html|All files (*.*)|*.*',
                       default_name='clash-summary.html', title='Save the report')
if path:                                       # None means cancelled: say nothing
    output.save(path)
    toast.success('Report saved', path)

Custom HTML#

print_html takes any fragment. To make it look like the rest of the window, target the classes the stylesheet already defines.

SelectorWhat it is
.pynavis-cardThe spacing wrapper each append lands in.
.pynavis-card.errThe same card with an error wash and a red left rail.
pre.pynavis-textMonospaced console text, wrapped.
pre.codeblockA framed code block on the surface fill.
table.pynavisThe framed table, with td.num and th.num for numeric cells.
a.pynavis-elAn element link.
#pynavis-progressThe sticky progress box at the bottom.

The output page is the one CSS surface in the project. Its custom properties are emitted server-side, once, for the current theme. There is no prefers-color-scheme query and no data-theme attribute to match on: use the variables and you are themed.

text
--bg          page background        --muted      secondary text
--fg          body text              --row-hover  table row hover fill
--surface     table header, code     --accent     links, progress fill, focus ring
--line        row separators         --err-wash   error card background
--frame       group frames           --err-line   error card border
--err         error text
These are not the desktop token names

The desktop side of the design system calls the page background Paper and the body colour Ink. The output page emits --bg and --fg. Writing var(--paper) or var(--ink) into print_html silently resolves to nothing and you get an unstyled block.

python
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)">items scanned</div>'
    '</div>')

What the output window does not have#

  • Charts are four shapes, not a general plotting surface. output.chart_bar, chart_line, chart_pie and chart_doughnut cover bar, line, pie and doughnut, theme-aware and drawn as SVG. Anything else, a sparkline, a scatter plot, still means hand-writing SVG and passing it to print_html.
  • No collapsible sections. There is no disclosure or accordion primitive either. Use headers and keep the report short enough not to need one.
  • No cleared window. Writes append. Each run starts a fresh page.
WebView2 is not guaranteed

On a machine without the WebView2 runtime the window degrades to readable plain text. Your script never branches on this and never checks for it: print_table falls back to the aligned text form, and print_md falls back to its source. Write for the rich window and the plain one follows.

Theming your own surfaces#

The three built-in surfaces are already themed. If you build the fourth kind, a WPF window of your own, ask the host which way to paint it.

python
from pynavis import script

if script.is_dark_theme():
    background, foreground = '#202020', '#E8E8E8'
else:
    background, foreground = '#FFFFFF', '#1A1A1A'

Read it at the moment you build the window rather than caching it at import time: the user can switch the Navisworks theme between two runs of your tool. The full palette for both themes is in Design rules for tools, and DesignSystem.cs applies it for you if you build from the shared toolkit.

One tool, three surfaces#

A realistic tool uses all three in their proper places: a dialog to get the one thing it cannot guess, progress and a table for the work, and a toast for the verdict.

Audit.pushbutton\script.py
"""Audits the selected items for a missing property and writes a report."""
from pynavis import forms, output, selection, toast

PROPERTY = 'Assembly Code'

items = selection.get_items()

if not items:
    # 1. Empty state: say what is missing and what to do about it.
    toast.info('Nothing selected', 'Select the items to audit, then run this again.')

elif not forms.confirm('Audit %d selected items for "%s"?' % (len(items), PROPERTY),
                       'Property audit'):
    # 2. Dialog: the one thing the script cannot decide for itself.
    pass                                    # cancelled: silent, no toast

else:
    # 3. Output: counted progress while the work runs.
    missing = []
    total = len(items)
    for index, item in enumerate(items):
        if not read_property(item, PROPERTY):
            missing.append(item)
        if index % 100 == 0:
            output.progress(float(index) / total,
                            'Checking %d of %d' % (index, total))
    output.progress(1.0)

    if missing:
        # A table, because a list of names is not a one-line result.
        rows = [[item.DisplayName, model_of(item), depth_of(item)] for item in missing]
        output.print_md('## Missing "%s"\n%d of %d items.' % (PROPERTY, len(missing), total))
        output.print_table(rows, ['Item', 'Model', 'Depth'])
        output.print_html('First offender: '
                          + output.element_link(missing[0], missing[0].DisplayName))

    # 4. Toast: the one-line verdict, so the user need not read the window to know the answer.
    if missing:
        toast.warning('%d of %d items are missing "%s"' % (len(missing), total, PROPERTY),
                      'The full list is in the output window.')
    else:
        toast.success('All %d items have "%s"' % (total, PROPERTY))

read_property, model_of and depth_of are stand-ins for your own helpers; the surface work is everything around them.

Notice what is not there. No print(). No toast on cancel. No window opened before there was something worth putting in it. No message that repeats what the user can already see. And note the order: the toast fires last, so the user gets the verdict even if they never look at the window.