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
@@ -0,0 +1,634 @@
1
+ /**
2
+ * SettingsView — the settings page, drawn from a settings store's schema.
3
+ *
4
+ * const view = new SettingsView({ store, eventBus }); // the bus the store emits on
5
+ * view.mount(host);
6
+ * view.selectCategory('window');
7
+ * view.setQuery('shadow');
8
+ * view.dispose();
9
+ *
10
+ * // or as tile content:
11
+ * createShell({ content: { settings: settingsViewContent({ store, eventBus }) } });
12
+ *
13
+ * `store` is a `createSettingsStore()` handle, or the app-global binding's own
14
+ * (`getSettingsStore()` in `@flexdesk/core`). The view knows no setting: every
15
+ * category, group, label, description and control comes from the store's
16
+ * `getCategories()` and `getSchema()`.
17
+ *
18
+ * THE LAYOUT. A sidebar with a filter box, the categories in their
19
+ * `CategoryDef.order` and *Reset all*; beside it the chosen category's
20
+ * settings under its title, description and *Reset category*, grouped by
21
+ * `SettingDef.group` in schema order. Each row shows its label, a *Modified*
22
+ * marker while its value differs from its default, its description, its path,
23
+ * its control and a reset button. Text typed in the filter searches every
24
+ * category's labels, descriptions and paths, and the matches are listed
25
+ * under their category and group.
26
+ *
27
+ * THE CONTROLS, one per `SettingDef.type`:
28
+ * boolean a ToggleField
29
+ * select a select over `options`
30
+ * number a number box with `min`, `max` and `step`; a value written is
31
+ * snapped to the step (counted from `min`) and clamped to the range,
32
+ * and a box left empty or unreadable goes back to the value
33
+ * text a text box written `textDebounceMs` after the last keystroke,
34
+ * or at once on Enter or blur; emptied, it writes `null` when the
35
+ * default is `null`, else `''`
36
+ * colorList a ColorListField, written on every committed edit while every
37
+ * row holds a colour
38
+ * A type the view does not know shows the value as text, read only.
39
+ *
40
+ * LIVE, BOTH WAYS. A control writes through `store.set()`. The view listens
41
+ * on `eventBus` for `settings:<path>:changed` for every path in the schema,
42
+ * for `settings:changed` with a null path (the resets) and for
43
+ * `settings:registered` (a slice registered after mount: its categories join
44
+ * the sidebar and its settings their category, without a remount). A value
45
+ * changed elsewhere moves its control and its marker. An edit still being
46
+ * typed (a text before its debounce, digits before 'change') is written
47
+ * before the view redraws or syncs its rows, so it is never dropped.
48
+ *
49
+ * RESETS. A row's reset button resets that setting. *Reset category* and
50
+ * *Reset all* ask `confirm(…)` first (default `openConfirm`) and change
51
+ * nothing unless it resolves true.
52
+ *
53
+ * Every word on screen that comes from the schema, the store or the user is
54
+ * set as text, so a label or a query holding markup is shown as typed.
55
+ *
56
+ * Markup: `.twm-settings` > `.twm-settings__sidebar` (`__search` >
57
+ * `__search-input`, `__nav` > `button.__cat` [`--active`], `__reset-all`)
58
+ * + `.twm-settings__content` (`__header` > `__title`, `__description`,
59
+ * `__reset-category`; `section.__group` > `__group-title` + `.__row`
60
+ * [`--modified`] > `__info` (`__label`, `__marker`, `__description`,
61
+ * `__path`) + `__control` + `__reset`; `__empty`).
62
+ */
63
+
64
+ import { ComponentBase } from '../base/component_base.js';
65
+ import { ToggleField } from './toggle_field.js';
66
+ import { ColorListField } from './color_list_field.js';
67
+ import { selectBox, shownValue } from './choice_controls.js';
68
+ import { openConfirm } from './modal.js';
69
+
70
+ /** Milliseconds between the last keystroke in a text setting and its write. */
71
+ export const SETTINGS_TEXT_DEBOUNCE_MS = 300;
72
+
73
+ function el(tag, className, text) {
74
+ const n = document.createElement(tag);
75
+ if (className) n.className = className;
76
+ if (text !== undefined && text !== null) n.textContent = String(text);
77
+ return n;
78
+ }
79
+
80
+ function icon(name) {
81
+ const n = el('span', 'material-symbols-outlined', name);
82
+ n.setAttribute('aria-hidden', 'true');
83
+ return n;
84
+ }
85
+
86
+ function same(a, b) {
87
+ if (a === b) return true;
88
+ if (a == null || b == null) return a == null && b == null;
89
+ if (typeof a !== typeof b || typeof a !== 'object') return false;
90
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
91
+ const ka = Object.keys(a);
92
+ const kb = Object.keys(b);
93
+ return ka.length === kb.length && ka.every((k) => same(a[k], b[k]));
94
+ }
95
+
96
+ /**
97
+ * A number written by a number row, always both in range and on the step grid
98
+ * (`min + k * step`, or `k * step` with no `min`): snapped to the nearest grid
99
+ * point, and a point outside [min, max] is moved inward to the nearest grid
100
+ * point inside. `min` is itself a grid point, so the range holds none only
101
+ * when `max < min`; the value is then `min`. With no step it is only clamped.
102
+ */
103
+ function fitNumber(n, { min, max, step }) {
104
+ const lo = min != null && Number.isFinite(Number(min)) ? Number(min) : null;
105
+ const hi = max != null && Number.isFinite(Number(max)) ? Number(max) : null;
106
+ const s = Number(step);
107
+ if (!(Number.isFinite(s) && s > 0)) {
108
+ let v = n;
109
+ if (hi != null) v = Math.min(hi, v);
110
+ if (lo != null) v = Math.max(lo, v);
111
+ return v;
112
+ }
113
+ const base = lo ?? 0;
114
+ const places = (x) => (String(x).split('.')[1] || '').length;
115
+ const decimals = Math.max(places(s), places(base));
116
+ const at = (k) => Number((base + k * s).toFixed(decimals));
117
+ const EPS = 1e-9;
118
+ let k = Math.round((n - base) / s);
119
+ if (hi != null && at(k) > hi) k = Math.floor((hi - base) / s + EPS);
120
+ if (lo != null && at(k) < lo) k = Math.ceil((lo - base) / s - EPS);
121
+ return at(k);
122
+ }
123
+
124
+ export class SettingsView extends ComponentBase {
125
+ /**
126
+ * @param {object} o
127
+ * @param {object} o.store a settings store (`createSettingsStore()` / `getSettingsStore()`)
128
+ * @param {object} o.eventBus the bus the store emits on
129
+ * @param {Function} [o.confirm] `({title, message, confirmLabel, danger}) -> Promise<boolean>`
130
+ * @param {string} [o.category] the category shown first (default: the first)
131
+ * @param {number} [o.textDebounceMs]
132
+ * @param {string} [o.title='Settings'] the sidebar heading
133
+ */
134
+ constructor({
135
+ store, eventBus, confirm = openConfirm, category = null,
136
+ textDebounceMs = SETTINGS_TEXT_DEBOUNCE_MS, title = 'Settings', logger,
137
+ } = {}) {
138
+ super({ eventBus, logger });
139
+ if (!store || typeof store.getSchema !== 'function') {
140
+ throw new TypeError('SettingsView: `store` must be a settings store');
141
+ }
142
+ if (!eventBus || typeof eventBus.on !== 'function') {
143
+ throw new TypeError('SettingsView: `eventBus` must be the bus the store emits on');
144
+ }
145
+ this.store = store;
146
+ this._confirm = confirm;
147
+ this._category = category;
148
+ this._query = '';
149
+ this._debounceMs = textDebounceMs;
150
+ this._title = title;
151
+
152
+ this.root = null;
153
+ this._nav = null;
154
+ this._content = null;
155
+ this._search = null;
156
+ /** path -> { sync(), flush(), dispose() } for the rows on screen. */
157
+ this._rows = new Map();
158
+ /** path -> the bus subscription handle, for every schema path. */
159
+ this._pathSubs = new Map();
160
+ /** Bus subscription handles for the whole-store events. */
161
+ this._subs = [];
162
+ this._disposed = false;
163
+ }
164
+
165
+ /** Build the view into `container` (once). */
166
+ mount(container) {
167
+ if (this.root) return this.root;
168
+ const root = el('div', 'twm-settings');
169
+ const side = el('aside', 'twm-settings__sidebar');
170
+ const head = el('div', 'twm-settings__sidebar-title');
171
+ head.append(icon('settings'), el('span', '', this._title));
172
+
173
+ const search = el('label', 'twm-settings__search');
174
+ const input = el('input', 'twm-settings__search-input');
175
+ input.type = 'search';
176
+ input.placeholder = 'Filter settings…';
177
+ input.autocomplete = 'off';
178
+ input.spellcheck = false;
179
+ input.setAttribute('aria-label', 'Filter settings');
180
+ input.addEventListener('input', () => this.setQuery(input.value));
181
+ search.append(icon('search'), input);
182
+ this._search = input;
183
+
184
+ const nav = el('nav', 'twm-settings__nav');
185
+ nav.setAttribute('aria-label', 'Settings categories');
186
+ nav.addEventListener('click', (e) => {
187
+ const btn = e.target.closest?.('[data-category]');
188
+ if (btn && nav.contains(btn)) this.selectCategory(btn.dataset.category);
189
+ });
190
+ this._nav = nav;
191
+
192
+ const resetAll = el('button', 'twm-btn twm-settings__reset-all');
193
+ resetAll.type = 'button';
194
+ resetAll.append(icon('restart_alt'), el('span', '', 'Reset all'));
195
+ resetAll.addEventListener('click', () => { this.resetAll(); });
196
+
197
+ side.append(head, search, nav, resetAll);
198
+
199
+ const content = el('div', 'twm-settings__content');
200
+ this._content = content;
201
+ root.append(side, content);
202
+ this.root = root;
203
+ container.appendChild(root);
204
+ this._mounted = true;
205
+
206
+ for (const path of Object.keys(this.store.getSchema())) this.#watch(path);
207
+ this._subs.push(this.eventBus.on('settings:changed', (p) => {
208
+ if (p?.path == null) this.#syncAll();
209
+ }));
210
+ this._subs.push(this.eventBus.on('settings:registered', (p) => this.#onRegistered(p)));
211
+
212
+ this.#paintNav();
213
+ this.#paintContent();
214
+ return root;
215
+ }
216
+
217
+ /** The category on screen (its id), or null. */
218
+ getCategory() { return this.#activeCategory()?.id ?? null; }
219
+
220
+ /** The filter text. */
221
+ getQuery() { return this._query; }
222
+
223
+ /** Show one category; clears the filter. */
224
+ selectCategory(id) {
225
+ if (!this.root) return;
226
+ if (!this.store.getCategories().some((c) => c.id === id)) return;
227
+ this._category = id;
228
+ this._query = '';
229
+ if (this._search) this._search.value = '';
230
+ this.#paintNav();
231
+ this.#paintContent();
232
+ }
233
+
234
+ /** Search every category; an empty query goes back to the category. */
235
+ setQuery(query) {
236
+ if (!this.root) return;
237
+ const q = String(query ?? '');
238
+ this._query = q;
239
+ if (this._search && this._search.value !== q) this._search.value = q;
240
+ this.#paintNav();
241
+ this.#paintContent();
242
+ }
243
+
244
+ /** Reset one category after `confirm`; resolves true when it was reset. */
245
+ async resetCategory(id = this.getCategory()) {
246
+ const cat = this.store.getCategories().find((c) => c.id === id);
247
+ if (!cat) return false;
248
+ const ok = await this._confirm({
249
+ title: 'Reset category',
250
+ message: `Reset every setting in “${cat.label ?? cat.id}” to its default?`,
251
+ confirmLabel: 'Reset',
252
+ danger: true,
253
+ });
254
+ if (!ok || this._disposed) return false;
255
+ this.#flushAll();
256
+ this.store.resetCategory(id);
257
+ this.#syncAll();
258
+ return true;
259
+ }
260
+
261
+ /** Reset every setting after `confirm`; resolves true when they were reset. */
262
+ async resetAll() {
263
+ const ok = await this._confirm({
264
+ title: 'Reset all settings',
265
+ message: 'Reset every setting to its default?',
266
+ confirmLabel: 'Reset all',
267
+ danger: true,
268
+ });
269
+ if (!ok || this._disposed) return false;
270
+ this.#flushAll();
271
+ this.store.resetAll();
272
+ this.#syncAll();
273
+ return true;
274
+ }
275
+
276
+ dispose() {
277
+ if (this._disposed) return;
278
+ this._disposed = true;
279
+ this.#flushAll();
280
+ this.#dropRows();
281
+ for (const sub of this._pathSubs.values()) sub.dispose();
282
+ this._pathSubs.clear();
283
+ for (const sub of this._subs) sub.dispose();
284
+ this._subs = [];
285
+ this.root?.remove();
286
+ this.root = null;
287
+ this._nav = null;
288
+ this._content = null;
289
+ this._search = null;
290
+ super.dispose();
291
+ }
292
+
293
+ // ── store events ─────────────────────────────────────────────────────
294
+
295
+ #watch(path) {
296
+ if (this._pathSubs.has(path)) return;
297
+ this._pathSubs.set(path, this.eventBus.on(`settings:${path}:changed`, () => {
298
+ this._rows.get(path)?.sync();
299
+ }));
300
+ }
301
+
302
+ #onRegistered({ paths = [] } = {}) {
303
+ if (!this.root) return;
304
+ const schema = this.store.getSchema();
305
+ for (const path of Object.keys(schema)) this.#watch(path);
306
+ this.#paintNav();
307
+ const active = this.getCategory();
308
+ const touches = this.#searching()
309
+ || !this._rows.size
310
+ || paths.some((p) => schema[p]?.category === active);
311
+ if (touches) {
312
+ this.#paintContent();
313
+ return;
314
+ }
315
+ // The slice's defaults may have moved values on screen; an edit still
316
+ // being typed is written first, so the sync shows it rather than drop it.
317
+ this.#flushAll();
318
+ this.#syncAll();
319
+ }
320
+
321
+ /** True while a filter is in effect: the trimmed query is not empty. */
322
+ #searching() {
323
+ return this._query.trim() !== '';
324
+ }
325
+
326
+ #syncAll() {
327
+ for (const row of this._rows.values()) row.sync();
328
+ }
329
+
330
+ #flushAll() {
331
+ for (const row of this._rows.values()) row.flush();
332
+ }
333
+
334
+ #dropRows() {
335
+ for (const row of this._rows.values()) row.dispose();
336
+ this._rows.clear();
337
+ }
338
+
339
+ // ── painting ─────────────────────────────────────────────────────────
340
+
341
+ #activeCategory() {
342
+ const cats = this.store.getCategories();
343
+ return cats.find((c) => c.id === this._category) ?? cats[0] ?? null;
344
+ }
345
+
346
+ #paintNav() {
347
+ const active = this.#searching() ? null : this.getCategory();
348
+ const buttons = this.store.getCategories().map((cat) => {
349
+ const b = el('button', 'twm-settings__cat');
350
+ b.type = 'button';
351
+ b.dataset.category = cat.id;
352
+ if (cat.id === active) {
353
+ b.classList.add('twm-settings__cat--active');
354
+ b.setAttribute('aria-current', 'page');
355
+ }
356
+ b.append(icon(cat.icon || 'tune'), el('span', 'twm-settings__cat-label', cat.label ?? cat.id));
357
+ if (cat.description) b.title = String(cat.description);
358
+ return b;
359
+ });
360
+ this._nav.replaceChildren(...buttons);
361
+ }
362
+
363
+ #paintContent() {
364
+ this.#flushAll();
365
+ this.#dropRows();
366
+ const schema = this.store.getSchema();
367
+ const content = this._content;
368
+ content.replaceChildren();
369
+
370
+ if (this.#searching()) {
371
+ const q = this._query.trim().toLowerCase();
372
+ const cats = this.store.getCategories();
373
+ const labelOf = new Map(cats.map((c) => [c.id, c.label ?? c.id]));
374
+ const rank = new Map(cats.map((c, i) => [c.id, i]));
375
+ const matches = Object.entries(schema)
376
+ .filter(([path, def]) => [def.label, def.description, path]
377
+ .some((t) => t != null && String(t).toLowerCase().includes(q)))
378
+ .map(([path, def]) => ({ path, ...def }));
379
+ matches.sort((a, b) => (rank.get(a.category) ?? Infinity) - (rank.get(b.category) ?? Infinity));
380
+
381
+ const header = el('header', 'twm-settings__header');
382
+ const info = el('div', 'twm-settings__header-info');
383
+ const n = matches.length;
384
+ info.append(el('h2', 'twm-settings__title',
385
+ `${n} result${n === 1 ? '' : 's'} for “${this._query.trim()}”`));
386
+ header.append(info);
387
+ content.append(header);
388
+ if (!n) {
389
+ content.append(el('div', 'twm-settings__empty', 'No setting matches the filter.'));
390
+ return;
391
+ }
392
+ this.#paintGroups(matches, (def) =>
393
+ `${labelOf.get(def.category) ?? def.category ?? ''} › ${def.group || 'General'}`);
394
+ return;
395
+ }
396
+
397
+ const cat = this.#activeCategory();
398
+ if (!cat) {
399
+ content.append(el('div', 'twm-settings__empty', 'No settings.'));
400
+ return;
401
+ }
402
+ // The page drawn is the category: a slice registered later, ordered
403
+ // before it, must not move the sidebar marker or getCategory() off it.
404
+ this._category = cat.id;
405
+ const header = el('header', 'twm-settings__header');
406
+ const info = el('div', 'twm-settings__header-info');
407
+ info.append(el('h2', 'twm-settings__title', cat.label ?? cat.id));
408
+ if (cat.description) info.append(el('p', 'twm-settings__description', cat.description));
409
+ const reset = el('button', 'twm-btn twm-settings__reset-category');
410
+ reset.type = 'button';
411
+ reset.append(icon('restart_alt'), el('span', '', 'Reset category'));
412
+ reset.addEventListener('click', () => { this.resetCategory(cat.id); });
413
+ header.append(info, reset);
414
+ content.append(header);
415
+
416
+ const defs = Object.entries(schema)
417
+ .filter(([, def]) => def.category === cat.id)
418
+ .map(([path, def]) => ({ path, ...def }));
419
+ if (!defs.length) {
420
+ content.append(el('div', 'twm-settings__empty', 'No settings in this category.'));
421
+ return;
422
+ }
423
+ this.#paintGroups(defs, (def) => def.group || 'General');
424
+ }
425
+
426
+ #paintGroups(defs, groupTitle) {
427
+ const groups = new Map();
428
+ for (const def of defs) {
429
+ const key = groupTitle(def);
430
+ if (!groups.has(key)) groups.set(key, []);
431
+ groups.get(key).push(def);
432
+ }
433
+ for (const [title, list] of groups) {
434
+ const section = el('section', 'twm-settings__group');
435
+ section.append(el('h3', 'twm-settings__group-title', title));
436
+ for (const def of list) section.append(this.#row(def));
437
+ this._content.append(section);
438
+ }
439
+ }
440
+
441
+ // ── rows ─────────────────────────────────────────────────────────────
442
+
443
+ #row(def) {
444
+ const { path } = def;
445
+ const store = this.store;
446
+ const row = el('div', 'twm-settings__row');
447
+ row.dataset.path = path;
448
+
449
+ const info = el('div', 'twm-settings__info');
450
+ const labelLine = el('div', 'twm-settings__label-line');
451
+ const label = el('span', 'twm-settings__label', def.label ?? path);
452
+ const marker = el('span', 'twm-settings__marker', 'Modified');
453
+ labelLine.append(label, marker);
454
+ info.append(labelLine);
455
+ if (def.description) info.append(el('div', 'twm-settings__description', def.description));
456
+ info.append(el('code', 'twm-settings__path', path));
457
+
458
+ const slot = el('div', 'twm-settings__control');
459
+ const control = this.#control(def, (value) => store.set(path, value));
460
+ slot.append(control.el);
461
+
462
+ const reset = el('button', 'twm-settings__reset');
463
+ reset.type = 'button';
464
+ reset.title = 'Reset to default';
465
+ reset.setAttribute('aria-label', `Reset ${def.label ?? path} to default`);
466
+ reset.append(icon('restart_alt'));
467
+ reset.addEventListener('click', () => {
468
+ control.cancel?.();
469
+ store.resetOne(path);
470
+ });
471
+
472
+ row.append(info, slot, reset);
473
+
474
+ const sync = () => {
475
+ const value = store.get(path);
476
+ control.show(value);
477
+ const modified = !same(value, store.getDefault(path));
478
+ row.classList.toggle('twm-settings__row--modified', modified);
479
+ marker.hidden = !modified;
480
+ reset.hidden = !modified;
481
+ };
482
+ sync();
483
+ this._rows.set(path, {
484
+ sync,
485
+ flush: () => control.flush?.(),
486
+ dispose: () => control.dispose?.(),
487
+ });
488
+ return row;
489
+ }
490
+
491
+ /** `{ el, show(value), flush?(), cancel?(), dispose?() }` for one setting. */
492
+ #control(def, write) {
493
+ const name = def.label ?? def.path;
494
+ switch (def.type) {
495
+ case 'boolean': {
496
+ const t = new ToggleField({
497
+ checked: !!this.store.get(def.path), ariaLabel: name, compact: true,
498
+ onChange: (on) => write(on),
499
+ });
500
+ return {
501
+ el: t.render(),
502
+ show: (v) => { if (t.get() !== !!v) t.set(!!v); },
503
+ dispose: () => t.dispose(),
504
+ };
505
+ }
506
+ case 'select': {
507
+ const c = selectBox(def.options || [], this.store.get(def.path), {
508
+ label: name, valueAttr: 'value', className: 'twm-settings__select',
509
+ });
510
+ c.box.addEventListener('change', () => {
511
+ // Redrawn as a fresh mount draws the chosen value, so a stray
512
+ // '(not found)' entry goes once an offered option is picked.
513
+ const v = c.valueOf();
514
+ c.setOptions(def.options || [], v);
515
+ write(v);
516
+ });
517
+ return {
518
+ el: c.box,
519
+ // Redrawn with the new value, as a fresh mount draws it: a
520
+ // value the options lack shows as its '(not found)' entry, and
521
+ // a stray entry left from an earlier value goes.
522
+ show: (v) => { if (!same(c.valueOf(), v)) c.setOptions(def.options || [], v); },
523
+ };
524
+ }
525
+ case 'number': {
526
+ const box = el('input', 'twm-settings__number');
527
+ box.type = 'number';
528
+ box.setAttribute('aria-label', name);
529
+ if (def.min != null) box.min = String(def.min);
530
+ if (def.max != null) box.max = String(def.max);
531
+ if (def.step != null) box.step = String(def.step);
532
+ // Digits typed but not yet committed by 'change'.
533
+ let dirty = false;
534
+ const show = (v) => {
535
+ dirty = false;
536
+ const text = v == null ? '' : String(v);
537
+ if (box.value !== text) box.value = text;
538
+ };
539
+ const commit = () => {
540
+ const current = this.store.get(def.path);
541
+ const n = box.value.trim() === '' ? NaN : Number(box.value);
542
+ if (!Number.isFinite(n)) { show(current); return; }
543
+ const v = fitNumber(n, def);
544
+ show(v);
545
+ if (!same(v, current)) write(v);
546
+ };
547
+ box.addEventListener('input', () => { dirty = true; });
548
+ box.addEventListener('change', commit);
549
+ return {
550
+ el: box,
551
+ show,
552
+ flush: () => { if (dirty) commit(); },
553
+ };
554
+ }
555
+ case 'text': {
556
+ const box = el('input', 'twm-settings__text');
557
+ box.type = 'text';
558
+ box.setAttribute('aria-label', name);
559
+ if (def.placeholder != null) box.placeholder = String(def.placeholder);
560
+ let timer = null;
561
+ const valueOf = () => (box.value === '' && def.defaultValue === null ? null : box.value);
562
+ const cancel = () => { if (timer !== null) { clearTimeout(timer); timer = null; } };
563
+ const commit = () => {
564
+ const v = valueOf();
565
+ if (!same(v, this.store.get(def.path))) write(v);
566
+ };
567
+ const flush = () => {
568
+ if (timer === null) return;
569
+ cancel();
570
+ commit();
571
+ };
572
+ box.addEventListener('input', () => {
573
+ cancel();
574
+ timer = setTimeout(() => { timer = null; commit(); }, this._debounceMs);
575
+ });
576
+ box.addEventListener('change', flush);
577
+ box.addEventListener('keydown', (e) => { if (e.key === 'Enter') flush(); });
578
+ return {
579
+ el: box,
580
+ show: (v) => {
581
+ const text = v == null ? '' : String(v);
582
+ if (box.value === text) return;
583
+ cancel();
584
+ box.value = text;
585
+ },
586
+ flush,
587
+ cancel,
588
+ dispose: cancel,
589
+ };
590
+ }
591
+ case 'colorList': {
592
+ const value = this.store.get(def.path);
593
+ const f = new ColorListField({
594
+ value: Array.isArray(value) ? value : [],
595
+ onChange: (colors, { commit }) => { if (commit && colors) write(colors); },
596
+ });
597
+ return {
598
+ el: f.render(),
599
+ show: (v) => {
600
+ const list = Array.isArray(v) ? v : [];
601
+ if (!same(f.getValue(), list)) f.setValue(list);
602
+ },
603
+ dispose: () => f.dispose(),
604
+ };
605
+ }
606
+ default: {
607
+ const out = el('span', 'twm-settings__readonly');
608
+ return {
609
+ el: out,
610
+ show: (v) => { out.textContent = shownValue(v); },
611
+ };
612
+ }
613
+ }
614
+ }
615
+ }
616
+
617
+ /**
618
+ * A content factory for the shell: `(hostEl, props, ctx) -> { title, destroy }`.
619
+ * `props.category` picks the category shown first.
620
+ *
621
+ * @param {object} opts as `new SettingsView(opts)`
622
+ * @returns {Function}
623
+ */
624
+ export function settingsViewContent(opts = {}) {
625
+ return (hostEl, props = {}) => {
626
+ const view = new SettingsView({ ...opts, category: props?.category ?? opts.category ?? null });
627
+ view.mount(hostEl);
628
+ return {
629
+ title: opts.title ?? 'Settings',
630
+ view,
631
+ destroy: () => view.dispose(),
632
+ };
633
+ };
634
+ }