bmweb-cli 0.1.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 (72) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +351 -0
  3. package/dist/bmweb.js +2790 -0
  4. package/package.json +53 -0
  5. package/runtime/core/bestvm/codec.js +285 -0
  6. package/runtime/core/bestvm/environment.js +116 -0
  7. package/runtime/core/bestvm/executor.js +1483 -0
  8. package/runtime/core/bestvm/index.js +52 -0
  9. package/runtime/core/bestvm/machine.js +491 -0
  10. package/runtime/core/bestvm/operands.js +356 -0
  11. package/runtime/core/bestvm/registers.js +152 -0
  12. package/runtime/core/bestvm/write-guard.js +111 -0
  13. package/runtime/core/ipofile/compile.js +364 -0
  14. package/runtime/core/ipofile/decls.js +187 -0
  15. package/runtime/core/ipofile/emit.js +708 -0
  16. package/runtime/core/ipofile/exec.js +164 -0
  17. package/runtime/core/ipofile/lex.js +243 -0
  18. package/runtime/core/ipofile/parse.js +550 -0
  19. package/runtime/core/ipofile/pool.js +404 -0
  20. package/runtime/core/ipofile/walk.js +431 -0
  21. package/runtime/core/ipovm/builtin-helpers.js +182 -0
  22. package/runtime/core/ipovm/builtins-api.js +610 -0
  23. package/runtime/core/ipovm/builtins-screen.js +493 -0
  24. package/runtime/core/ipovm/builtins-table.js +166 -0
  25. package/runtime/core/ipovm/builtins-text.js +166 -0
  26. package/runtime/core/ipovm/emissions.js +138 -0
  27. package/runtime/core/ipovm/hosts.js +191 -0
  28. package/runtime/core/ipovm/operators.js +229 -0
  29. package/runtime/core/ipovm/structures.js +250 -0
  30. package/runtime/core/ipovm/suspensions.js +241 -0
  31. package/runtime/core/ipovm/tape.js +206 -0
  32. package/runtime/core/ipovm/values.js +241 -0
  33. package/runtime/core/ipovm/vm.js +1166 -0
  34. package/runtime/core/translate.js +526 -0
  35. package/runtime/core/webshim/api-router.js +592 -0
  36. package/runtime/core/webshim/bus.js +95 -0
  37. package/runtime/core/webshim/coding.js +82 -0
  38. package/runtime/core/webshim/data-fetch.js +66 -0
  39. package/runtime/core/webshim/exchange.js +288 -0
  40. package/runtime/core/webshim/framing.js +331 -0
  41. package/runtime/core/webshim/install.js +30 -0
  42. package/runtime/core/webshim/job-runner.js +319 -0
  43. package/runtime/core/webshim/native-bus.js +108 -0
  44. package/runtime/core/webshim/timers.js +82 -0
  45. package/runtime/core/webshim/trace.js +205 -0
  46. package/runtime/core/webshim/transport-base.js +128 -0
  47. package/runtime/core/webshim/variant-resolver.js +249 -0
  48. package/runtime/core/webshim/web-serial-bus.js +734 -0
  49. package/runtime/home/bmweb-home.ips +76 -0
  50. package/runtime/home/bmweb.h +26 -0
  51. package/runtime/screens/activations.js +258 -0
  52. package/runtime/screens/garage/diff.js +331 -0
  53. package/runtime/screens/garage/share.js +276 -0
  54. package/runtime/screens/garage/store.js +547 -0
  55. package/runtime/screens/ipo-runtime/cells.js +176 -0
  56. package/runtime/screens/ipo-runtime/dialogs.js +254 -0
  57. package/runtime/screens/ipo-runtime/home.js +358 -0
  58. package/runtime/screens/ipo-runtime/open.js +393 -0
  59. package/runtime/screens/ipo-runtime/paint-grid.js +106 -0
  60. package/runtime/screens/ipo-runtime/paint-modern.js +424 -0
  61. package/runtime/screens/ipo-runtime/print.js +281 -0
  62. package/runtime/screens/ipo-runtime/program.js +1337 -0
  63. package/runtime/screens/ipo-runtime/protocol.js +464 -0
  64. package/runtime/screens/ipo-runtime/script-scan.js +225 -0
  65. package/runtime/screens/ipo-runtime/translate-sets.js +130 -0
  66. package/runtime/screens/ipo-runtime/ui.js +249 -0
  67. package/runtime/screens/ipo-runtime/wire-policy.js +113 -0
  68. package/runtime/screens/ir.js +324 -0
  69. package/runtime/screens/search/data.js +153 -0
  70. package/runtime/screens/search/match.js +285 -0
  71. package/runtime/screens/search/open.js +66 -0
  72. package/runtime/vendor/fflate.min.js +1 -0
@@ -0,0 +1,393 @@
1
+ /**
2
+ * @file Entry point: open the live program for a module, and the one-driver
3
+ * rule that pauses it while a remote helper has the cable. This is the last
4
+ * piece of screens/ipo-runtime/ in load order and exports the runtime's
5
+ * public API.
6
+ */
7
+
8
+ /** @type {IpoProgram|null} the program whose view is showing */
9
+ let _ipoCurrent = null;
10
+
11
+ /**
12
+ * The current program left its view (its UI adapter reports it).
13
+ * @returns {void}
14
+ */
15
+ function ipoProgramLeft() {
16
+ _ipoCurrent = null;
17
+ }
18
+
19
+ /**
20
+ * ONE DRIVER AT A TIME. While the owner has admitted a helper, the helper's
21
+ * runtime is what runs on the cable. The owner's own module screens would
22
+ * cycle their jobs on the same K-line every tick and the helper's requests
23
+ * would queue behind them, seconds at a time; so an owner opening a module
24
+ * during a live share sees a notice instead, and a share being admitted
25
+ * closes whatever the owner had running.
26
+ * @returns {boolean}
27
+ */
28
+ function ipoRemoteDriving() {
29
+ return (
30
+ typeof Remote !== 'undefined' &&
31
+ Remote &&
32
+ Remote.role === 'owner' &&
33
+ !!Remote.accepted
34
+ );
35
+ }
36
+
37
+ /**
38
+ * Close the owner's running program because a helper was admitted.
39
+ * @returns {void}
40
+ */
41
+ function ipoPauseForRemote() {
42
+ if (_ipoCurrent) {
43
+ _ipoCurrent.close();
44
+ _ipoCurrent = null;
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Show a notice in the module container with only a Back key.
50
+ * @param {HTMLElement} container - the view
51
+ * @param {string} html - the notice
52
+ * @param {string} status - the status-bar text
53
+ * @param {() => void} back - leave the module view
54
+ * @returns {void}
55
+ */
56
+ function ipoNotice(container, html, status, back) {
57
+ container.className = 'results-panel';
58
+ container.innerHTML = html;
59
+ sbLeft.textContent = status;
60
+ setActions([ipoBackAction(() => back())]);
61
+ }
62
+
63
+ /**
64
+ * The "module did not identify itself" screen: the script's own last
65
+ * message when it stopped itself, else the generic explanation, plus WHY
66
+ * inpainit had nothing better than the SGBD filename to check -- the group
67
+ * probe's own verdict (bus-silent, probe-error, ...) is the actionable half
68
+ * of this screen, so say it instead of leaving a self-contradictory "'SM46'
69
+ * not found, found 'SM46'".
70
+ * @param {object} ecu - the module
71
+ * @param {IpoProgram} program - the program that failed to start
72
+ * @returns {string} HTML
73
+ */
74
+ function ipoStoppedHtml(ecu, program) {
75
+ const m = (program.messages || []).slice(-1)[0];
76
+ const rd =
77
+ typeof webResolveVariantLast === 'function'
78
+ ? webResolveVariantLast()
79
+ : null;
80
+ const g = String(ecu.group || '').toLowerCase();
81
+ const why =
82
+ rd && g && rd.group === g && rd.path !== 'resolved'
83
+ ? `<div style="margin-top:14px;font-size:12px;color:var(--ink-faint)">` +
84
+ `Variant probe ${esc(g)}: <b>${esc(rd.path)}</b>` +
85
+ (rd.empty != null || rd.real != null
86
+ ? ` (${Number(rd.real || 0)} answered, ${Number(rd.empty || 0)} silent)`
87
+ : '') +
88
+ (rd.error ? ` — ${esc(String(rd.error))}` : '') +
89
+ `. The car did not name this module, so the script checked the ` +
90
+ `SGBD filename instead. Ignition on, reopen the module.</div>`
91
+ : '';
92
+ return (
93
+ `<div class="empty"><div class="empty-big" style="color:var(--amber)">` +
94
+ `${esc(m ? ipoText(m.title) : `${ecu.label} is not answering`)}</div>` +
95
+ `<div>${esc(
96
+ m
97
+ ? ipoText(m.body || '')
98
+ : 'The cable is connected, but this module did not identify itself. It may not be fitted to this car, or the ignition may need to be on.'
99
+ )}</div>${why}</div>`
100
+ );
101
+ }
102
+
103
+ /**
104
+ * Which shipped SGBDs a script may address explicitly, cached on the module.
105
+ * @param {object} ecu - the module
106
+ * @returns {Promise<void>}
107
+ */
108
+ async function ipoLoadKnownSgbds(ecu) {
109
+ if (ecu._ipoKnownSgbds) return;
110
+ try {
111
+ const idx = await fetch('api/ecu-index.json').then((r) =>
112
+ r.ok ? r.json() : null
113
+ );
114
+ ecu._ipoKnownSgbds = new Set(
115
+ Object.keys(idx || {}).map((k) => k.toLowerCase())
116
+ );
117
+ } catch (e) {
118
+ ecu._ipoKnownSgbds = new Set();
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Open the live program for a module. Returns true when it took the view
124
+ * (even if the script stopped itself: the reason is shown), false when this
125
+ * module cannot run live (no exec) so the caller falls back.
126
+ * @param {object} ecu - the module
127
+ * @param {HTMLElement} container - where the view is drawn
128
+ * @param {() => void} back - leave the module view
129
+ * @param {string|null} [openMenu] - a menu to open (a deep link)
130
+ * @param {string|null} [openScreen] - the screen to show on that menu
131
+ * @param {RegExp|string|null} [pressKey] - a read key to press on arrival, by its caption
132
+ * @returns {Promise<boolean>}
133
+ */
134
+ /** The shipped group -> identifiable variants map, fetched once. */
135
+ let _ipoVariantsByGroupP = null;
136
+ function ipoVariantsByGroup() {
137
+ return (_ipoVariantsByGroupP ??= fetch('data/groups/variants-by-group.json')
138
+ .then((r) => (r.ok ? r.json() : null))
139
+ .catch(() => null));
140
+ }
141
+
142
+ /**
143
+ * The variant names a script's entry accepts: the string constants of its
144
+ * inpainit that name a shipped SGBD ("B_SM46_3", "EASY_E_B").
145
+ * @param {IpoExec} exec - the script
146
+ * @param {Set<string>} known - every shipped SGBD, lowercased
147
+ * @returns {string[]} lowercased
148
+ */
149
+ function ipoScriptVariants(exec, known) {
150
+ const toks =
151
+ (exec.procs && (exec.procs.inpainit || exec.procs.SgbdInpaCheck)) || [];
152
+ const out = [];
153
+ for (const t of toks) {
154
+ if (t.op !== 'const' || typeof t.v !== 'string') continue;
155
+ const v = t.v.trim().toLowerCase();
156
+ if (v && known.has(v) && !out.includes(v)) out.push(v);
157
+ }
158
+ return out;
159
+ }
160
+
161
+ /**
162
+ * The module a scriptchange target addresses. The new script's inpainit
163
+ * names the variants it accepts; the group whose IDENTIFIKATION can name
164
+ * one of them is asked live, and the car's answer is the SGBD the program
165
+ * talks to from then on. With no group to ask, the first shipped variant
166
+ * the script names is taken.
167
+ * @param {EcuRecord} ecu - the module the view opened
168
+ * @param {string} script - the script the key named, lowercased
169
+ * @param {IpoExec} exec - that script
170
+ * @returns {Promise<EcuRecord|null>} the target, null when nothing answered
171
+ */
172
+ async function ipoResolveScriptEcu(ecu, script, exec) {
173
+ const known = ecu._ipoKnownSgbds || new Set();
174
+ const wants = ipoScriptVariants(exec, known);
175
+ const byGroup = (await ipoVariantsByGroup()) || {};
176
+ let group = null;
177
+ let best = 0;
178
+ for (const [g, list] of Object.entries(byGroup)) {
179
+ const hits = (list || []).filter((v) =>
180
+ wants.includes(String(v).toLowerCase())
181
+ ).length;
182
+ if (hits > best) {
183
+ best = hits;
184
+ group = g;
185
+ }
186
+ }
187
+ let sgbd = null;
188
+ if (group && typeof webResolveVariant === 'function') {
189
+ try {
190
+ sgbd = await webResolveVariant(group);
191
+ } catch (e) {
192
+ sgbd = null;
193
+ }
194
+ if (!sgbd) return null; // the group asked and nothing answered
195
+ } else if (wants.length) {
196
+ sgbd = wants[0];
197
+ } else if (known.has(script)) {
198
+ sgbd = script;
199
+ }
200
+ if (!sgbd) return null;
201
+ return {
202
+ ...ecu,
203
+ code: script,
204
+ sgbd: String(sgbd).toLowerCase(),
205
+ group: group ? group.toUpperCase() : ecu.group,
206
+ _variant: String(sgbd).toUpperCase(),
207
+ _irFrom: script,
208
+ _sgbdBase: undefined,
209
+ _scriptChangeOf: ecu.sgbd,
210
+ };
211
+ }
212
+
213
+ async function ipoProgramOpen(
214
+ ecu,
215
+ container,
216
+ back,
217
+ openMenu,
218
+ openScreen,
219
+ pressKey
220
+ ) {
221
+ if (typeof IpoVm === 'undefined' || typeof FeedHost === 'undefined')
222
+ return false;
223
+ if (ipoRemoteDriving()) {
224
+ ipoNotice(
225
+ container,
226
+ `<div class="empty"><div class="empty-big" style="color:var(--amber)">A helper is driving your car</div>` +
227
+ `<div>Your own module screens stay off while the remote session is live, so the helper's reads are not queued behind them. End the session to use this module yourself.</div></div>`,
228
+ `${ecu.sgbd}.prg · remote session live`,
229
+ back
230
+ );
231
+ return true;
232
+ }
233
+ if (typeof irLiveExec !== 'function' || typeof irExecSgbd !== 'function')
234
+ return false;
235
+ const exec = await irLiveExec(irExecSgbd(ecu));
236
+ if (!exec || !exec.procs || !Object.keys(exec.procs).length) return false;
237
+ if (!(exec.procs.inpainit || exec.procs.SgbdInpaCheck)) return false;
238
+ if (_ipoCurrent) _ipoCurrent.close();
239
+ if (ecu._ir && typeof irUseTranslations === 'function')
240
+ irUseTranslations(ecu._ir);
241
+ await ipoLoadKnownSgbds(ecu);
242
+ // the fault dictionaries the fed results are translated through
243
+ // (faultdb.js: large, injected on demand, absent from a build that opted
244
+ // out of the fault tables -- then the ECU's German is what shows)
245
+ if (ipoTranslating() && typeof loadFaultDb === 'function') {
246
+ try {
247
+ await loadFaultDb();
248
+ } catch (e) {
249
+ /* results stay as sent */
250
+ }
251
+ }
252
+ const ui = ipoMakeUi(ecu, container, back);
253
+ const program = new IpoProgram(ecu, exec, ui);
254
+ _ipoCurrent = program;
255
+ sbLeft.textContent = `${ecu.sgbd}.prg · starting`;
256
+ const r = await program.start();
257
+ // No adapter at all: the entry jobs could not reach the car. Some scripts
258
+ // tolerate a failed INITIALISIERUNG and still open their root menu, which
259
+ // would read as an offline view of a module nothing has talked to -- so
260
+ // the gate fires whether or not the script carried on.
261
+ if (program.noCable) {
262
+ program.close();
263
+ _ipoCurrent = null;
264
+ ipoNotice(
265
+ container,
266
+ errorBlock('no cable connected'),
267
+ `${ecu.sgbd}.prg · no cable`,
268
+ back
269
+ );
270
+ return true;
271
+ }
272
+ if (!r.ok) {
273
+ _ipoCurrent = null;
274
+ if (program.silent || r.reason === 'stopped') {
275
+ ipoNotice(
276
+ container,
277
+ ipoStoppedHtml(ecu, program),
278
+ `${ecu.sgbd}.prg · ${program.silent ? 'no response' : 'stopped'}`,
279
+ back
280
+ );
281
+ return true;
282
+ }
283
+ if (r.reason === 'cancelled') {
284
+ back();
285
+ return true;
286
+ }
287
+ // the script itself failed before its root menu opened: an app error,
288
+ // not the car -- say which, there is no other renderer to fall back to
289
+ ipoNotice(
290
+ container,
291
+ errorBlock(
292
+ `vm error: INPA's script for ${ecu.sgbd} did not start (${r.reason})`
293
+ ),
294
+ `${ecu.sgbd}.prg · failed`,
295
+ back
296
+ );
297
+ return true;
298
+ }
299
+ // A DEEP LINK LANDS; IT DOES NOT PRESS. openMenu runs a menu's prologue and
300
+ // shows a screen -- both of which read -- but it never runs an ITEM body, so
301
+ // a link into an activation menu cannot drive an actuator on arrival. That
302
+ // is the whole reason a search result carries the menu and screen NAMES
303
+ // rather than the key to press.
304
+ const wantScreen = openScreen && exec.procs[openScreen] ? openScreen : null;
305
+ // a link that names the screen but not its menu (a search result for the
306
+ // screen itself) lands on the menu that shows that screen, so the F-keys
307
+ // are the screen's own and not the entry menu's
308
+ let landMenu = openMenu && exec.procs[openMenu] ? openMenu : null;
309
+ if (wantScreen) {
310
+ // A key result names the menu the key SITS on and the screen it opens;
311
+ // that screen belongs to the menu the key switches to (its item body
312
+ // is setscreen then setmenu), so landing on the named menu with that
313
+ // screen would put the parent's jobs on the F-keys. Land on the owner
314
+ // unless the named menu shows this screen itself.
315
+ const shownHere = landMenu ? ipoScreenForMenu(exec, landMenu) : null;
316
+ if (!(shownHere && shownHere.screen === wantScreen)) {
317
+ const owner = ipoMenuForScreen(exec, wantScreen);
318
+ if (owner) landMenu = owner;
319
+ }
320
+ }
321
+ if (landMenu && landMenu !== program.menu) {
322
+ await program.openMenu(landMenu, wantScreen ? { screen: wantScreen } : {});
323
+ }
324
+ // A MENU PROLOGUE'S OWN setscreen OUTRANKS opts.screen, by design: the
325
+ // script's choice is what a normal keypress must land on. A link naming a
326
+ // screen is the one case where the user's choice is more specific than the
327
+ // script's default backdrop, so it is applied afterwards rather than by
328
+ // weakening that rule for every other caller.
329
+ if (wantScreen && wantScreen !== program.screen) {
330
+ await program.showScreen(wantScreen, false);
331
+ }
332
+ // A deep link lands; it does not press. The one exception is a caller
333
+ // asking for a named READ key by its caption (the Garage's Fault scan /
334
+ // Identification buttons): that key is pressed for the user exactly as
335
+ // their own press would be, so a key that writes still goes through the
336
+ // runtime's confirmation.
337
+ if (pressKey) {
338
+ const re =
339
+ pressKey instanceof RegExp
340
+ ? pressKey
341
+ : new RegExp(
342
+ `^${String(pressKey).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`,
343
+ 'i'
344
+ );
345
+ const it = (program.items || []).find((x) =>
346
+ re.test(String(x.label || '').trim())
347
+ );
348
+ if (it) program.press(it.nr).catch(() => {});
349
+ }
350
+ return true;
351
+ }
352
+
353
+ if (typeof window !== 'undefined') {
354
+ window.ipoProgramOpen = ipoProgramOpen;
355
+ window.ipoResolveScriptEcu = ipoResolveScriptEcu;
356
+ window.ipoPauseForRemote = ipoPauseForRemote;
357
+ // Cmd/Ctrl+P on an open module view prints its sheet (core/print.js asks)
358
+ window.ipoPrintAvailable = () => !!(_ipoCurrent && !_ipoCurrent.closed);
359
+ window.ipoPrintCurrent = () =>
360
+ _ipoCurrent
361
+ ? ipoPrintScreen(
362
+ _ipoCurrent,
363
+ _ipoCurrent.ecu,
364
+ typeof inpaMode === 'function' && inpaMode()
365
+ )
366
+ : null;
367
+ window.IpoProgram = IpoProgram;
368
+ window.ipoMenuItems = ipoMenuItems;
369
+ window.ipoWireTarget = ipoWireTarget;
370
+ window.ipoScreenForMenu = ipoScreenForMenu;
371
+ window.ipoNeedsConfirm = ipoNeedsConfirm;
372
+ window.ipoMakeUi = ipoMakeUi;
373
+ }
374
+ if (typeof module !== 'undefined' && module.exports) {
375
+ module.exports = {
376
+ ipoNeedsConfirm,
377
+ IpoProgram,
378
+ ipoMenuItems,
379
+ ipoWireTarget,
380
+ ipoProgramOpen,
381
+ ipoResolveScriptEcu,
382
+ ipoScriptVariants,
383
+ ipoMakeUi,
384
+ ipoLineRows,
385
+ ipoMenuTiles,
386
+ ipoScreenComponents,
387
+ ipoLampHtml,
388
+ ipoGaugeHtml,
389
+ ipoScreenLineNames,
390
+ ipoProgramLeft,
391
+ ipoPauseForRemote,
392
+ };
393
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * @file INPA mode: the canvas. One DOM row per screen row, each cell placed
3
+ * at its column (padding with spaces), captions and values marked, lamps and
4
+ * bars drawn inline at the column the script gave them.
5
+ */
6
+
7
+ /** A logical line at least this tall (a fault entry) gets air above it. */
8
+ const IPO_BAND_MIN_ROWS = 3;
9
+
10
+ /** Columns a lamp's dot and spacing take beyond its word. */
11
+ const IPO_LAMP_EXTRA_COLS = 2;
12
+
13
+ /**
14
+ * The cells of the grid by row, translated and width-kept. INPA's virtual
15
+ * screen has as many logical lines as the script prints (a four-fault
16
+ * freeze-frame list runs past 30 rows and INPA scrolls); the page scrolls
17
+ * the same way, so nothing is cut off. A translated cell keeps the width
18
+ * the script gave the German, so the columns INPA laid out stay put
19
+ * ("Motordrehzahl" and its value at column 40 -> "Engine speed" padded to
20
+ * the same width). A longer English pushes only its own row right.
21
+ * @param {IpoProgram} p - the program
22
+ * @returns {Map<number, Array<{col: number, text: string, kind: string, key: string|null, meta: IpoCellMeta|null}>>}
23
+ */
24
+ function ipoGridRows(p) {
25
+ const byRow = new Map();
26
+ for (const c of p.cells.values()) {
27
+ const r = Number(c.row),
28
+ col = Number(c.col);
29
+ if (!(r >= 0) || !(col >= 0)) continue;
30
+ let text = ipoText(c.text);
31
+ if (!text) continue;
32
+ if (text !== c.text && text.length < c.text.length)
33
+ text = text.padEnd(c.text.length);
34
+ if (!byRow.has(r)) byRow.set(r, []);
35
+ byRow.get(r).push({ col, text, kind: c.kind, key: c.key, meta: c.meta });
36
+ }
37
+ return byRow;
38
+ }
39
+
40
+ /**
41
+ * One screen row's HTML: its cells in column order, a cell that starts
42
+ * before the previous one ended still getting one space so overlapping
43
+ * draws never merge into one word.
44
+ * @param {Array<{col: number, text: string, kind: string, key: string|null, meta: IpoCellMeta|null}>} cells - the row's cells
45
+ * @returns {string} HTML
46
+ */
47
+ function ipoGridRowHtml(cells) {
48
+ let out = '';
49
+ let at = 0;
50
+ for (const c of cells) {
51
+ const gap = Math.max(c.col - at, at > 0 ? 1 : 0);
52
+ out += ' '.repeat(gap);
53
+ if (c.kind === 'lamp') {
54
+ out += ipoLampHtml(c);
55
+ at = c.col + c.text.length + IPO_LAMP_EXTRA_COLS;
56
+ continue;
57
+ }
58
+ if (c.kind === 'gauge') {
59
+ out += ipoGaugeHtml(c);
60
+ at = c.col + IPO_GAUGE_COLS + c.text.length;
61
+ continue;
62
+ }
63
+ const cls = c.kind === 'value' ? 'ipo-val' : 'ipo-cap';
64
+ out += `<span class="${cls}"${c.key ? ` data-key="${esc(c.key)}"` : ''}>${esc(c.text)}</span>`;
65
+ at = c.col + c.text.length;
66
+ }
67
+ // a value the script padded to its column width ends in blanks that
68
+ // would only widen the row
69
+ return out.replace(/\s+$/, '');
70
+ }
71
+
72
+ /**
73
+ * Paint the INPA grid into the screen container.
74
+ * @param {HTMLElement} gridEl - the screen container
75
+ * @param {IpoProgram} p - the program
76
+ * @returns {void}
77
+ */
78
+ function ipoPaintGrid(gridEl, p) {
79
+ const byRow = ipoGridRows(p);
80
+ const rows = [...byRow.keys()].sort((a, b) => a - b);
81
+ if (!rows.length) {
82
+ gridEl.innerHTML = `<div class="ipo-empty">${esc(ipoText(p.title || ''))}</div>`;
83
+ return;
84
+ }
85
+ const last = rows[rows.length - 1];
86
+ const html = [];
87
+ for (let r = 0; r <= last; r++) {
88
+ const cells = (byRow.get(r) || []).sort((a, b) => a.col - b.col);
89
+ // a logical line's first row gets a little air above it (the fault
90
+ // list is one LINE per entry); the top of the screen needs none
91
+ const band =
92
+ r > 0 && p.bandTops && (p.bandTops.get(r) || 0) >= IPO_BAND_MIN_ROWS
93
+ ? ' ipo-band'
94
+ : '';
95
+ if (!cells.length) {
96
+ html.push(`<div class="ipo-row${band}">&nbsp;</div>`);
97
+ continue;
98
+ }
99
+ html.push(`<div class="ipo-row${band}">${ipoGridRowHtml(cells)}</div>`);
100
+ }
101
+ gridEl.innerHTML = html.join('');
102
+ }
103
+
104
+ if (typeof module !== 'undefined' && module.exports) {
105
+ module.exports = { ipoPaintGrid };
106
+ }