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,424 @@
1
+ /**
2
+ * @file The modern skin: the same cycle's emissions grouped the way the
3
+ * script wrote them -- one row per LINE block -- and a menu's backdrop
4
+ * legend shown as the function-group tiles the app has always used. Nothing
5
+ * here decides what a line means: a row exists because the program painted
6
+ * it this cycle, keyed by the result the script bound.
7
+ */
8
+
9
+ /**
10
+ * INPA's key-legend notation as a menu's backdrop prints it: "< F4 >
11
+ * Fehlerspeicher lesen", "< Shift > + < F6 > EWS". Group 1 = the Shift
12
+ * prefix, 2 = the key number, 3 = the caption.
13
+ * @type {RegExp}
14
+ */
15
+ const IPO_KEY_LEGEND =
16
+ /^\s*(<\s*Shift\s*>\s*\+\s*)?<\s*F\s*(\d+)\s*>\s*(\S.*)$/i;
17
+
18
+ /** How many tiles enter with a stagger before the animation is skipped. */
19
+ const IPO_TILE_STAGGER = 20;
20
+
21
+ /**
22
+ * One row of the modern skin.
23
+ * @typedef {object} IpoRow
24
+ * @property {string} caption - the caption pieces joined
25
+ * @property {string[]} parts - the caption pieces as printed
26
+ * @property {IpoRowCell[]} cells - the value/lamp/gauge cells
27
+ * @property {string} trailer - text printed after the values (a unit)
28
+ * @property {boolean} band - first row of its logical line
29
+ */
30
+
31
+ /**
32
+ * A cell of a modern row.
33
+ * @typedef {object} IpoRowCell
34
+ * @property {'text'|'value'|'lamp'|'gauge'} kind - what drew it
35
+ * @property {string|null} key - the result key
36
+ * @property {string} text - the text
37
+ * @property {string} [on] - a lamp's TrueText
38
+ * @property {string} [off] - a lamp's FalseText
39
+ * @property {IpoCellMeta|null} meta - the instrument's declaration
40
+ */
41
+
42
+ /**
43
+ * A menu shown as tiles.
44
+ * @typedef {object} IpoMenuTiles
45
+ * @property {Array<{nr: number, shift: boolean, label: string, legend: string, cat: string}>} tiles - one per key
46
+ * @property {IpoRow[]} rest - the rows that were not legend lines
47
+ */
48
+
49
+ /**
50
+ * The rows of the last cycle, one per LINE block (a LINE that prints several
51
+ * screen rows is several rows here too: INPA laid them out one under the
52
+ * other). Within a row the text elements before the first value are its
53
+ * caption, the value/lamp/gauge elements are its cells, text after a value
54
+ * is a unit or trailer. A row with only text is a note -- unless it was
55
+ * printed in two or more pieces at different columns, which is a caption
56
+ * and its value (ZKE5's info screen: "Rework program" at column 0, ":" at
57
+ * 33, the name at 35 -- copied into strings at startup and printed with
58
+ * ftextout). The first piece is the caption, a lone ":" is dropped, the
59
+ * rest are the value.
60
+ * @param {IpoProgram} p - the program (its lines and cells)
61
+ * @returns {IpoRow[]}
62
+ */
63
+ function ipoLineRows(p) {
64
+ const rows = [];
65
+ for (const ln of p.lines || []) {
66
+ const els = (ln.elements || [])
67
+ .filter((el) => el.row != null && el.col != null)
68
+ .sort((a, b) => a.row - b.row || a.col - b.col);
69
+ if (!els.length) continue;
70
+ const cellOf = (el) => p.cells.get(`${el.row}:${el.col}`);
71
+ const byRow = new Map();
72
+ for (const el of els) {
73
+ if (!byRow.has(el.row)) byRow.set(el.row, []);
74
+ byRow.get(el.row).push(el);
75
+ }
76
+ let first = true;
77
+ const groups = [...byRow.values()];
78
+ for (let gi = 0; gi < groups.length; gi++) {
79
+ const group = groups[gi];
80
+ // INPA's status idiom: captions on one screen row, the lamps or bars
81
+ // under them on the next, column by column ("Klemme 15" at col 1 over
82
+ // its lamp at col 1, "Motor steht" at col 30 over its own). Read as
83
+ // rows those were a caption row with the second caption taken for a
84
+ // value, then an anonymous row of lamps. Pair each cell with the
85
+ // caption standing over it and emit one row per pair.
86
+ const paired = ipoPairCaptionRow(group, groups[gi + 1], cellOf);
87
+ if (paired) {
88
+ for (const r of paired) {
89
+ rows.push({ ...r, band: first });
90
+ first = false;
91
+ }
92
+ gi += 1; // the cell row is consumed
93
+ continue;
94
+ }
95
+ const caption = [];
96
+ const cells = [];
97
+ const trailer = [];
98
+ for (const el of group) {
99
+ const c = cellOf(el);
100
+ const text = c ? c.text : '';
101
+ if (el.t === 'text') {
102
+ if (!text.trim()) continue;
103
+ (cells.length ? trailer : caption).push(text.trim());
104
+ } else {
105
+ cells.push({
106
+ kind: el.t,
107
+ key: el.key || null,
108
+ text,
109
+ on: el.on,
110
+ off: el.off,
111
+ meta: c ? c.meta : null,
112
+ });
113
+ }
114
+ }
115
+ if (!caption.length && !cells.length) continue;
116
+ if (
117
+ !cells.length &&
118
+ caption.length >= 2 &&
119
+ !IPO_KEY_LEGEND.test(caption[0])
120
+ ) {
121
+ const rest = caption.slice(1).filter((t) => t !== ':');
122
+ if (rest.length) {
123
+ for (const t of rest)
124
+ cells.push({ kind: 'text', key: null, text: t, meta: null });
125
+ caption.length = 1;
126
+ }
127
+ }
128
+ rows.push({
129
+ caption: caption.join(' '),
130
+ parts: caption,
131
+ cells,
132
+ trailer: trailer.join(' '),
133
+ band: first, // first row of its logical line
134
+ });
135
+ first = false;
136
+ }
137
+ }
138
+ return rows;
139
+ }
140
+
141
+ /**
142
+ * A caption row over a cell row, paired by column: each lamp/bar takes the
143
+ * nearest caption at or left of its column. Null when the two rows are not
144
+ * that shape (the top row has a non-text element, the bottom row has text,
145
+ * or two cells would share a caption -- a heading over several lamps stays
146
+ * a heading).
147
+ * @param {IpoElement[]} top - the elements of one screen row
148
+ * @param {IpoElement[]|undefined} bottom - the elements of the next row
149
+ * @param {(el: IpoElement) => IpoCell|undefined} cellOf - the painted cell for an element
150
+ * @returns {Array<{caption: string, parts: string[], cells: IpoRowCell[], trailer: string}>|null}
151
+ */
152
+ function ipoPairCaptionRow(top, bottom, cellOf) {
153
+ if (!bottom || !bottom.length) return null;
154
+ const textOf = (el) => ((cellOf(el) || {}).text || '').trim();
155
+ // a caption is printed text, or a result printed as text that reads as a
156
+ // label (a measurement block prints STAT_MESSWERTn_TEXT over each bar)
157
+ const isLabel = (el) =>
158
+ el.t === 'text' ||
159
+ (el.t === 'value' &&
160
+ /[A-Za-z]/.test(textOf(el)) &&
161
+ !/^-?\d/.test(textOf(el)));
162
+ if (!top.every(isLabel)) return null;
163
+ // the row under it holds lamps or bars, plus printed units after them
164
+ if (!bottom.some((el) => el.t === 'lamp' || el.t === 'gauge')) return null;
165
+ if (
166
+ bottom.some((el) => el.t !== 'lamp' && el.t !== 'gauge' && el.t !== 'text')
167
+ )
168
+ return null;
169
+ const caps = top
170
+ .map((el) => ({ col: el.col, text: textOf(el) }))
171
+ .sort((a, b) => a.col - b.col);
172
+ if (!caps.length) return null;
173
+ const sorted = [...bottom].sort((a, b) => a.col - b.col);
174
+ const used = new Set();
175
+ const out = [];
176
+ for (const el of sorted) {
177
+ if (el.t === 'text') {
178
+ // a unit or note printed after a cell belongs to that cell
179
+ const t = textOf(el);
180
+ if (!out.length) return null;
181
+ if (t) {
182
+ const last = out[out.length - 1];
183
+ last.trailer = `${last.trailer} ${t}`.trim();
184
+ }
185
+ continue;
186
+ }
187
+ let cap = null;
188
+ for (const c of caps) if (c.col <= el.col + 1) cap = c;
189
+ if (!cap) cap = caps[0];
190
+ if (used.has(cap)) return null;
191
+ used.add(cap);
192
+ const c = cellOf(el);
193
+ out.push({
194
+ caption: cap.text,
195
+ parts: cap.text ? [cap.text] : [],
196
+ cells: [
197
+ {
198
+ kind: el.t,
199
+ key: el.key || null,
200
+ text: c ? c.text : '',
201
+ on: el.on,
202
+ off: el.off,
203
+ meta: c ? c.meta : null,
204
+ },
205
+ ],
206
+ trailer: '',
207
+ });
208
+ }
209
+ return out;
210
+ }
211
+
212
+ /**
213
+ * A tile's category class from the SGBD job the key's body sends (a fixed
214
+ * vocabulary), never from the caption.
215
+ * @param {string|null|undefined} job - job name
216
+ * @returns {string}
217
+ */
218
+ function ipoJobCategory(job) {
219
+ if (!job) return '';
220
+ if (/^(FS|IS|HS)_/i.test(job)) return 'gt-fault';
221
+ if (/^(STATUS|MESSWERT)/i.test(job)) return 'gt-live';
222
+ if (/^(STEUERN|START)/i.test(job)) return 'gt-act';
223
+ if (/IDENT|^INFO$/i.test(job)) return 'gt-info';
224
+ if (/COD|ADAPT|ABGLEICH/i.test(job)) return 'gt-code';
225
+ return '';
226
+ }
227
+
228
+ /**
229
+ * nr -> the caption the current screen printed for that key in its legend.
230
+ * @param {IpoProgram} p - the program
231
+ * @param {string[]|null} [notes] - collects the caption pieces that are not legend lines
232
+ * @returns {Map<number, string>}
233
+ */
234
+ function ipoLegendMap(p, notes) {
235
+ const legend = new Map();
236
+ for (const r of ipoLineRows(p)) {
237
+ for (const t of r.parts || []) {
238
+ const m = t.match(IPO_KEY_LEGEND);
239
+ if (!m) {
240
+ if (notes) notes.push(t);
241
+ continue;
242
+ }
243
+ const nr = Number(m[2]) + (m[1] ? IPO_SHIFT_BASE : 0);
244
+ if (!legend.has(nr)) legend.set(nr, m[3].trim());
245
+ }
246
+ }
247
+ return legend;
248
+ }
249
+
250
+ /**
251
+ * A menu's backdrop as tiles: one per ITEM the script declared, captioned
252
+ * by the ITEM's own short label, described by the legend line the screen
253
+ * printed for that key. A screen that prints no legend is a readout, drawn
254
+ * as rows (null). ZKE5's main screen prints BOTH -- the part number and
255
+ * build date above the legend -- so those rows stay rows (`rest`) and the
256
+ * legend becomes the tiles.
257
+ * @param {IpoProgram} p - the program
258
+ * @returns {IpoMenuTiles|null}
259
+ */
260
+ function ipoMenuTiles(p) {
261
+ const rows = ipoLineRows(p);
262
+ const legend = ipoLegendMap(p, null);
263
+ if (!legend.size) return null;
264
+ const items = (p.items || []).filter((it) => !it.hidden || legend.has(it.nr));
265
+ if (!items.length || !items.some((it) => legend.has(it.nr))) return null;
266
+ const rest = rows.filter(
267
+ (r) => !(r.parts || []).some((t) => IPO_KEY_LEGEND.test(t))
268
+ );
269
+ const toks = p.exec && p.exec.procs ? p.exec.procs[p.menu] : null;
270
+ const tiles = items.map((it) => {
271
+ let cat = '';
272
+ if (toks && typeof irItemBodyJobs === 'function') {
273
+ const jobs = irItemBodyJobs(p.exec, toks, it.start, it.end) || [];
274
+ for (const j of jobs) {
275
+ cat = ipoJobCategory(j);
276
+ if (cat) break;
277
+ }
278
+ }
279
+ return {
280
+ nr: it.nr,
281
+ shift: !!it.shift,
282
+ label: it.label || legend.get(it.nr) || '',
283
+ legend: legend.get(it.nr) || '',
284
+ cat: cat || 'gt-default',
285
+ };
286
+ });
287
+ return { tiles, rest };
288
+ }
289
+
290
+ /**
291
+ * Paint a menu's tiles. A backdrop screen can be frequent: the cycle
292
+ * repaints every tick. The tiles are rebuilt (and their entrance animation
293
+ * replayed) only when what they show changed, else the grid is left exactly
294
+ * as it is.
295
+ * @param {HTMLElement} gridEl - the screen container
296
+ * @param {IpoProgram} p - the program
297
+ * @param {IpoMenuTiles} menu - the tiles to draw
298
+ * @returns {void}
299
+ */
300
+ function ipoPaintTiles(gridEl, p, menu) {
301
+ const sig = JSON.stringify([p.menu, menu.tiles, menu.rest]);
302
+ if (gridEl._ipoTilesSig === sig) return;
303
+ gridEl._ipoTilesSig = sig;
304
+ const notes = ipoRowsHtml(menu.rest);
305
+ const grid = document.createElement('div');
306
+ grid.className = 'group-grid stagger';
307
+ for (const t of menu.tiles) {
308
+ const tile = document.createElement('div');
309
+ tile.className = `group-tile ${t.cat}`;
310
+ const fk = `${t.shift ? 'Shift+' : ''}F${t.nr > IPO_SHIFT_BASE ? t.nr - IPO_SHIFT_BASE : t.nr}`;
311
+ const name = ipoText(t.label);
312
+ const desc = t.legend ? ipoText(t.legend) : '';
313
+ tile.innerHTML =
314
+ `<div class="group-header-row"><span class="group-fkey">${esc(fk)}</span></div>` +
315
+ `<div class="group-name">${esc(name)}</div>` +
316
+ `<div class="group-count">${esc(desc !== name ? desc : '')}</div>` +
317
+ `<div class="group-arrow">→</div>`;
318
+ tile.onclick = () => p.press(t.nr);
319
+ grid.appendChild(tile);
320
+ }
321
+ gridEl.classList.add('ipo-tiles');
322
+ gridEl.innerHTML = notes ? `<div class="ipo-notes">${notes}</div>` : '';
323
+ gridEl.appendChild(grid);
324
+ if (typeof stagger === 'function') stagger(grid, IPO_TILE_STAGGER);
325
+ }
326
+
327
+ /**
328
+ * Paint the modern skin: a menu's tiles when the screen printed a legend,
329
+ * else the LINE rows; a screen func that printed without LINE blocks (a
330
+ * userbox, a banner) falls back to its rows of text in order.
331
+ * @param {HTMLElement} gridEl - the screen container
332
+ * @param {IpoProgram} p - the program
333
+ * @returns {void}
334
+ */
335
+ function ipoPaintLines(gridEl, p) {
336
+ const menu = ipoMenuTiles(p);
337
+ if (menu) {
338
+ ipoPaintTiles(gridEl, p, menu);
339
+ return;
340
+ }
341
+ gridEl._ipoTilesSig = null;
342
+ gridEl.classList.remove('ipo-tiles');
343
+ const rows = ipoLineRows(p);
344
+ if (!rows.length) {
345
+ const texts = [...p.cells.values()]
346
+ .filter((c) => c.text && c.text.trim())
347
+ .sort((a, b) => a.row - b.row || a.col - b.col);
348
+ if (!texts.length) {
349
+ gridEl.innerHTML = `<div class="ipo-empty">${esc(ipoText(p.title || ''))}</div>`;
350
+ return;
351
+ }
352
+ const byRow = new Map();
353
+ for (const c of texts) {
354
+ if (!byRow.has(c.row)) byRow.set(c.row, []);
355
+ byRow.get(c.row).push(ipoText(c.text).trim());
356
+ }
357
+ gridEl.innerHTML = [...byRow.values()]
358
+ .map(
359
+ (parts) =>
360
+ `<div class="ipo-line ipo-line-note">${esc(parts.join(' '))}</div>`
361
+ )
362
+ .join('');
363
+ return;
364
+ }
365
+ gridEl.innerHTML = ipoRowsHtml(rows);
366
+ }
367
+
368
+ /**
369
+ * The modern rows' HTML (see ipoLineRows). A logical line's first row gets
370
+ * air above it (one LINE per fault); a row with no caption is the value
371
+ * itself (a fault's own header row).
372
+ * @param {IpoRow[]} rows - the rows
373
+ * @returns {string} HTML
374
+ */
375
+ function ipoRowsHtml(rows) {
376
+ const html = [];
377
+ const capHtml = (parts) =>
378
+ parts
379
+ .map((t) => esc(ipoText(t).replace(/\s*[:=]\s*$/, '')))
380
+ .join('<span class="ipo-line-gap"></span>');
381
+ for (let i = 0; i < rows.length; i++) {
382
+ const r = rows[i];
383
+ const cap = capHtml(r.parts || [r.caption]);
384
+ const band = i > 0 && r.band ? ' ipo-band' : '';
385
+ if (!r.cells.length) {
386
+ html.push(`<div class="ipo-line ipo-line-note${band}">${cap}</div>`);
387
+ continue;
388
+ }
389
+ const cells = r.cells
390
+ .map((c) => {
391
+ const text = ipoText(c.text);
392
+ const key = c.key ? ` data-key="${esc(c.key)}"` : '';
393
+ if (c.kind === 'lamp') return ipoLampHtml(c);
394
+ if (c.kind === 'gauge') return ipoGaugeHtml(c);
395
+ return `<span class="ipo-line-val ipo-${c.kind}"${key}>${esc(text.trim())}</span>`;
396
+ })
397
+ .join('');
398
+ const unit = r.trailer
399
+ ? `<span class="ipo-line-unit">${esc(ipoText(r.trailer))}</span>`
400
+ : '';
401
+ const nocap = r.parts && r.parts.length ? '' : ' ipo-nocap';
402
+ html.push(
403
+ `<div class="ipo-line${band}${nocap}">` +
404
+ `<span class="ipo-line-cap">${cap}</span>` +
405
+ `<span class="ipo-line-cells">${cells}${unit}</span>` +
406
+ `</div>`
407
+ );
408
+ }
409
+ return html.join('');
410
+ }
411
+
412
+ if (typeof module !== 'undefined' && module.exports) {
413
+ module.exports = {
414
+ IPO_KEY_LEGEND,
415
+ ipoLineRows,
416
+ ipoPairCaptionRow,
417
+ ipoJobCategory,
418
+ ipoLegendMap,
419
+ ipoMenuTiles,
420
+ ipoPaintTiles,
421
+ ipoPaintLines,
422
+ ipoRowsHtml,
423
+ };
424
+ }
@@ -0,0 +1,281 @@
1
+ /**
2
+ * @file INPA's Print screen (the script's printscreen, the F9 key, Cmd/Ctrl+P
3
+ * on a module view) as a clean sheet: the module and menu named at the top,
4
+ * the screen as the car answered it, the keys the script offers, and no app
5
+ * chrome. In INPA mode the sheet is the 80-column text grid, lamps and bars
6
+ * spelled out; in modern mode it is the same rows as a caption / value /
7
+ * unit table. Built on core/print.js, so it paginates and prints
8
+ * black-on-white whatever theme the app wears.
9
+ */
10
+
11
+ /** Columns of INPA's virtual screen the printed grid keeps. */
12
+ const IPO_PRINT_COLS = 80;
13
+
14
+ /**
15
+ * One grid row as plain text: cells placed at their columns, a lamp as
16
+ * "[x] word" / "[ ] word", a bar as its reading followed by the declared
17
+ * scale.
18
+ * @param {Array<{col: number, text: string, kind: string, meta: IpoCellMeta|null}>} cells - the row's cells, column order
19
+ * @returns {string}
20
+ */
21
+ function ipoPrintRowText(cells) {
22
+ let out = '';
23
+ for (const c of cells) {
24
+ const gap = Math.max(c.col - out.length, out.length > 0 ? 1 : 0);
25
+ out += ' '.repeat(gap);
26
+ if (c.kind === 'lamp') {
27
+ out += `[${ipoLampOn(c) ? 'x' : ' '}] ${ipoText(c.text).trim()}`;
28
+ } else if (c.kind === 'gauge') {
29
+ out += `${ipoText(c.text).trim()}${ipoPrintScale(c.meta)}`;
30
+ } else {
31
+ out += c.text;
32
+ }
33
+ }
34
+ return out.replace(/\s+$/, '');
35
+ }
36
+
37
+ /**
38
+ * A bar's declared scale for the sheet: " (min..max)" when it has one.
39
+ * @param {IpoCellMeta|null} m - the cell's declaration
40
+ * @returns {string}
41
+ */
42
+ function ipoPrintScale(m) {
43
+ if (
44
+ !m ||
45
+ m.min == null ||
46
+ m.max == null ||
47
+ !Number.isFinite(m.min) ||
48
+ !Number.isFinite(m.max)
49
+ )
50
+ return '';
51
+ return ` (${ipoScaleLabel(m.min)}..${ipoScaleLabel(m.max)})`;
52
+ }
53
+
54
+ /**
55
+ * The INPA grid as text lines, top row to the last printed one, blank rows
56
+ * kept so the layout INPA chose survives on paper.
57
+ * @param {IpoProgram} p - the program
58
+ * @returns {string[]}
59
+ */
60
+ function ipoPrintGridText(p) {
61
+ const byRow = ipoGridRows(p);
62
+ const rows = [...byRow.keys()].sort((a, b) => a - b);
63
+ if (!rows.length) return [];
64
+ const last = rows[rows.length - 1];
65
+ const out = [];
66
+ for (let r = 0; r <= last; r++) {
67
+ const cells = (byRow.get(r) || []).sort((a, b) => a.col - b.col);
68
+ out.push(cells.length ? ipoPrintRowText(cells) : '');
69
+ }
70
+ // trailing blank rows only lengthen the sheet
71
+ while (out.length && !out[out.length - 1]) out.pop();
72
+ return out;
73
+ }
74
+
75
+ /**
76
+ * A modern-mode cell as text for the sheet.
77
+ * @param {{kind: string, text: string, meta: IpoCellMeta|null}} c - the cell
78
+ * @returns {string}
79
+ */
80
+ function ipoPrintCellText(c) {
81
+ const text = ipoText(c.text).trim();
82
+ if (c.kind === 'lamp') return `${ipoLampOn(c) ? '●' : '○'} ${text}`;
83
+ if (c.kind === 'gauge') return `${text}${ipoPrintScale(c.meta)}`;
84
+ return text;
85
+ }
86
+
87
+ /**
88
+ * The modern rows as a caption / value / unit table, notes spanning the
89
+ * width, a logical line's first row separated from the one before.
90
+ * @param {IpoRow[]} rows - the rows (see ipoLineRows)
91
+ * @returns {string} HTML
92
+ */
93
+ function ipoPrintRowsHtml(rows) {
94
+ if (!rows.length) return '';
95
+ const body = rows
96
+ .map((r, i) => {
97
+ const cap = (r.parts || [r.caption])
98
+ .map((t) => ipoText(t).replace(/\s*[:=]\s*$/, ''))
99
+ .join(' ');
100
+ const band = i > 0 && r.band ? ' class="pr-band"' : '';
101
+ if (!r.cells.length) {
102
+ return `<tr${band}><td colspan="3" class="pr-note">${esc(cap)}</td></tr>`;
103
+ }
104
+ const vals = r.cells.map(ipoPrintCellText).filter(Boolean).join(' ');
105
+ return (
106
+ `<tr${band}><td>${esc(cap)}</td>` +
107
+ `<td class="pr-mono pr-num">${esc(vals)}</td>` +
108
+ `<td class="pr-unit">${esc(r.trailer ? ipoText(r.trailer) : '')}</td></tr>`
109
+ );
110
+ })
111
+ .join('');
112
+ return `<table class="pr-table pr-screen-table"><tbody>${body}</tbody></table>`;
113
+ }
114
+
115
+ /**
116
+ * The keys the script offers on this menu, drawn as the F-key bar the app
117
+ * shows under the screen: ten slots F1..F10 with the short caption, a
118
+ * second row for the Shift keys when the menu has any, and the menu's own
119
+ * legend text under a caption where it says more. Empty slots stay as
120
+ * blank boxes, as on the bar.
121
+ * @param {IpoProgram} p - the program
122
+ * @returns {string} HTML, empty when the menu has no keys
123
+ */
124
+ function ipoPrintKeysHtml(p, inpa) {
125
+ const items = (p.items || []).filter((it) => it.nr !== 20);
126
+ if (!items.length) return '';
127
+ if (inpa) return ipoPrintInpaKeysHtml(p, items);
128
+ const menu = ipoMenuTiles(p);
129
+ const legend = new Map();
130
+ for (const t of (menu && menu.tiles) || []) {
131
+ if (t.legend) legend.set(`${t.shift ? 's' : ''}${t.nr}`, t.legend);
132
+ }
133
+ const byKey = new Map(items.map((it) => [it.nr, it]));
134
+ const row = (shift) => {
135
+ let any = false;
136
+ const slots = [];
137
+ for (let k = 1; k <= 10; k++) {
138
+ const nr = shift ? IPO_SHIFT_BASE + k : k;
139
+ const it = byKey.get(nr);
140
+ const label = it ? ipoKeyLabel(p, it) : '';
141
+ if (label) any = true;
142
+ const lg = it ? legend.get(`${shift ? 's' : ''}${nr}`) : null;
143
+ const sub =
144
+ lg && ipoText(lg).trim() !== label.trim()
145
+ ? `<span class="pr-fkey-s">${esc(ipoText(lg).trim())}</span>`
146
+ : '';
147
+ slots.push(
148
+ `<span class="pr-fkey${label ? '' : ' empty'}">` +
149
+ `<span class="pr-fkey-k">${shift ? '⇧' : ''}F${k}</span>` +
150
+ `<span class="pr-fkey-l">${esc(label)}</span>${sub}</span>`
151
+ );
152
+ }
153
+ return any ? `<div class="pr-fkeys">${slots.join('')}</div>` : '';
154
+ };
155
+ const plain = row(false);
156
+ const shifted = row(true);
157
+ if (!plain && !shifted) return '';
158
+ return `<div class="pr-keys">${plain}${shifted}</div>`;
159
+ }
160
+
161
+ /**
162
+ * The print document for the module view as it stands: what printDoc()
163
+ * takes. Pure, so the harness can pin it without a printer.
164
+ * @param {IpoProgram} p - the program
165
+ * @param {EcuRecord} ecu - the module
166
+ * @param {boolean} inpa - INPA layout (the text grid) rather than the modern rows
167
+ * @returns {PrintDocOptions}
168
+ */
169
+ /**
170
+ * The key bar the way the INPA layout draws it on screen: a row of key
171
+ * numbers over a row of flat boxes with the caption centred, and a Shift
172
+ * row below when the menu has shifted keys.
173
+ * @param {object} p - the running program
174
+ * @param {object[]} items - the menu's keys (Shift+F10 excluded)
175
+ * @returns {string} HTML
176
+ */
177
+ function ipoPrintInpaKeysHtml(p, items) {
178
+ const byKey = new Map(items.map((it) => [it.nr, it]));
179
+ const row = (shift) => {
180
+ let any = false;
181
+ const nums = [];
182
+ const btns = [];
183
+ for (let k = 1; k <= 10; k++) {
184
+ const it = byKey.get(shift ? IPO_SHIFT_BASE + k : k);
185
+ const label = it ? ipoKeyLabel(p, it) : '';
186
+ if (label) any = true;
187
+ nums.push(`<span class="pr-ikey-num">${shift ? '⇧' : ''}F${k}</span>`);
188
+ btns.push(
189
+ `<span class="pr-ikey${label ? '' : ' empty'}">${esc(label)}</span>`
190
+ );
191
+ }
192
+ return any
193
+ ? `<div class="pr-ikeys"><div class="pr-ikey-nums">${nums.join('')}</div>` +
194
+ `<div class="pr-ikey-btns">${btns.join('')}</div></div>`
195
+ : '';
196
+ };
197
+ const plain = row(false);
198
+ const shifted = row(true);
199
+ if (!plain && !shifted) return '';
200
+ return `<div class="pr-keys">${plain}${shifted}</div>`;
201
+ }
202
+
203
+ function ipoPrintDocument(p, ecu, inpa) {
204
+ const title = ipoText(p.title || '') || p.menu || '';
205
+ const sections = [];
206
+ if (
207
+ p.view &&
208
+ p.view.report &&
209
+ typeof ipoProtocolPrintSections === 'function'
210
+ ) {
211
+ // viewopen over a fault read: the scan report's tables, then INPA's text
212
+ sections.push(...ipoProtocolPrintSections(p.view));
213
+ } else if (p.view) {
214
+ // viewopen: the file the script wrote
215
+ sections.push({
216
+ html: `<pre class="pr-screen">${esc((p.view.lines || []).join('\n'))}</pre>`,
217
+ });
218
+ } else if (inpa) {
219
+ const lines = ipoPrintGridText(p);
220
+ if (lines.length) {
221
+ sections.push({
222
+ html: `<pre class="pr-screen">${esc(lines.join('\n'))}</pre>`,
223
+ });
224
+ }
225
+ } else {
226
+ const menu = ipoMenuTiles(p);
227
+ const rows = menu && menu.tiles.length ? menu.rest : ipoLineRows(p);
228
+ const html = ipoPrintRowsHtml(rows);
229
+ if (html) sections.push({ html });
230
+ }
231
+ const keys = ipoPrintKeysHtml(p, inpa);
232
+ if (keys) sections.push({ html: keys, avoidBreak: true });
233
+ if (!sections.length) {
234
+ sections.push({
235
+ html: `<p class="pr-p">${esc(title || 'Nothing on screen yet.')}</p>`,
236
+ });
237
+ }
238
+ const meta = [
239
+ ecu.chassis ? ['Chassis', String(ecu.chassis).toUpperCase()] : null,
240
+ ['SGBD', `${ecu.sgbd}.prg`],
241
+ p.menu ? ['Menu', p.menu] : null,
242
+ p.screen ? ['Screen', p.screen] : null,
243
+ ['Printed', new Date().toLocaleString()],
244
+ ];
245
+ return {
246
+ title: ecu.label || ecu.sgbd,
247
+ subtitle: title,
248
+ meta,
249
+ sections,
250
+ landscape:
251
+ inpa && ipoPrintGridText(p).some((l) => l.length > IPO_PRINT_COLS),
252
+ };
253
+ }
254
+
255
+ /**
256
+ * INPA's printscreen for the module view: the clean sheet when the print
257
+ * builder is loaded, the browser's own print otherwise.
258
+ * @param {IpoProgram} p - the program
259
+ * @param {EcuRecord} ecu - the module
260
+ * @param {boolean} inpa - INPA layout
261
+ * @returns {Promise<void>}
262
+ */
263
+ function ipoPrintScreen(p, ecu, inpa) {
264
+ if (typeof printDoc === 'function') {
265
+ return printDoc(ipoPrintDocument(p, ecu, inpa));
266
+ }
267
+ if (typeof window !== 'undefined' && typeof window.print === 'function')
268
+ window.print();
269
+ return Promise.resolve();
270
+ }
271
+
272
+ if (typeof module !== 'undefined' && module.exports) {
273
+ module.exports = {
274
+ IPO_PRINT_COLS,
275
+ ipoPrintGridText,
276
+ ipoPrintRowsHtml,
277
+ ipoPrintKeysHtml,
278
+ ipoPrintDocument,
279
+ ipoPrintScreen,
280
+ };
281
+ }