flexdesk 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (251) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/LICENSE +0 -0
  3. package/README.md +2261 -42
  4. package/THIRD_PARTY_LICENSES.md +0 -0
  5. package/css/base.css +1834 -75
  6. package/css/flexdesk.css +3373 -238
  7. package/css/overrides.css +16 -0
  8. package/css/reset.css +0 -0
  9. package/css/tokens.css +0 -0
  10. package/dist/canvas.js +6 -239
  11. package/dist/canvas.js.map +3 -3
  12. package/dist/charts.js +709 -18
  13. package/dist/charts.js.map +4 -4
  14. package/dist/chunk-3PHCPZHT.js +0 -0
  15. package/dist/chunk-3PHCPZHT.js.map +0 -0
  16. package/dist/chunk-4A7SJC3T.js +503 -0
  17. package/dist/chunk-4A7SJC3T.js.map +7 -0
  18. package/dist/{chunk-6JSOVNID.js → chunk-CVMCZZX2.js} +635 -10
  19. package/dist/chunk-CVMCZZX2.js.map +7 -0
  20. package/dist/chunk-D3COLZZ2.js +1311 -0
  21. package/dist/chunk-D3COLZZ2.js.map +7 -0
  22. package/dist/chunk-FOOS3T5L.js +0 -0
  23. package/dist/chunk-FOOS3T5L.js.map +0 -0
  24. package/dist/{chunk-SN4LSOAR.js → chunk-GOQLR5WR.js} +452 -2
  25. package/dist/chunk-GOQLR5WR.js.map +7 -0
  26. package/dist/{chunk-FL5KFNQH.js → chunk-HNU6MM2B.js} +52 -17
  27. package/dist/chunk-HNU6MM2B.js.map +7 -0
  28. package/dist/chunk-HYJYYEPK.js +375 -0
  29. package/dist/chunk-HYJYYEPK.js.map +7 -0
  30. package/dist/chunk-JKZLNKG6.js +2041 -0
  31. package/dist/chunk-JKZLNKG6.js.map +7 -0
  32. package/dist/chunk-JYWURG5T.js +0 -0
  33. package/dist/chunk-JYWURG5T.js.map +0 -0
  34. package/dist/{chunk-RQM5Z2MU.js → chunk-MCIXFNVF.js} +633 -311
  35. package/dist/chunk-MCIXFNVF.js.map +7 -0
  36. package/dist/chunk-N7HC3ADV.js +30 -0
  37. package/dist/chunk-N7HC3ADV.js.map +7 -0
  38. package/dist/{chunk-FBGP5KN5.js → chunk-Q36EPT2R.js} +3268 -203
  39. package/dist/chunk-Q36EPT2R.js.map +7 -0
  40. package/dist/chunk-QWQJUDPD.js +610 -0
  41. package/dist/chunk-QWQJUDPD.js.map +7 -0
  42. package/dist/{chunk-NLTOY57M.js → chunk-UURZ37SD.js} +28 -48
  43. package/dist/chunk-UURZ37SD.js.map +7 -0
  44. package/dist/chunk-UWO43BZU.js +111 -0
  45. package/dist/chunk-UWO43BZU.js.map +7 -0
  46. package/dist/chunk-WVFGV5FT.js +0 -0
  47. package/dist/chunk-WVFGV5FT.js.map +0 -0
  48. package/dist/core.js +170 -118
  49. package/dist/core.js.map +2 -2
  50. package/dist/editor.js +156 -70
  51. package/dist/editor.js.map +2 -2
  52. package/dist/flexdesk.css +3373 -238
  53. package/dist/flow.js +2789 -306
  54. package/dist/flow.js.map +4 -4
  55. package/dist/host.js +109 -3
  56. package/dist/host.js.map +3 -3
  57. package/dist/reset.css +0 -0
  58. package/dist/tile_registry-6WZPTUZV.js +0 -0
  59. package/dist/tile_registry-6WZPTUZV.js.map +0 -0
  60. package/dist/tiles.js +1131 -253
  61. package/dist/tiles.js.map +4 -4
  62. package/dist/tokens.css +0 -0
  63. package/dist/widgets.js +3254 -2112
  64. package/dist/widgets.js.map +4 -4
  65. package/dist/wm.js +1227 -345
  66. package/dist/wm.js.map +4 -4
  67. package/package.json +3 -1
  68. package/src/canvas/canvas_adapter.js +5 -0
  69. package/src/canvas/edge_router.js +0 -0
  70. package/src/canvas/graph/connector_router.js +0 -0
  71. package/src/canvas/graph/history_service.js +0 -0
  72. package/src/canvas/graph/node_platform.js +0 -0
  73. package/src/canvas/index.js +1 -0
  74. package/src/canvas/layered_layout.js +244 -0
  75. package/src/charts/chart_types.js +0 -0
  76. package/src/charts/data_series_plot_window.js +0 -0
  77. package/src/charts/downsample.js +2 -2
  78. package/src/charts/plot_config.js +53 -29
  79. package/src/charts/plot_config_editor.js +627 -0
  80. package/src/charts/plot_grid.js +449 -0
  81. package/src/charts/plot_popout_window.js +103 -317
  82. package/src/charts/plotly_wrapper.js +22 -2
  83. package/src/charts/series_transforms.js +206 -0
  84. package/src/core/event_bus.js +0 -0
  85. package/src/core/logging.js +1 -1
  86. package/src/core/settings.js +105 -19
  87. package/src/core/state_guard.js +93 -51
  88. package/src/core/state_guard_config.js +85 -74
  89. package/src/core/state_machine.js +0 -0
  90. package/src/editor/editor_pane.js +3 -3
  91. package/src/editor/monaco_editor_factory.js +124 -60
  92. package/src/editor/monaco_loader.js +103 -28
  93. package/src/editor/notebook_search.js +0 -0
  94. package/src/editor/notebook_tab_bar.js +38 -52
  95. package/src/editor/notebook_undo_manager.js +0 -0
  96. package/src/editor/split_pane_container.js +6 -3
  97. package/src/flow/canvas_editor/connections.js +90 -0
  98. package/src/flow/canvas_editor/editor.js +2351 -153
  99. package/src/flow/canvas_editor/index.js +2 -1
  100. package/src/flow/canvas_editor/layout.js +119 -12
  101. package/src/flow/canvas_editor/strings.js +80 -0
  102. package/src/flow/kit/always_before.js +0 -0
  103. package/src/flow/kit/catalogue.js +97 -3
  104. package/src/flow/kit/chip_input.js +0 -0
  105. package/src/flow/kit/dom.js +0 -0
  106. package/src/flow/kit/findings.js +0 -0
  107. package/src/flow/kit/history.js +0 -0
  108. package/src/flow/kit/index.js +3 -3
  109. package/src/flow/kit/keys.js +7 -1
  110. package/src/flow/kit/logic_graph.js +97 -15
  111. package/src/flow/kit/references.js +0 -0
  112. package/src/flow/kit/settings_panel.js +0 -0
  113. package/src/flow/kit/settings_schema.js +2 -1
  114. package/src/flow/kit/step_picker.js +0 -0
  115. package/src/flow/kit/strings.js +3 -1
  116. package/src/flow/kit/value_picker.js +0 -0
  117. package/src/flow/kit/widgets.js +86 -109
  118. package/src/flow/lanes/editor.js +136 -14
  119. package/src/flow/lanes/index.js +3 -1
  120. package/src/flow/lanes/layout.js +0 -0
  121. package/src/flow/lanes/pipeline.js +44 -2
  122. package/src/flow/lanes/preview.js +0 -0
  123. package/src/flow/lanes/strings.js +1 -0
  124. package/src/flow/outline/editor.js +0 -0
  125. package/src/flow/outline/index.js +0 -0
  126. package/src/flow/outline/mapping.js +0 -0
  127. package/src/flow/outline/menu.js +0 -0
  128. package/src/flow/outline/operations.js +0 -0
  129. package/src/flow/outline/recognise.js +0 -0
  130. package/src/flow/outline/strings.js +0 -0
  131. package/src/help/help_modal.js +168 -42
  132. package/src/help/help_registry.js +12 -1
  133. package/src/host/external_links.js +0 -0
  134. package/src/host/file_picker.js +117 -0
  135. package/src/host/host.js +10 -2
  136. package/src/host/photino_host.js +9 -0
  137. package/src/host/photino_host.md +21 -3
  138. package/src/host/pywebview_host.js +10 -0
  139. package/src/tiles/chart_tiles.js +53 -245
  140. package/src/tiles/config_schema.js +8 -0
  141. package/src/tiles/figure_tile.js +322 -0
  142. package/src/tiles/layout_persistence.js +3 -3
  143. package/src/tiles/list_tiles.js +422 -0
  144. package/src/tiles/scope_picker.js +96 -0
  145. package/src/tiles/tile_base.js +12 -1
  146. package/src/tiles/tile_config_form.js +442 -0
  147. package/src/tiles/tile_grid.js +38 -1
  148. package/src/tiles/tile_registry.js +0 -0
  149. package/src/tiling/bottom_panel_tabs.js +67 -99
  150. package/src/tiling/command_palette.js +64 -19
  151. package/src/tiling/content_registry.js +4 -1
  152. package/src/tiling/desktops.js +11 -1
  153. package/src/tiling/entity_sources.js +0 -0
  154. package/src/tiling/keymap.js +403 -128
  155. package/src/tiling/kind_taxonomy.js +78 -0
  156. package/src/tiling/landing_table.js +0 -0
  157. package/src/tiling/loading_overlay.js +0 -0
  158. package/src/tiling/nav_panel.js +59 -10
  159. package/src/tiling/page_factory.js +5 -5
  160. package/src/tiling/panel_availability.js +49 -0
  161. package/src/tiling/panel_keys.js +0 -0
  162. package/src/tiling/shell.js +114 -12
  163. package/src/tiling/tab_strip.js +0 -0
  164. package/src/tiling/tile_breadcrumb.js +0 -0
  165. package/src/tiling/tile_renderer.js +105 -9
  166. package/src/tiling/tile_tab_menu.js +0 -0
  167. package/src/tiling/tile_tree.js +0 -0
  168. package/src/tiling/wm.js +562 -30
  169. package/src/tiling/zoom.js +0 -0
  170. package/src/ui/base/component_base.js +0 -0
  171. package/src/ui/base/controller_base.js +0 -0
  172. package/src/ui/base/page_base.js +0 -0
  173. package/src/ui/components/about_dialog.js +0 -0
  174. package/src/ui/components/action_dropdown.js +30 -2
  175. package/src/ui/components/alert_log.js +0 -0
  176. package/src/ui/components/alerts_pane.js +0 -0
  177. package/src/ui/components/attribute_list_editor.js +0 -0
  178. package/src/ui/components/autocomplete_field.js +0 -0
  179. package/src/ui/components/cell_editor_host.js +211 -0
  180. package/src/ui/components/cell_grid.js +648 -0
  181. package/src/ui/components/cell_selection.js +220 -0
  182. package/src/ui/components/choice_controls.js +351 -0
  183. package/src/ui/components/color_field.js +211 -0
  184. package/src/ui/components/color_list_field.js +210 -0
  185. package/src/ui/components/column_fit.js +0 -0
  186. package/src/ui/components/column_groups.js +314 -0
  187. package/src/ui/components/column_order.js +142 -0
  188. package/src/ui/components/computing_status_window.js +98 -25
  189. package/src/ui/components/confirm_dialog.js +81 -38
  190. package/src/ui/components/context_menu.js +2 -2
  191. package/src/ui/components/conversation_view.js +440 -0
  192. package/src/ui/components/crosstab.js +289 -0
  193. package/src/ui/components/data_table.js +601 -62
  194. package/src/ui/components/detail_header.js +22 -11
  195. package/src/ui/components/document_outline.js +397 -0
  196. package/src/ui/components/drag_reorder.js +0 -0
  197. package/src/ui/components/fact_table.js +0 -0
  198. package/src/ui/components/fill_handle.js +322 -0
  199. package/src/ui/components/gallery_picker.js +19 -8
  200. package/src/ui/components/header_actions.js +55 -0
  201. package/src/ui/components/inline_renamer.js +0 -0
  202. package/src/ui/components/job_progress.js +51 -0
  203. package/src/ui/components/managed_window.js +27 -4
  204. package/src/ui/components/menu_bar.js +745 -0
  205. package/src/ui/components/modal.js +449 -75
  206. package/src/ui/components/notification_history.js +3 -3
  207. package/src/ui/components/progress.js +483 -0
  208. package/src/ui/components/record_list.js +0 -0
  209. package/src/ui/components/row_activation.js +0 -0
  210. package/src/ui/components/section_stack.js +298 -0
  211. package/src/ui/components/series.js +173 -0
  212. package/src/ui/components/settings_view.js +634 -0
  213. package/src/ui/components/slide_out_panel.js +43 -6
  214. package/src/ui/components/slider_field.js +275 -18
  215. package/src/ui/components/sortable_list.js +26 -4
  216. package/src/ui/components/staged_edits.js +343 -0
  217. package/src/ui/components/staged_history.js +172 -0
  218. package/src/ui/components/table_filter_chips.js +0 -0
  219. package/src/ui/components/table_state_store.js +0 -0
  220. package/src/ui/components/table_structure.js +182 -0
  221. package/src/ui/components/table_values.js +0 -0
  222. package/src/ui/components/tabs.js +497 -0
  223. package/src/ui/components/tag_input.js +215 -0
  224. package/src/ui/components/toast.js +25 -8
  225. package/src/ui/components/toggle_field.js +175 -0
  226. package/src/ui/components/tree_view.js +55 -34
  227. package/src/ui/components/window_taskbar.js +0 -0
  228. package/src/ui/components/wizard.js +563 -0
  229. package/src/ui/controllers/window_chrome_controller.js +2 -2
  230. package/src/ui/notification_center.js +375 -119
  231. package/src/ui/utils/auto_scrollbars.js +0 -0
  232. package/src/ui/utils/markdown.js +709 -0
  233. package/src/ui/utils/native_context_menu.js +0 -0
  234. package/src/ui/utils/overlay_scrollbar.js +16 -12
  235. package/src/ui/utils/raf_resize_observer.js +0 -0
  236. package/src/ui/utils/scroll_lock.js +0 -0
  237. package/src/ui/utils/strip_scroll.js +55 -0
  238. package/src/ui/utils/tooltip_service.js +0 -8
  239. package/dist/chunk-6JSOVNID.js.map +0 -7
  240. package/dist/chunk-CWPMLX67.js +0 -475
  241. package/dist/chunk-CWPMLX67.js.map +0 -7
  242. package/dist/chunk-FBGP5KN5.js.map +0 -7
  243. package/dist/chunk-FL5KFNQH.js.map +0 -7
  244. package/dist/chunk-NLTOY57M.js.map +0 -7
  245. package/dist/chunk-NNFTETK3.js +0 -633
  246. package/dist/chunk-NNFTETK3.js.map +0 -7
  247. package/dist/chunk-O5OHMWBB.js +0 -284
  248. package/dist/chunk-O5OHMWBB.js.map +0 -7
  249. package/dist/chunk-RQM5Z2MU.js.map +0 -7
  250. package/dist/chunk-SN4LSOAR.js.map +0 -7
  251. package/src/ui/controllers/panel_state_machine.js +0 -634
@@ -1,5 +1,5 @@
1
1
  /**
2
- * keymap.js — Alt-based shortcuts for the tiling window manager.
2
+ * keymap.js — the window manager's keyboard shortcuts.
3
3
  *
4
4
  * Alt+H split focused tile horizontally (new empty pane)
5
5
  * Alt+V split focused tile vertically (new empty pane)
@@ -16,18 +16,46 @@
16
16
  * Ctrl+ArrowLeft/Right iterate virtual desktops
17
17
  * Shift+ArrowLeft/Right iterate bottom-panel tabs (when present)
18
18
  * Ctrl+Tab / Ctrl+Shift+Tab next / previous main-panel tab (focused tile)
19
- * F1..F8 jump to the Nth top-nav page
19
+ * F1..F12 jump to the Nth top-nav page (the table says F1..F8)
20
+ * Backspace go back one step up the breadcrumb
20
21
  * Ctrl+K open command palette
21
- * ? open Help (lists every shortcut) — bound globally
22
- * by help_modal.js, not here
23
- * Esc close palette / managed-modal
22
+ * ? open Help
23
+ * Esc close the palette
24
+ * any other Alt+letter swallowed (no browser menu-access mid-flow)
24
25
  *
25
- * The full user-facing reference is the Help modal's "Keyboard shortcuts"
26
- * topic (ui/js/help/help_service.js) — keep that table in sync when
27
- * binding new chords here.
26
+ * ── The bindings are DATA, and an embedder may change them ─────────────
27
+ * Every chord above is one row of `DEFAULT_CHORDS`: a chord id, the action it
28
+ * runs, and whether it also fires while focus is in an editable field. An
29
+ * embedder passes `bindings: { [chordId]: action | false | fn }` (through
30
+ * `createShell({ keymap: { bindings } })`):
28
31
  *
29
- * The handler refuses to act when focus is in an editable field, unless
30
- * the chord uses the Alt modifier (which is never typed into a field).
32
+ * 'F1': false the chord is the window manager's no more; the event
33
+ * is not prevented, so the application's own handler
34
+ * gets it untouched
35
+ * 'F2': 'nav:0' the chord runs another built-in action (`ACTIONS`)
36
+ * 'Alt+W': (e, ctx) => … the chord runs the embedder's function; the event is
37
+ * prevented unless it returns `false`
38
+ *
39
+ * Only the chords listed in `DEFAULT_CHORDS` can be rebound — the keymap
40
+ * owns those and nothing else. An unknown chord id, or an unknown action
41
+ * name, is reported ONCE (a warning when the keymap is installed) and
42
+ * ignored, so a typo shows up at boot instead of as a key that silently does
43
+ * nothing.
44
+ *
45
+ * ── An event somebody else handled is theirs ──────────────────────────
46
+ * The listener is on `document`, so every component's own keydown handler
47
+ * runs first. One that called `preventDefault()` — a list's Backspace that
48
+ * deletes a row, an editor's Alt+Arrow — keeps its behaviour: the keymap
49
+ * skips every event that is already `defaultPrevented`.
50
+ *
51
+ * The user-facing reference is `KEYMAP_SHORTCUTS` below, which the palette's
52
+ * built-in *Keyboard shortcuts* command shows as a help topic (see
53
+ * `keyboardShortcutsTopic`); `keymapShortcuts(bindings)` is the same table
54
+ * after an embedder's bindings, and that is what the shell hands the palette.
55
+ * Keep the table and `DEFAULT_CHORDS` in step when binding new chords here.
56
+ *
57
+ * Unless a chord is marked as working in fields, the handler refuses to act
58
+ * when focus is in an editable field.
31
59
  *
32
60
  * ── It returns a disposer, and that is not decoration ──────────────────
33
61
  * The listener is on `document` and is bound to ONE shell's `wm` and
@@ -37,138 +65,385 @@
37
65
  * attached for ever. Two handlers is not "twice as responsive": `Ctrl+K`
38
66
  * toggles the dead palette open and the live one closed in the same keystroke,
39
67
  * and `Alt+W` closes a tile in a tree nobody can see. `createShell().dispose()`
40
- * calls this.
68
+ * calls this. The `?` help shortcut is one of these chords, so it comes off
69
+ * with the rest — importing the help modal installs no listener of its own.
41
70
  */
42
71
 
43
- export function installKeymap({ wm, palette, ...opts } = {}) {
44
- const onKeyDown = (e) => {
45
- const inField = e.target?.closest?.(
46
- 'input, textarea, select, [contenteditable="true"]');
72
+ import { HelpModal } from '../help/help_modal.js';
47
73
 
48
- if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k' && !e.shiftKey) {
49
- e.preventDefault();
50
- palette.toggle();
51
- return;
52
- }
53
- // ── Ctrl+Tab / Ctrl+Shift+Tab → next / previous main-panel tab in the
54
- // focused tile (wraps). Works everywhere (Tab isn't a text key here);
55
- // no-op when the tile has a single tab.
56
- if ((e.ctrlKey || e.metaKey) && !e.altKey && e.key === 'Tab') {
57
- e.preventDefault();
58
- wm.cycleFocusedTab(e.shiftKey ? -1 : 1);
59
- return;
60
- }
61
- if (e.key === 'Escape') {
62
- if (palette.isOpen()) { palette.close(); return; }
63
- // Let managed-window system handle its own Esc.
64
- }
65
- // Backspace = walk one step up the breadcrumb hierarchy. Skips
66
- // when the user is typing in a field so it doesn't steal
67
- // text-deletion.
68
- if (e.key === 'Backspace' && !inField && !e.metaKey && !e.ctrlKey) {
69
- e.preventDefault();
70
- wm.navigateBack?.();
71
- return;
72
- }
74
+ /**
75
+ * The window manager's default bindings, as the user reads them: one row per
76
+ * chord (or family of chords), `keys` as drawn, `action` in plain words.
77
+ */
78
+ export const KEYMAP_SHORTCUTS = Object.freeze([
79
+ { keys: 'Ctrl+K', action: 'Open the command palette' },
80
+ { keys: '?', action: 'Open help' },
81
+ { keys: 'Esc', action: 'Close the palette or a modal' },
82
+ { keys: 'F1..F8', action: 'Jump to the Nth top-nav page' },
83
+ { keys: 'Backspace', action: 'Go back one step up the breadcrumb' },
84
+ { keys: 'Alt+H / Alt+V', action: 'Split the focused tile horizontally / vertically (empty pane)' },
85
+ { keys: 'Alt+Shift+H / Alt+Shift+V', action: "Open the focused tile's content in a horizontal / vertical split" },
86
+ { keys: 'Alt+T', action: "Open the focused tile's content in a new tab" },
87
+ { keys: 'Alt+N', action: "Open the focused tile's content in a new window" },
88
+ { keys: 'Alt+W', action: 'Close the focused tile' },
89
+ { keys: 'Alt+F', action: 'Float the focused tile into a window, or dock it back' },
90
+ { keys: 'Alt+Arrow', action: 'Move focus to the neighbouring tile' },
91
+ { keys: 'Alt+Shift+Arrow', action: 'Swap the focused tile with its neighbour' },
92
+ { keys: 'Alt+1..9', action: 'Switch to desktop N' },
93
+ { keys: 'Alt+Shift+1..9', action: 'Move the focused tile to desktop N' },
94
+ { keys: 'Ctrl+Left / Ctrl+Right', action: 'Previous / next desktop' },
95
+ { keys: 'Shift+Left / Shift+Right', action: 'Previous / next bottom-panel tab' },
96
+ { keys: 'Ctrl+Tab / Ctrl+Shift+Tab', action: 'Next / previous tab in the focused tile' },
97
+ ].map((r) => Object.freeze(r)));
98
+
99
+ /** The help topic id the palette's *Keyboard shortcuts* command opens. */
100
+ export const KEYBOARD_SHORTCUTS_TOPIC_ID = 'keyboard-shortcuts';
73
101
 
74
- // ── F1..F8 → jump straight to the Nth top-nav page. F-keys never
75
- // type, but we still skip while a field is focused so editing
76
- // flows (query editor, filters) keep keyboard focus.
77
- const fMatch = /^F([1-9]|1[0-2])$/.exec(e.key);
78
- if (fMatch && !inField && !e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
102
+ const _esc = (s) => String(s).replace(/[&<>"']/g, (c) => (
103
+ { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
104
+
105
+ /**
106
+ * A shortcuts table (`KEYMAP_SHORTCUTS`, or `keymapShortcuts(bindings)`) as a
107
+ * help topic (`{id, title, html}`, the shape a HelpProvider's `loadTopic`
108
+ * answers). The palette hands it to HelpModal as the FALLBACK for its built-in
109
+ * command: an application whose provider defines `keyboard-shortcuts` shows
110
+ * its own topic; one that defines none — or registers no help at all — shows
111
+ * this table instead of an error.
112
+ */
113
+ export function keyboardShortcutsTopic(shortcuts = KEYMAP_SHORTCUTS) {
114
+ const rows = shortcuts.map((r) =>
115
+ `<tr><td><kbd>${_esc(r.keys)}</kbd></td><td>${_esc(r.action)}</td></tr>`).join('');
116
+ return {
117
+ id: KEYBOARD_SHORTCUTS_TOPIC_ID,
118
+ title: 'Keyboard shortcuts',
119
+ html: '<table class="help-shortcuts"><thead><tr><th>Keys</th><th>Action</th></tr></thead>'
120
+ + `<tbody>${rows}</tbody></table>`,
121
+ };
122
+ }
123
+
124
+ // ══ Actions ════════════════════════════════════════════════════════════
125
+ // What a chord can run, by name. `run(ctx, e)` answers `false` when the event
126
+ // is to be left unprevented (an F-key with no Nth top-nav button, Esc). `label`
127
+ // is the shortcuts table's words; an action without one is not listed.
128
+
129
+ const DEFAULT_NAV_SELECTOR = '.twm-global-top-bar .twm-bar-center.twm-top-nav .twm-top-nav__btn';
130
+ const DIRS = ['left', 'right', 'up', 'down'];
131
+ const _where = (d) => (d === 'up' || d === 'down' ? d : `on the ${d}`);
132
+
133
+ /** The fixed-name actions. Parameterised ones (`nav:N`, `desktop:N`,
134
+ * `toDesktop:N`, all counted from 0) are built by `_action`. */
135
+ const ACTIONS = {
136
+ 'palette': { label: 'Open the command palette', run: ({ palette }) => { palette.toggle(); } },
137
+ 'palette:close': { label: 'Close the palette', run: ({ palette }) => {
138
+ // Not prevented either way: a managed modal handles its own Esc.
139
+ if (palette.isOpen()) palette.close();
140
+ return false;
141
+ } },
142
+ 'help': { label: 'Open help', run: () => { HelpModal.toggle(); } },
143
+ 'back': { label: 'Go back one step up the breadcrumb', run: ({ wm }) => { wm.navigateBack?.(); } },
144
+ 'tab:next': { label: 'Next tab in the focused tile', run: ({ wm }) => { wm.cycleFocusedTab(1); } },
145
+ 'tab:prev': { label: 'Previous tab in the focused tile', run: ({ wm }) => { wm.cycleFocusedTab(-1); } },
146
+ 'desktop:next': { label: 'Next desktop', run: ({ wm }) => { wm.cycleDesktop(1); } },
147
+ 'desktop:prev': { label: 'Previous desktop', run: ({ wm }) => { wm.cycleDesktop(-1); } },
148
+ 'panelTab:next': { label: 'Next bottom-panel tab', run: () => _cyclePanelTab(1) },
149
+ 'panelTab:prev': { label: 'Previous bottom-panel tab', run: () => _cyclePanelTab(-1) },
150
+ 'split:h': { label: 'Split the focused tile horizontally (empty pane)', run: ({ wm }) => { wm.split('h'); } },
151
+ 'split:v': { label: 'Split the focused tile vertically (empty pane)', run: ({ wm }) => { wm.split('v'); } },
152
+ 'splitWith:h': { label: "Open the focused tile's content in a horizontal split", run: ({ wm }) => { wm.splitFocusedWith('h'); } },
153
+ 'splitWith:v': { label: "Open the focused tile's content in a vertical split", run: ({ wm }) => { wm.splitFocusedWith('v'); } },
154
+ 'openInTab': { label: "Open the focused tile's content in a new tab", run: ({ wm }) => { wm.openFocusedInTab(); } },
155
+ 'openInWindow': { label: "Open the focused tile's content in a new window", run: ({ wm }) => { wm.openFocusedInWindow(); } },
156
+ 'close': { label: 'Close the focused tile', run: ({ wm }) => { wm.closeFocused(); } },
157
+ 'float': { label: 'Float the focused tile into a window, or dock it back', run: ({ wm }) => { wm.toggleManagedFocused(); } },
158
+ // Bare Alt+letter: browsers open their menu bar on it, mid-flow.
159
+ 'suppress': { label: null, run: () => {} },
160
+ ...Object.fromEntries(DIRS.flatMap((d) => [
161
+ [`focus:${d}`, { label: `Move focus to the tile ${_where(d)}`, run: ({ wm }) => { wm.focusDir(d); } }],
162
+ [`move:${d}`, { label: `Swap the focused tile with its neighbour ${_where(d)}`, run: ({ wm }) => { wm.moveFocused(d); } }],
163
+ ])),
164
+ };
165
+
166
+ /** The action a name refers to, or null. */
167
+ function _action(name) {
168
+ if (typeof name !== 'string') return null;
169
+ if (Object.hasOwn(ACTIONS, name)) return ACTIONS[name];
170
+ const m = /^(nav|desktop|toDesktop):(\d+)$/.exec(name);
171
+ if (!m) return null;
172
+ const n = Number(m[2]);
173
+ if (m[1] === 'nav') {
174
+ return {
175
+ label: `Jump to top-nav page ${n + 1}`,
79
176
  // The selector is a CONFIG option, not a constant. An embedder that
80
177
  // moves its sections out of the top bar — into an icon rail, say —
81
- // would otherwise find F1..F8 silently doing nothing: the query
82
- // returns an empty NodeList, `idx < btns.length` is false, and the
83
- // handler falls through without a hint that anything is bound.
84
- // Passing `navSelector` is how such an embedder keeps the keys.
85
- const btns = document.querySelectorAll(
86
- opts.navSelector
87
- || '.twm-global-top-bar .twm-bar-center.twm-top-nav .twm-top-nav__btn');
88
- const idx = Number(fMatch[1]) - 1;
89
- if (idx < btns.length) {
90
- e.preventDefault();
91
- btns[idx].click();
92
- return;
93
- }
94
- }
178
+ // would otherwise find F1..F8 silently doing nothing. Passing
179
+ // `navSelector` is how such an embedder keeps the keys.
180
+ run: ({ navSelector }) => {
181
+ const btns = document.querySelectorAll(navSelector || DEFAULT_NAV_SELECTOR);
182
+ if (n >= btns.length) return false;
183
+ btns[n].click();
184
+ return undefined;
185
+ },
186
+ };
187
+ }
188
+ if (m[1] === 'desktop') {
189
+ return { label: `Switch to desktop ${n + 1}`, run: ({ wm }) => { wm.switchDesktop(n); } };
190
+ }
191
+ return { label: `Move the focused tile to desktop ${n + 1}`, run: ({ wm }) => { wm.moveFocusedToDesktop(n); } };
192
+ }
95
193
 
96
- // ── Shift+Left/Right → previous/next bottom-panel tab. No-op when
97
- // no bottom panel is mounted. Skipped in fields so Shift+Arrow
98
- // keeps selecting text there.
99
- if (e.shiftKey && !e.altKey && !e.ctrlKey && !e.metaKey && !inField
100
- && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
101
- const tabs = [...document.querySelectorAll('.twm-bp__tabs .twm-bp__tab')];
102
- if (tabs.length) {
103
- const cur = tabs.findIndex((t) => t.classList.contains('twm-bp__tab--on'));
104
- const dir = e.key === 'ArrowRight' ? 1 : -1;
105
- const next = ((cur < 0 ? 0 : cur) + dir + tabs.length) % tabs.length;
106
- e.preventDefault();
107
- tabs[next].click();
108
- return;
109
- }
110
- }
194
+ /** Shift+Left/Right: previous/next bottom-panel tab; nothing without one. */
195
+ function _cyclePanelTab(dir) {
196
+ const tabs = [...document.querySelectorAll('.twm-bp__tabs .twm-bp__tab')];
197
+ if (!tabs.length) return false;
198
+ const cur = tabs.findIndex((t) => t.classList.contains('twm-bp__tab--on'));
199
+ tabs[((cur < 0 ? 0 : cur) + dir + tabs.length) % tabs.length].click();
200
+ return undefined;
201
+ }
111
202
 
112
- // ── Ctrl+Left/Right → iterate virtual desktops (wraps).
113
- if (e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey && !inField
114
- && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
115
- e.preventDefault();
116
- wm.cycleDesktop(e.key === 'ArrowRight' ? 1 : -1);
117
- return;
118
- }
203
+ // ══ Chords ═════════════════════════════════════════════════════════════
119
204
 
120
- if (!e.altKey) return;
121
- // Browser Alt+letter triggers menu-access in some browsers — always preventDefault.
122
- const key = e.key.toLowerCase();
123
-
124
- // Desktop switch / move — Alt+digit
125
- if (/^[1-9]$/.test(e.key)) {
126
- e.preventDefault();
127
- const idx = Number(e.key) - 1;
128
- if (e.shiftKey) wm.moveFocusedToDesktop(idx);
129
- else wm.switchDesktop(idx);
130
- return;
205
+ const ARROWS = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'];
206
+ const DIGITS = [1, 2, 3, 4, 5, 6, 7, 8, 9];
207
+ const F_KEYS = Array.from({ length: 12 }, (_, i) => `F${i + 1}`);
208
+
209
+ /**
210
+ * Every chord the window manager binds: `[chordId, action, worksInFields]`.
211
+ * Alt is never typed into a field, and Ctrl+K / Ctrl+Tab / Esc are not text
212
+ * keys; everything else would steal an editing key, so it is skipped while
213
+ * focus is in an input, a textarea, a select or a contenteditable.
214
+ *
215
+ * `Alt+Letter` is not a key but the family "any Alt+letter no other row
216
+ * binds"; it only prevents the browser's menu-access.
217
+ */
218
+ const DEFAULT_CHORDS = [
219
+ ['Ctrl+K', 'palette', true],
220
+ ['Meta+K', 'palette', true],
221
+ ['?', 'help', false],
222
+ ['Escape', 'palette:close', true],
223
+ ...F_KEYS.map((f, i) => [f, `nav:${i}`, false]),
224
+ ['Backspace', 'back', false],
225
+ ['Alt+H', 'split:h', true],
226
+ ['Alt+V', 'split:v', true],
227
+ ['Alt+Shift+H', 'splitWith:h', true],
228
+ ['Alt+Shift+V', 'splitWith:v', true],
229
+ ['Alt+T', 'openInTab', true],
230
+ ['Alt+N', 'openInWindow', true],
231
+ ['Alt+W', 'close', true],
232
+ ['Alt+F', 'float', true],
233
+ ...ARROWS.map((k, i) => [`Alt+${k}`, `focus:${DIRS[i]}`, true]),
234
+ ...ARROWS.map((k, i) => [`Alt+Shift+${k}`, `move:${DIRS[i]}`, true]),
235
+ ...DIGITS.map((d) => [`Alt+${d}`, `desktop:${d - 1}`, true]),
236
+ ...DIGITS.map((d) => [`Alt+Shift+${d}`, `toDesktop:${d - 1}`, true]),
237
+ ['Ctrl+ArrowLeft', 'desktop:prev', false],
238
+ ['Ctrl+ArrowRight', 'desktop:next', false],
239
+ ['Shift+ArrowLeft', 'panelTab:prev', false],
240
+ ['Shift+ArrowRight', 'panelTab:next', false],
241
+ ['Ctrl+Tab', 'tab:next', true],
242
+ ['Meta+Tab', 'tab:next', true],
243
+ ['Ctrl+Shift+Tab', 'tab:prev', true],
244
+ ['Meta+Shift+Tab', 'tab:prev', true],
245
+ ['Alt+Letter', 'suppress', false],
246
+ ];
247
+
248
+ /** The chords each `KEYMAP_SHORTCUTS` row stands for, row for row. */
249
+ const SHORTCUT_CHORDS = [
250
+ ['Ctrl+K', 'Meta+K'],
251
+ ['?'],
252
+ ['Escape'],
253
+ F_KEYS,
254
+ ['Backspace'],
255
+ ['Alt+H', 'Alt+V'],
256
+ ['Alt+Shift+H', 'Alt+Shift+V'],
257
+ ['Alt+T'],
258
+ ['Alt+N'],
259
+ ['Alt+W'],
260
+ ['Alt+F'],
261
+ ARROWS.map((k) => `Alt+${k}`),
262
+ ARROWS.map((k) => `Alt+Shift+${k}`),
263
+ DIGITS.map((d) => `Alt+${d}`),
264
+ DIGITS.map((d) => `Alt+Shift+${d}`),
265
+ ['Ctrl+ArrowLeft', 'Ctrl+ArrowRight'],
266
+ ['Shift+ArrowLeft', 'Shift+ArrowRight'],
267
+ ['Ctrl+Tab', 'Meta+Tab', 'Ctrl+Shift+Tab', 'Meta+Shift+Tab'],
268
+ ];
269
+
270
+ const DEFAULTS = new Map(DEFAULT_CHORDS.map(([id, action, inField]) => [id, { action, inField }]));
271
+ const BY_LOWER = new Map([...DEFAULTS.keys()].map((id) => [id.toLowerCase(), id]));
272
+
273
+ const MODIFIER_ORDER = ['Ctrl', 'Meta', 'Alt', 'Shift'];
274
+ const MODIFIER_NAMES = {
275
+ ctrl: 'Ctrl', control: 'Ctrl', meta: 'Meta', cmd: 'Meta', command: 'Meta',
276
+ alt: 'Alt', option: 'Alt', shift: 'Shift',
277
+ };
278
+ const KEY_ALIASES = { esc: 'Escape', left: 'ArrowLeft', right: 'ArrowRight', up: 'ArrowUp', down: 'ArrowDown' };
279
+
280
+ /** Shift is how a `?` is typed, so it is not part of that chord; letters,
281
+ * digits and named keys (Tab, ArrowLeft) keep it. */
282
+ const _shiftCounts = (key) => key.length !== 1 || /^[\p{L}\p{N}]$/u.test(key);
283
+
284
+ function _compose(mods, key) {
285
+ const k = key.length === 1 ? key.toUpperCase() : key;
286
+ const used = MODIFIER_ORDER.filter((m) => mods.has(m) && (m !== 'Shift' || _shiftCounts(key)));
287
+ return [...used, k].join('+');
288
+ }
289
+
290
+ /**
291
+ * The chord id a keydown event is read as, in `DEFAULT_CHORDS`' spelling, or
292
+ * null. This is NOT an exact match on the modifier set: it reads an event the
293
+ * way the window manager always has, so a page with no bindings behaves as it
294
+ * did before chords could be rebound. In order, first match wins:
295
+ *
296
+ * Ctrl or Meta (either or both, Alt allowed) + K, no Shift Ctrl+K (Meta+K
297
+ * when Ctrl is not held)
298
+ * Ctrl or Meta (either or both) + Tab, no Alt Ctrl+Tab / Ctrl+Shift+Tab
299
+ * (Meta+… when Ctrl is not held)
300
+ * Esc, any modifiers Escape
301
+ * ?, any modifiers ?
302
+ * Backspace, neither Ctrl nor Meta (Shift and Alt ignored) Backspace
303
+ * F1..F12, Shift+Left/Right, Ctrl+Left/Right exactly those modifiers
304
+ * with Alt held, Ctrl and Meta are ignored:
305
+ * Alt(+Shift)+1..9, +H, +V, +Arrow Shift picks the Alt+Shift+… row
306
+ * Alt+T / N / W / F Shift ignored
307
+ * any other Alt+letter Alt+Letter
308
+ *
309
+ * A disabled chord therefore takes every event read as it: `{Backspace:
310
+ * false}` leaves Shift+Backspace and Alt+Backspace to the page as well.
311
+ */
312
+ function _chordOfEvent(e) {
313
+ const key = e.key;
314
+ if (!key || key === 'Control' || key === 'Shift' || key === 'Alt' || key === 'Meta') return null;
315
+ const { ctrlKey: ctrl, metaKey: meta, altKey: alt, shiftKey: shift } = e;
316
+ const lower = key.toLowerCase();
317
+ const cm = ctrl ? 'Ctrl' : 'Meta';
318
+ if ((ctrl || meta) && lower === 'k' && !shift) return `${cm}+K`;
319
+ if ((ctrl || meta) && !alt && key === 'Tab') return `${cm}+${shift ? 'Shift+' : ''}Tab`;
320
+ if (key === 'Escape') return 'Escape';
321
+ if (key === '?') return '?';
322
+ if (key === 'Backspace') return ctrl || meta ? null : 'Backspace';
323
+ if (!alt) {
324
+ const only = (m) => ctrl === (m === 'Ctrl') && shift === (m === 'Shift') && !meta;
325
+ if (/^F([1-9]|1[0-2])$/.test(key)) return !ctrl && !meta && !shift ? key : null;
326
+ if (key === 'ArrowLeft' || key === 'ArrowRight') {
327
+ if (only('Shift')) return `Shift+${key}`;
328
+ if (only('Ctrl')) return `Ctrl+${key}`;
131
329
  }
330
+ return null;
331
+ }
332
+ const sh = shift ? 'Shift+' : '';
333
+ if (/^[1-9]$/.test(key)) return `Alt+${sh}${key}`;
334
+ if (lower === 'h' || lower === 'v') return `Alt+${sh}${lower.toUpperCase()}`;
335
+ if (lower === 't' || lower === 'n' || lower === 'w' || lower === 'f') return `Alt+${lower.toUpperCase()}`;
336
+ if (ARROWS.includes(key)) return `Alt+${sh}${key}`;
337
+ if (/^[a-z]$/.test(lower)) return 'Alt+Letter';
338
+ return null;
339
+ }
340
+
341
+ /** An embedder's chord id in canonical spelling, or null if the keymap does
342
+ * not bind it. Modifiers in any order and case; `Esc` and `Left` are read. */
343
+ function _canonicalChord(id) {
344
+ if (typeof id !== 'string' || !id) return null;
345
+ const parts = id.split('+').map((p) => p.trim());
346
+ const keyRaw = parts.pop();
347
+ if (!keyRaw) return null;
348
+ const mods = new Set();
349
+ for (const p of parts) {
350
+ const m = MODIFIER_NAMES[p.toLowerCase()];
351
+ if (!m) return null;
352
+ mods.add(m);
353
+ }
354
+ const key = KEY_ALIASES[keyRaw.toLowerCase()] ?? keyRaw;
355
+ return BY_LOWER.get(_compose(mods, key).toLowerCase()) ?? null;
356
+ }
132
357
 
133
- switch (key) {
134
- // Plain Alt+H/V split into an empty pane; with Shift they open
135
- // the focused tile's own content in the new split.
136
- case 'h':
137
- e.preventDefault();
138
- e.shiftKey ? wm.splitFocusedWith('h') : wm.split('h');
139
- return;
140
- case 'v':
141
- e.preventDefault();
142
- e.shiftKey ? wm.splitFocusedWith('v') : wm.split('v');
143
- return;
144
- case 't': e.preventDefault(); wm.openFocusedInTab(); return;
145
- case 'n': e.preventDefault(); wm.openFocusedInWindow(); return;
146
- case 'w': e.preventDefault(); wm.closeFocused(); return;
147
- case 'f': e.preventDefault(); wm.toggleManagedFocused(); return;
148
- case 'arrowleft':
149
- e.preventDefault();
150
- e.shiftKey ? wm.moveFocused('left') : wm.focusDir('left');
151
- return;
152
- case 'arrowright':
153
- e.preventDefault();
154
- e.shiftKey ? wm.moveFocused('right') : wm.focusDir('right');
155
- return;
156
- case 'arrowup':
157
- e.preventDefault();
158
- e.shiftKey ? wm.moveFocused('up') : wm.focusDir('up');
159
- return;
160
- case 'arrowdown':
161
- e.preventDefault();
162
- e.shiftKey ? wm.moveFocused('down') : wm.focusDir('down');
163
- return;
358
+ /**
359
+ * The defaults with an embedder's bindings applied: a `Map` of chord id →
360
+ * `{ run, label, inField, isDefault }`, or `{ disabled: true }` for a chord the
361
+ * embedder took away — and what was wrong with `bindings`, for the caller to
362
+ * report.
363
+ */
364
+ function _resolve(bindings) {
365
+ const table = new Map();
366
+ for (const [id, { action, inField }] of DEFAULTS) {
367
+ const a = _action(action);
368
+ table.set(id, { run: a.run, label: a.label, inField, isDefault: true });
369
+ }
370
+ const problems = [];
371
+ if (bindings == null) return { table, problems };
372
+ if (typeof bindings !== 'object') {
373
+ problems.push('`bindings` must be an object of { chord: action | false | function } — ignored');
374
+ return { table, problems };
375
+ }
376
+ for (const [rawId, value] of Object.entries(bindings)) {
377
+ const id = _canonicalChord(rawId);
378
+ if (!id) { problems.push(`unknown chord "${rawId}" in bindings — ignored`); continue; }
379
+ const { action: defaultAction, inField } = DEFAULTS.get(id);
380
+ if (value === false) {
381
+ table.set(id, { disabled: true });
382
+ } else if (typeof value === 'function') {
383
+ table.set(id, {
384
+ run: (ctx, e) => value(e, { wm: ctx.wm, palette: ctx.palette }),
385
+ label: 'Defined by the application', inField, isDefault: false,
386
+ });
387
+ } else {
388
+ const a = _action(value);
389
+ if (!a) { problems.push(`unknown action ${JSON.stringify(value)} for chord "${rawId}" — ignored`); continue; }
390
+ table.set(id, { run: a.run, label: a.label, inField, isDefault: value === defaultAction });
164
391
  }
392
+ }
393
+ return { table, problems };
394
+ }
165
395
 
166
- // Suppress accidental browser menu-access on bare Alt+letter
167
- // chords we don't bind (so the user doesn't get a Firefox/Chromium
168
- // menu popping open mid-flow).
169
- if (key.length === 1 && /[a-z]/.test(key) && !inField) {
170
- e.preventDefault();
396
+ /**
397
+ * The shortcuts table after `bindings`: a `KEYMAP_SHORTCUTS` row where nothing
398
+ * it stands for was changed and, for a row that was, one line per chord still
399
+ * bound, naming the action it now runs. A disabled chord is not listed. Pure:
400
+ * it reports nothing (`installKeymap` does).
401
+ *
402
+ * @param {object} [bindings] as for `installKeymap`
403
+ * @returns {ReadonlyArray<{keys: string, action: string}>}
404
+ */
405
+ export function keymapShortcuts(bindings = null) {
406
+ const { table } = _resolve(bindings);
407
+ const rows = [];
408
+ SHORTCUT_CHORDS.forEach((chords, i) => {
409
+ if (chords.every((id) => table.get(id).isDefault)) { rows.push(KEYMAP_SHORTCUTS[i]); return; }
410
+ for (const id of chords) {
411
+ const b = table.get(id);
412
+ if (b.disabled || !b.label) continue;
413
+ rows.push(Object.freeze({ keys: id, action: b.label }));
171
414
  }
415
+ });
416
+ return Object.freeze(rows);
417
+ }
418
+
419
+ /**
420
+ * Install the window manager's keyboard shortcuts on `document`.
421
+ *
422
+ * @param {object} cfg
423
+ * @param {object} cfg.wm
424
+ * @param {object} cfg.palette
425
+ * @param {string} [cfg.navSelector] what F1..F12 click (see `nav:N`)
426
+ * @param {object} [cfg.bindings] `{ [chordId]: action | false | fn }` —
427
+ * see the head of this file
428
+ * @param {object} [cfg.logger] told of each problem with `bindings` once,
429
+ * as a warning; `console` when omitted
430
+ * @returns {() => void} the disposer
431
+ */
432
+ export function installKeymap({ wm, palette, navSelector = null, bindings = null, logger = null } = {}) {
433
+ const { table, problems } = _resolve(bindings);
434
+ const log = logger ?? console;
435
+ for (const p of problems) log.warn?.(`[keymap] ${p}`);
436
+ const ctx = { wm, palette, navSelector };
437
+
438
+ const onKeyDown = (e) => {
439
+ // A component that handled the key keeps it.
440
+ if (e.defaultPrevented) return;
441
+ const chord = _chordOfEvent(e);
442
+ if (!chord) return;
443
+ const b = table.get(chord);
444
+ if (!b || b.disabled) return;
445
+ if (!b.inField && e.target?.closest?.('input, textarea, select, [contenteditable="true"]')) return;
446
+ if (b.run(ctx, e) !== false) e.preventDefault();
172
447
  };
173
448
 
174
449
  document.addEventListener('keydown', onKeyDown);