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#
| Surface | Blocks? | Use it for | Do not use it for |
|---|---|---|---|
pynavis.toast | No | The result of a finished action, in one line. | Questions, anything the user must read to continue. |
pynavis.forms | Yes | A question you must have answered, and file pickers. | Reporting a result the user did not ask about. |
pynavis.output | No | Tables, lists, links into the model, progress, long logs. | One sentence. Opening a window to say one line is a defect. |
| A custom WPF window | Your choice | A tool with many settings, a preview, or a list to work through. | Anything the three above already handle. |
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.
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.
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.
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.
toast.success('Exported 412 items', path)
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.
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 NoneThese 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#
| Function | On OK / Yes | On cancel or close | Correct test |
|---|---|---|---|
alert | None | None |
None; it is acknowledgement only. |
confirm | True | False |
if forms.confirm(...): |
ask_string | the typed string, possibly '' |
None | if answer is None: |
save_file | the chosen path | None |
if path: |
open_file | the chosen path | None |
if path: |
pick_folder | the chosen folder | None |
if folder: |
ask_number | a float, already in range |
None | if value is None: |
ask_options | the chosen entry from options |
None | if choice is None: |
select_from_list |
the value, or a list of values with multiselect=True |
None, never an empty list | if picked is None: |
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 == ''.
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)
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.
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.
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')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.
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.
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.
path = forms.save_file() if path: write_report(path) toast.success('Report saved', path) # no else: cancel exits silently
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.
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 ruleThe 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.
Markdown#
print_md understands a deliberately small subset, chosen to match what tool
scripts actually write.
| Supported | Not 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. |
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.
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.
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.
rows.append(['Level 05 unmodelled', '', ''])
rows.append(['Level 05 unmodelled', 'n/a', 'n/a'])
Element links#
element_link does not print anything. It returns a string of HTML, and you
embed it in whatever you print.
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.
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.
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.
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.
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.
| Selector | What it is |
|---|---|
.pynavis-card | The spacing wrapper each append lands in. |
.pynavis-card.err | The same card with an error wash and a red left rail. |
pre.pynavis-text | Monospaced console text, wrapped. |
pre.codeblock | A framed code block on the surface fill. |
table.pynavis | The framed table, with td.num and th.num for numeric cells. |
a.pynavis-el | An element link. |
#pynavis-progress | The 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.
--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 textThe 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.
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_pieandchart_doughnutcover 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 toprint_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.
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.
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.
"""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.