Bundles

Shortcuts and keytips

Keyboard chords that run a tool without touching the ribbon, and the Alt letters that walk to it.

A pyNavis tool can be reached three ways: by clicking it, by pressing a keyboard chord such as Ctrl+Shift+M, or by walking to it with Alt and a couple of letters. The second and third are what this page is about. Both are cheap to add and both are rebuilt from scratch on every boot and every Reload, so you can iterate on them as fast as you iterate on the script.

Two places a binding comes from#

You, the tool author, declare a default in bundle.yaml. The person using your tool overrides it in config.json. Neither knows about the other, and the user always wins.

01_Memorize.pushbutton\bundle.yaml
title: Memorize
tooltip: Stores the current selection in the memory register.
shortcut: Ctrl+Shift+M
keytip: MM
%APPDATA%\pyNavis\config.json
{
  "shortcuts": {
    "allowBareKeys": false,
    "bindings": {
      "pyNavis.tab/Memory.panel/01_Memorize.pushbutton": "Ctrl+Alt+M",
      "pyNavis.tab/Memory.panel/02_Recall.pushbutton": null
    }
  }
}

The first entry rebinds Memorize. The second disables Recall entirely: null means unbound, not "use the default". Users rarely write this file by hand, because the built-in Shortcuts editor on the pyNavis panel writes it for them, but the format is plain and worth knowing.

Pick defaults that will not fight the host

Navisworks and Windows already own a lot of the chord space. The shipped tools use Ctrl+Shift+letter, which is mostly free. Avoid plain Ctrl+letter for anything a user would expect to save, copy or print, and remember that your default is only a suggestion: anyone can move it.

The chord grammar#

A binding string is split on +, each token is trimmed, and every comparison is case-insensitive. ctrl + shift + m parses exactly like Ctrl+Shift+M. The vocabulary is deliberately small.

Token groupAcceptedNot accepted
Modifiers Ctrl, Alt, Shift Win, Control, Meta, Cmd
Letters and digits A to Z, 0 to 9 Punctuation of any kind
Function keys F1 to F24  
Arrows Left, Up, Right, Down  
Navigation Home, End, PgUp, PgDn PageUp, PageDown
Everything else Nothing Esc, Tab, Space, Enter, Del, Ins, numpad keys

Exactly one key token is required, and no more than one. A chord may carry any combination of the three modifiers, subject to the policy below.

What you wroteLogged error
"" or whitespaceempty binding
Ctrl+M+K 'Ctrl+M+K': more than one key
Ctrl+Esc 'Ctrl+Esc': unknown token 'Esc'
Ctrl+Shift 'Ctrl+Shift': no key
Shift+M 'Shift+M': bindings need Ctrl or Alt (set shortcuts.allowBareKeys to permit bare keys)

The Ctrl-or-Alt policy#

A binding must contain Ctrl or Alt. Shift+M and a bare M are both rejected, because a chord that is a single printable key would fire every time someone typed into a control the runtime failed to recognise as a text field.

A user who wants that anyway can opt in:

config.json
{ "shortcuts": { "allowBareKeys": true } }

The flag is read only when it is a real JSON boolean; a string "true" is ignored and the default of false stands. It is a user setting, not a bundle setting: as an author you cannot enable it for your own tool, so never ship a bare-key default.

How the map is built#

Before each ribbon build, every pushbutton-shaped bundle in every extension is walked, defaults and overrides are merged, and the result is one table of chord to button. Pushbutton-shaped means anything that runs a script.py on a click: *.pushbutton wherever it sits, plus *.nobutton, *.toggle and *.smartbutton. The other shapes take no chord: a urlbutton or linkbutton has no script to run, and a dockpane's shortcut: is parsed but reserved, not wired to the toggle in this release. The order of the walk is what breaks ties, so it is worth stating: extensions, then tabs, then panels, then the buttons of a panel in order, with stack and pulldown children flattened in place, and a panel's *.slideout contents last, after everything on its face. Duplicate BundleKey values dedupe, first occurrence wins.

Button bundle.yaml config.json Active binding Memorize first in ribbon order Ctrl+Shift+M (no entry) Ctrl+Shift+M the author default stands Recall user turned it off Ctrl+Shift+R null Unbound a user entry replaces the default, including a null one Clash Report later in ribbon order Ctrl+Shift+M (no entry) Skipped Memorize already owns that chord Every problem is written to the log with the prefix Shortcut: 'pyNavis.tab/Tools.panel/Clash_Report.pushbutton' wants Ctrl+Shift+M but 'pyNavis.tab/Memory.panel/01_Memorize.pushbutton' already has it - first wins
Three outcomes from the same merge: the default stands, the user disables it, or a duplicate loses to whoever the ribbon reached first.

The rules, in the order they apply:

  1. An override keyed to a tool that does not exist logs shortcut override for unknown tool '<key>' ignored and is dropped. This is what happens after you rename a bundle folder.
  2. For each button, a user entry replaces the author default entirely. A null value means unbound; the default is not consulted.
  3. The chosen string is parsed. A failure logs '<BundleKey>': <error> and the binding is skipped, leaving the tool unbound.
  4. If the parsed chord is already taken, the later button loses and logs '<key>' wants <chord> but '<winner>' already has it - first wins. First in ribbon order keeps the chord.
  5. All problems are logged with the prefix Shortcut: , followed by a summary line: Shortcuts: N binding(s) active.
A broken override looks fine in the editor

There is a deliberate asymmetry here. When a user's chord string fails to parse, the runtime skips it and the tool is unbound, but the Shortcuts editor dialog falls back to displaying the author default for that row. The dialog shows what the tool would have; the log shows what it actually has. If a shortcut is not firing, read %APPDATA%\pyNavis\logs\pyNavis.log and search for Shortcut:.

The BundleKey#

Overrides are keyed by BundleKey: the bundle's folder path relative to the .extension folder that contains it, with forward slashes. Nothing is stripped. Folder suffixes stay, and so do the NN_ ordering prefixes that the ribbon removes from the visible title.

On disk D:\NavisTools\ pyNavis.extension\ pyNavis.tab\ Memory.panel\ 01_Memorize.pushbutton\ The accent bar marks the levels that form the key. The root above it does not. The key pyNavis.tab/Memory.panel/ 01_Memorize.pushbutton Backslashes become forward slashes. .tab, .panel and .pushbutton are kept. The 01_ prefix is kept, even though the button is titled just “Memorize”. Lookup is case-insensitive.
The key starts inside the .extension folder, so it survives moving the extension to another drive but not renaming a folder inside it.

A button nested inside a stack or a pulldown keeps those folders in its key:

text
pyNavis.tab/Memory.panel/03_Set.stack/01_Add.pushbutton
pyNavis.tab/Memory.panel/05_Memory.pulldown/03_Purge.pushbutton
Do not type these by hand

Open the Shortcuts editor, set the binding there, and let it write the key. Renaming a bundle folder invalidates every override that points at it, and the only symptom is the unknown tool line in the log.

When a chord fires#

Bindings are watched by a keyboard hook, and a matched chord still has to clear three guards before anything runs.

A key goes down in Navisworks Does a binding match this chord? Fresh key down, not auto-repeat? Is the main window in the foreground? Is focus outside any text input? Run the bundle's script.py no The key is not swallowed It falls through to Navisworks exactly as if pyNavis were not listening. ShouldDispatch(isKeyDown, isRepeat, foregroundIsMain, focusIsTextInput) The three guards are one pure function, so they are unit-tested rather than guessed at.
Only a dispatched chord is swallowed. Every other path leaves the key press untouched, so a binding can never make a Navisworks shortcut stop working while you are typing somewhere else.

The guards in detail:

  • Fresh key down only. Holding the chord down does not run the tool repeatedly; auto-repeat is suppressed.
  • Navisworks must be foreground. The chord does nothing while another application has focus, even though the hook is still installed.
  • Focus must not be a text input. A WPF TextBoxBase or PasswordBox blocks dispatch, as does a native window whose class name contains "edit". If focus cannot be determined at all, the runtime assumes you are typing and does not fire. The safe answer is always "do nothing".
It is not a global hook

The hook is a thread-scoped WH_KEYBOARD hook on the Navisworks UI thread. It sees keys delivered to that thread and nothing else. pyNavis does not install a system-wide keyboard hook and cannot see what you type in other applications.

A chord always runs the primary script

Ctrl+Shift+M runs script.py, not config.py, even though the chord contains Shift. Keyboard chords and modifier click actions are separate mechanisms: Shift+Click is the only way to reach the secondary action. See Click actions.

A resolved binding is appended to the button's tooltip in canonical form, which is always Ctrl+Alt+Shift+Key in that order regardless of how it was spelled in the file. shift+ctrl+m and Ctrl+Shift+M both display as Ctrl+Shift+M.

Keytips#

Keytips are the letters that appear over the ribbon when you press and release Alt. They are assigned automatically, per tab, and you can steer the result with a keytip: line in bundle.yaml.

Assignment covers everything that renders on the tab: top-level pushbuttons, the child buttons of a stack, the pulldown menu button itself (and the same for a *.splitbutton and a *.splitpushbutton), urlbuttons, linkbuttons, and the ribbon toggle of a dockpane. Items inside a *.slideout are included too, and compete for letters with everything else on the tab. Pulldown children get none, because an open menu is navigated with the arrow keys, and a *.nobutton gets none because it never renders.

Title “Memorize”, letters only, uppercased, tried in this order M ME M2 M3 M4 M5 M6 M7 M8 M9 M is already taken by another item on this tab, so it is struck out. ME is the first free candidate and wins. If all ten were taken, the button would simply have no keytip; nothing is invented beyond this list. Explicit keytip: values are claimed first, before any of this runs. Two bundles asking for the same letters: the later one is dropped and gets no keytip at all.
Pass two, in full. The candidate list is first letter, first two letters, then the first letter followed by 2 through 9.

Assignment runs in two passes over the items of one tab:

  1. Explicit values claim first. Every keytip: from bundle.yaml is trimmed and uppercased, and takes its letters. Content is not validated: any string is accepted as written.
  2. Everything else is derived from the title. Non-letters are dropped and the rest uppercased, then the candidates above are tried in order and the first untaken one wins. A title with no letters at all gets nothing.
An explicit collision loses everything

If two bundles on the same tab both declare keytip: MM, the first one claims it and the second gets no keytip at all. It does not fall through to auto-assignment. Declaring a keytip is a commitment to it being unique on that tab.

Known limitation: every pyNavis tab is PY

The tab keytip itself is hard-coded to PY and nothing dedupes it. If two extensions each create a tab, both claim PY and Alt navigation into them becomes unreliable. Until this is fixed, put your buttons on the existing tab or accept the collision.

The Shortcuts editor#

The Shortcuts button on the pyNavis panel opens the editor: every tool, its author default, and its current binding. Recording a chord is a matter of clicking a row and pressing the keys.

KeyWhile recording
EscLeaves the field, changing nothing.
Backspace or Delete Clears the row, which means disabled. This is what writes a null.
TabAlways passes through, so the dialog stays keyboard navigable.
Modifiers aloneKeep recording; a chord is only captured once a real key arrives.

Every capture is round-tripped through the same parser the runtime uses, so the recorder physically cannot produce a binding the runtime would reject. An unsupported key shows unsupported key, and a chord without Ctrl or Alt shows needs Ctrl or Alt.

Saving writes a minimal map: only the rows that differ from their author default are stored, stale keys are dropped, and every other key in config.json is preserved. If nothing differs, the bindings key is removed, and shortcuts goes with it when nothing else is left under it. A malformed config logs Failed to load config '<path>' - using defaults. and unknown keys are ignored rather than rewritten away.

Checklist#

  • Author defaults use Ctrl+Shift+letter unless you have a reason not to.
  • Every binding contains Ctrl or Alt; never ship a bare-key default.
  • Key tokens come from the supported list only: letters, digits, F1 to F24, arrows, Home, End, PgUp, PgDn.
  • Check the log after a Reload for lines beginning Shortcut: and for the N binding(s) active summary.
  • Remember that a duplicate chord is resolved by ribbon order, so the tool nearest the start of the tab wins.
  • Declare keytip: only when you are sure it is unique on the tab; otherwise let the automatic pass do it.
  • Overrides are keyed by folder path, so renaming a bundle silently orphans them.