@datamoc/mw_games 0.26.1 → 0.30.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.
- package/CHANGELOG.md +134 -0
- package/README.md +2 -1
- package/api-surface.json +564 -0
- package/dist/ai/Suggest.d.ts +54 -0
- package/dist/ai/Suggest.d.ts.map +1 -0
- package/dist/ai/Suggest.js +48 -0
- package/dist/ai/Suggest.js.map +1 -0
- package/dist/ai/index.d.ts +2 -0
- package/dist/ai/index.d.ts.map +1 -1
- package/dist/ai/index.js +1 -0
- package/dist/ai/index.js.map +1 -1
- package/dist/core/Input.d.ts +7 -0
- package/dist/core/Input.d.ts.map +1 -1
- package/dist/core/Input.js +9 -0
- package/dist/core/Input.js.map +1 -1
- package/dist/core/Random.d.ts +32 -5
- package/dist/core/Random.d.ts.map +1 -1
- package/dist/core/Random.js.map +1 -1
- package/dist/core/Replay.d.ts +28 -4
- package/dist/core/Replay.d.ts.map +1 -1
- package/dist/core/Replay.js +38 -9
- package/dist/core/Replay.js.map +1 -1
- package/dist/core/ReplayFile.d.ts +197 -0
- package/dist/core/ReplayFile.d.ts.map +1 -0
- package/dist/core/ReplayFile.js +257 -0
- package/dist/core/ReplayFile.js.map +1 -0
- package/dist/core/index.d.ts +4 -2
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +2 -1
- package/dist/core/index.js.map +1 -1
- package/dist/mw_games.global.js +113 -113
- package/dist/simulation/Training.d.ts +77 -0
- package/dist/simulation/Training.d.ts.map +1 -1
- package/dist/simulation/Training.js +87 -0
- package/dist/simulation/Training.js.map +1 -1
- package/dist/simulation/index.d.ts +2 -2
- package/dist/simulation/index.d.ts.map +1 -1
- package/dist/simulation/index.js +1 -1
- package/dist/simulation/index.js.map +1 -1
- package/dist/two-d/render/Beam.d.ts +136 -0
- package/dist/two-d/render/Beam.d.ts.map +1 -0
- package/dist/two-d/render/Beam.js +203 -0
- package/dist/two-d/render/Beam.js.map +1 -0
- package/dist/two-d/render/Flights.d.ts +90 -0
- package/dist/two-d/render/Flights.d.ts.map +1 -0
- package/dist/two-d/render/Flights.js +135 -0
- package/dist/two-d/render/Flights.js.map +1 -0
- package/dist/two-d/render/Particles.d.ts +57 -0
- package/dist/two-d/render/Particles.d.ts.map +1 -1
- package/dist/two-d/render/Particles.js +68 -1
- package/dist/two-d/render/Particles.js.map +1 -1
- package/dist/two-d/render/index.d.ts +5 -1
- package/dist/two-d/render/index.d.ts.map +1 -1
- package/dist/two-d/render/index.js +2 -0
- package/dist/two-d/render/index.js.map +1 -1
- package/dist/two-d/ui/MessageLog.d.ts +28 -0
- package/dist/two-d/ui/MessageLog.d.ts.map +1 -1
- package/dist/two-d/ui/MessageLog.js +23 -6
- package/dist/two-d/ui/MessageLog.js.map +1 -1
- package/dist/two-d/ui/OnScreenKeyboard.d.ts +91 -0
- package/dist/two-d/ui/OnScreenKeyboard.d.ts.map +1 -0
- package/dist/two-d/ui/OnScreenKeyboard.js +109 -0
- package/dist/two-d/ui/OnScreenKeyboard.js.map +1 -0
- package/dist/two-d/ui/TextPrompt.d.ts +122 -0
- package/dist/two-d/ui/TextPrompt.d.ts.map +1 -0
- package/dist/two-d/ui/TextPrompt.js +236 -0
- package/dist/two-d/ui/TextPrompt.js.map +1 -0
- package/dist/two-d/ui/index.d.ts +6 -2
- package/dist/two-d/ui/index.d.ts.map +1 -1
- package/dist/two-d/ui/index.js +3 -1
- package/dist/two-d/ui/index.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
- package/tools/browser-driver.d.mts +30 -0
- package/tools/browser-driver.mjs +146 -3
|
@@ -4,17 +4,22 @@ import { theme } from './theme.js';
|
|
|
4
4
|
/** the default per-level text colours; `info` and `highlight` follow the active theme */
|
|
5
5
|
const LEVEL_COLORS = { positive: 0x55dd77, negative: 0xff5555, warning: 0xffb347 };
|
|
6
6
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* The newest `count` entries of a retained log, oldest first. Pure so the readback
|
|
8
|
+
* contract is testable without a renderer: a `count` of 0 (or negative) reads
|
|
9
|
+
* nothing, a `count past what is retained reads everything retained.
|
|
10
10
|
*
|
|
11
11
|
* @example
|
|
12
12
|
* ```ts
|
|
13
|
-
* import {
|
|
13
|
+
* import { takeLastEntries } from '@datamoc/mw_games/two-d/ui';
|
|
14
14
|
*
|
|
15
|
-
* console.log(
|
|
15
|
+
* console.log(takeLastEntries(['a', 'b', 'c'], 2)); // ['b', 'c']
|
|
16
16
|
* ```
|
|
17
17
|
*/
|
|
18
|
+
export function takeLastEntries(entries, count) {
|
|
19
|
+
if (!(count > 0))
|
|
20
|
+
return [];
|
|
21
|
+
return entries.slice(Math.max(0, entries.length - Math.floor(count)));
|
|
22
|
+
}
|
|
18
23
|
export function linesToDrop(lineCounts, maxLines) {
|
|
19
24
|
let total = 0;
|
|
20
25
|
for (const lines of lineCounts)
|
|
@@ -54,6 +59,18 @@ export class MessageLog extends Container {
|
|
|
54
59
|
get contentHeight() {
|
|
55
60
|
return this.blocks.reduce((height, block) => height + block.label.height, 0);
|
|
56
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* The newest `count` entries as plain `{ text, level }` records, oldest first -
|
|
64
|
+
* the readback for automated checks and secondary displays, without scraping
|
|
65
|
+
* rendered children. Entries the wrapped-line budget already dropped are gone, so
|
|
66
|
+
* what "last" means is bounded by `entryCount`: a `count` past that returns
|
|
67
|
+
* everything retained, and a `count` of 0 (or negative) returns `[]`. The
|
|
68
|
+
* records are copies; mutating them cannot corrupt the log. (There is no
|
|
69
|
+
* `snapshot()` on this widget, so there is nothing further to include.)
|
|
70
|
+
*/
|
|
71
|
+
lastEntries(count) {
|
|
72
|
+
return takeLastEntries(this.blocks.map((block) => ({ text: block.text, level: block.level })), count);
|
|
73
|
+
}
|
|
57
74
|
add(text, level = 'info') {
|
|
58
75
|
const label = new Label({
|
|
59
76
|
text,
|
|
@@ -63,7 +80,7 @@ export class MessageLog extends Container {
|
|
|
63
80
|
resolution: this.options.resolution,
|
|
64
81
|
});
|
|
65
82
|
this.addChild(label);
|
|
66
|
-
this.blocks.push({ label, level });
|
|
83
|
+
this.blocks.push({ label, level, text });
|
|
67
84
|
this.trim();
|
|
68
85
|
this.layout();
|
|
69
86
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"MessageLog.js","sourceRoot":"","sources":["../../../src/two-d/ui/MessageLog.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AACpC,OAAO,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AACnC,OAAO,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AAKnC,yFAAyF;AACzF,MAAM,YAAY,GAAG,EAAE,QAAQ,EAAE,QAAQ,EAAE,QAAQ,EAAE,QAAQ,EAAE,OAAO,EAAE,QAAQ,EAAW,CAAC;
|
|
1
|
+
{"version":3,"file":"MessageLog.js","sourceRoot":"","sources":["../../../src/two-d/ui/MessageLog.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { Container } from 'pixi.js';
|
|
2
|
+
import { type Action } from '../../core/Input.ts';
|
|
3
|
+
/** one key: what it is labelled, what it types, or which action it fires */
|
|
4
|
+
export interface KeyboardKey {
|
|
5
|
+
/** the key's own caption */
|
|
6
|
+
label: string;
|
|
7
|
+
/**
|
|
8
|
+
* What the key types through `core.Input`'s `onText`. Defaults to `label`, so an
|
|
9
|
+
* ordinary letter key is just `{ label: 'A' }`; a wider gap or an accented glyph
|
|
10
|
+
* can say `{ label: 'a`', text: 'à' }`.
|
|
11
|
+
*/
|
|
12
|
+
text?: string;
|
|
13
|
+
/**
|
|
14
|
+
* The named action the key fires instead of typing - `backspace`/`delete`/`left`/
|
|
15
|
+
* `right`/`confirm`/`cancel`, the same vocabulary a physical key is bound to. A
|
|
16
|
+
* key with an action types nothing.
|
|
17
|
+
*/
|
|
18
|
+
action?: Action;
|
|
19
|
+
/** row units wide; a space bar is wide, an ordinary key is 1. Default 1 */
|
|
20
|
+
span?: number;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Rows of keys as plain data - the shape a game authors, never code. Ragged rows are
|
|
24
|
+
* fine: each starts at the left edge and ends where it ends.
|
|
25
|
+
*/
|
|
26
|
+
export interface KeyboardLayout {
|
|
27
|
+
/** the rows, top to bottom */
|
|
28
|
+
rows: KeyboardKey[][];
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* One shipped default: the uppercase alphabet over a digit row, then the editing tail
|
|
32
|
+
* (`Bksp`, caret moves, space, `OK`, `Esc`). A game with its own needs - digits only, a
|
|
33
|
+
* seed code's letters and hyphens - authors its own layout; the default exists so a
|
|
34
|
+
* first game does not start from a blank page.
|
|
35
|
+
*/
|
|
36
|
+
export declare const UPPERCASE_KEYBOARD: KeyboardLayout;
|
|
37
|
+
/**
|
|
38
|
+
* What pressing a key does: a typing key feeds `core.Input`'s `onText`, an action key
|
|
39
|
+
* fires its action through `onAction` - the same two paths a physical key travels, so
|
|
40
|
+
* a prompt or menu cannot tell an on-screen tap from a keyboard press. Pure, so the
|
|
41
|
+
* dispatch semantics are testable without a renderer.
|
|
42
|
+
*/
|
|
43
|
+
export declare function pressKey(key: KeyboardKey): void;
|
|
44
|
+
/** where one laid-out key sits, and which key it is */
|
|
45
|
+
export interface LaidOutKey {
|
|
46
|
+
key: KeyboardKey;
|
|
47
|
+
x: number;
|
|
48
|
+
y: number;
|
|
49
|
+
width: number;
|
|
50
|
+
height: number;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The rows-to-buttons arithmetic as a pure function: a row's span units share its width
|
|
54
|
+
* minus the gaps, wide keys take their units plus the gaps between them, and rows stack
|
|
55
|
+
* at a fixed height. The class below is the thin Button shell over this.
|
|
56
|
+
*/
|
|
57
|
+
export declare function layoutKeys(layout: KeyboardLayout, options: {
|
|
58
|
+
width: number;
|
|
59
|
+
keyHeight: number;
|
|
60
|
+
gap: number;
|
|
61
|
+
}): LaidOutKey[];
|
|
62
|
+
/**
|
|
63
|
+
* An on-screen keyboard: rows of keys as data, rendered through the theme's own
|
|
64
|
+
* `Button`, each tap pressing its key through `pressKey` - `dispatchText` for a typing
|
|
65
|
+
* key, `dispatchAction` for an action key - so a `TextPrompt` (or any listener of
|
|
66
|
+
* `onText`/`onAction`) cannot tell the tap from a physical key press. Pointer-only
|
|
67
|
+
* devices get a way to type; nothing game-specific lives here.
|
|
68
|
+
*
|
|
69
|
+
* @example
|
|
70
|
+
* ```ts
|
|
71
|
+
* import { OnScreenKeyboard, UPPERCASE_KEYBOARD } from '@datamoc/mw_games/two-d/ui';
|
|
72
|
+
* import type { Container2D } from '@datamoc/mw_games/two-d/render';
|
|
73
|
+
*
|
|
74
|
+
* declare const layer: Container2D;
|
|
75
|
+
*
|
|
76
|
+
* const keyboard = new OnScreenKeyboard(UPPERCASE_KEYBOARD, { width: 320 });
|
|
77
|
+
* keyboard.x = 20;
|
|
78
|
+
* keyboard.y = 240;
|
|
79
|
+
* layer.addChild(keyboard);
|
|
80
|
+
* // taps type through core.Input, exactly like a physical key
|
|
81
|
+
* ```
|
|
82
|
+
*/
|
|
83
|
+
export declare class OnScreenKeyboard extends Container {
|
|
84
|
+
private readonly laidOutHeight;
|
|
85
|
+
constructor(layout: KeyboardLayout, options: {
|
|
86
|
+
width: number;
|
|
87
|
+
});
|
|
88
|
+
/** the laid-out height, so a host window can size itself to the keyboard */
|
|
89
|
+
get contentHeight(): number;
|
|
90
|
+
}
|
|
91
|
+
//# sourceMappingURL=OnScreenKeyboard.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"OnScreenKeyboard.d.ts","sourceRoot":"","sources":["../../../src/two-d/ui/OnScreenKeyboard.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AAEpC,OAAO,EAAgC,KAAK,MAAM,EAAE,MAAM,qBAAqB,CAAC;AAIhF,4EAA4E;AAC5E,MAAM,WAAW,WAAW;IAC3B,4BAA4B;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd;;;;OAIG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;;OAIG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,2EAA2E;IAC3E,IAAI,CAAC,EAAE,MAAM,CAAC;CACd;AAED;;;GAGG;AACH,MAAM,WAAW,cAAc;IAC9B,8BAA8B;IAC9B,IAAI,EAAE,WAAW,EAAE,EAAE,CAAC;CACtB;AAID;;;;;GAKG;AACH,eAAO,MAAM,kBAAkB,EAAE,cAehC,CAAC;AAEF;;;;;GAKG;AACH,wBAAgB,QAAQ,CAAC,GAAG,EAAE,WAAW,GAAG,IAAI,CAG/C;AAED,uDAAuD;AACvD,MAAM,WAAW,UAAU;IAC1B,GAAG,EAAE,WAAW,CAAC;IACjB,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;IACV,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CACf;AAED;;;;GAIG;AACH,wBAAgB,UAAU,CACzB,MAAM,EAAE,cAAc,EACtB,OAAO,EAAE;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GACxD,UAAU,EAAE,CAkBd;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,qBAAa,gBAAiB,SAAQ,SAAS;IAC9C,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAS;IAEvC,YAAY,MAAM,EAAE,cAAc,EAAE,OAAO,EAAE;QAAE,KAAK,EAAE,MAAM,CAAA;KAAE,EAkB7D;IAED,4EAA4E;IAC5E,IAAI,aAAa,IAAI,MAAM,CAE1B;CACD"}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { Container } from 'pixi.js';
|
|
2
|
+
import { dispatchAction, dispatchText } from '../../core/Input.js';
|
|
3
|
+
import { Button } from './Button.js';
|
|
4
|
+
import { theme } from './theme.js';
|
|
5
|
+
const letters = (word) => word.split('').map((label) => ({ label }));
|
|
6
|
+
/**
|
|
7
|
+
* One shipped default: the uppercase alphabet over a digit row, then the editing tail
|
|
8
|
+
* (`Bksp`, caret moves, space, `OK`, `Esc`). A game with its own needs - digits only, a
|
|
9
|
+
* seed code's letters and hyphens - authors its own layout; the default exists so a
|
|
10
|
+
* first game does not start from a blank page.
|
|
11
|
+
*/
|
|
12
|
+
export const UPPERCASE_KEYBOARD = {
|
|
13
|
+
rows: [
|
|
14
|
+
letters('QWERTYUIOP'),
|
|
15
|
+
letters('ASDFGHJKL'),
|
|
16
|
+
letters('ZXCVBNM'),
|
|
17
|
+
letters('1234567890'),
|
|
18
|
+
[
|
|
19
|
+
{ label: 'Bksp', action: 'backspace' },
|
|
20
|
+
{ label: 'Left', action: 'left' },
|
|
21
|
+
{ label: 'Right', action: 'right' },
|
|
22
|
+
{ label: 'Space', text: ' ', span: 3 },
|
|
23
|
+
{ label: 'OK', action: 'confirm' },
|
|
24
|
+
{ label: 'Esc', action: 'cancel' },
|
|
25
|
+
],
|
|
26
|
+
],
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* What pressing a key does: a typing key feeds `core.Input`'s `onText`, an action key
|
|
30
|
+
* fires its action through `onAction` - the same two paths a physical key travels, so
|
|
31
|
+
* a prompt or menu cannot tell an on-screen tap from a keyboard press. Pure, so the
|
|
32
|
+
* dispatch semantics are testable without a renderer.
|
|
33
|
+
*/
|
|
34
|
+
export function pressKey(key) {
|
|
35
|
+
if (key.action !== undefined)
|
|
36
|
+
dispatchAction(key.action);
|
|
37
|
+
else
|
|
38
|
+
dispatchText(key.text ?? key.label);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The rows-to-buttons arithmetic as a pure function: a row's span units share its width
|
|
42
|
+
* minus the gaps, wide keys take their units plus the gaps between them, and rows stack
|
|
43
|
+
* at a fixed height. The class below is the thin Button shell over this.
|
|
44
|
+
*/
|
|
45
|
+
export function layoutKeys(layout, options) {
|
|
46
|
+
const laid = [];
|
|
47
|
+
let y = 0;
|
|
48
|
+
for (const row of layout.rows) {
|
|
49
|
+
const units = row.reduce((sum, key) => sum + (key.span ?? 1), 0);
|
|
50
|
+
//a row is span-units wide with a gap between every unit slot, so a span-S key
|
|
51
|
+
//covers S slots plus its internal gaps and the row consumes its width exactly
|
|
52
|
+
const unitWidth = (options.width - options.gap * (units - 1)) / units;
|
|
53
|
+
let x = 0;
|
|
54
|
+
for (const key of row) {
|
|
55
|
+
const span = key.span ?? 1;
|
|
56
|
+
const width = unitWidth * span + options.gap * (span - 1);
|
|
57
|
+
laid.push({ key, x, y, width, height: options.keyHeight });
|
|
58
|
+
x += width + options.gap;
|
|
59
|
+
}
|
|
60
|
+
y += options.keyHeight + options.gap;
|
|
61
|
+
}
|
|
62
|
+
return laid;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* An on-screen keyboard: rows of keys as data, rendered through the theme's own
|
|
66
|
+
* `Button`, each tap pressing its key through `pressKey` - `dispatchText` for a typing
|
|
67
|
+
* key, `dispatchAction` for an action key - so a `TextPrompt` (or any listener of
|
|
68
|
+
* `onText`/`onAction`) cannot tell the tap from a physical key press. Pointer-only
|
|
69
|
+
* devices get a way to type; nothing game-specific lives here.
|
|
70
|
+
*
|
|
71
|
+
* @example
|
|
72
|
+
* ```ts
|
|
73
|
+
* import { OnScreenKeyboard, UPPERCASE_KEYBOARD } from '@datamoc/mw_games/two-d/ui';
|
|
74
|
+
* import type { Container2D } from '@datamoc/mw_games/two-d/render';
|
|
75
|
+
*
|
|
76
|
+
* declare const layer: Container2D;
|
|
77
|
+
*
|
|
78
|
+
* const keyboard = new OnScreenKeyboard(UPPERCASE_KEYBOARD, { width: 320 });
|
|
79
|
+
* keyboard.x = 20;
|
|
80
|
+
* keyboard.y = 240;
|
|
81
|
+
* layer.addChild(keyboard);
|
|
82
|
+
* // taps type through core.Input, exactly like a physical key
|
|
83
|
+
* ```
|
|
84
|
+
*/
|
|
85
|
+
export class OnScreenKeyboard extends Container {
|
|
86
|
+
constructor(layout, options) {
|
|
87
|
+
super();
|
|
88
|
+
const gap = theme().spacing;
|
|
89
|
+
const keyHeight = Math.max(theme().font.size * 2, 18);
|
|
90
|
+
let maxY = 0;
|
|
91
|
+
for (const laid of layoutKeys(layout, { width: options.width, keyHeight, gap })) {
|
|
92
|
+
const button = new Button({
|
|
93
|
+
width: laid.width,
|
|
94
|
+
height: laid.height,
|
|
95
|
+
text: laid.key.label,
|
|
96
|
+
onClick: () => pressKey(laid.key),
|
|
97
|
+
});
|
|
98
|
+
button.position.set(laid.x, laid.y);
|
|
99
|
+
this.addChild(button);
|
|
100
|
+
maxY = Math.max(maxY, laid.y + laid.height);
|
|
101
|
+
}
|
|
102
|
+
this.laidOutHeight = maxY;
|
|
103
|
+
}
|
|
104
|
+
/** the laid-out height, so a host window can size itself to the keyboard */
|
|
105
|
+
get contentHeight() {
|
|
106
|
+
return this.laidOutHeight;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
//# sourceMappingURL=OnScreenKeyboard.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"OnScreenKeyboard.js","sourceRoot":"","sources":["../../../src/two-d/ui/OnScreenKeyboard.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AAEpC,OAAO,EAAE,cAAc,EAAE,YAAY,EAAe,MAAM,qBAAqB,CAAC;AAChF,OAAO,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AACrC,OAAO,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AA+BnC,MAAM,OAAO,GAAG,CAAC,IAAY,EAAiB,EAAE,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC;AAE5F;;;;;GAKG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAmB;IACjD,IAAI,EAAE;QACL,OAAO,CAAC,YAAY,CAAC;QACrB,OAAO,CAAC,WAAW,CAAC;QACpB,OAAO,CAAC,SAAS,CAAC;QAClB,OAAO,CAAC,YAAY,CAAC;QACrB;YACC,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE;YACtC,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE;YACjC,EAAE,KAAK,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE;YACnC,EAAE,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,GAAG,EAAE,IAAI,EAAE,CAAC,EAAE;YACtC,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE;YAClC,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE;SAClC;KACD;CACD,CAAC;AAEF;;;;;GAKG;AACH,MAAM,UAAU,QAAQ,CAAC,GAAgB;IACxC,IAAI,GAAG,CAAC,MAAM,KAAK,SAAS;QAAE,cAAc,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;;QACpD,YAAY,CAAC,GAAG,CAAC,IAAI,IAAI,GAAG,CAAC,KAAK,CAAC,CAAC;AAC1C,CAAC;AAWD;;;;GAIG;AACH,MAAM,UAAU,UAAU,CACzB,MAAsB,EACtB,OAA0D;IAE1D,MAAM,IAAI,GAAG,EAAE,CAAC;IAChB,IAAI,CAAC,GAAG,CAAC,CAAC;IACV,KAAK,MAAM,GAAG,IAAI,MAAM,CAAC,IAAI,EAAE,CAAC;QAC/B,MAAM,KAAK,GAAG,GAAG,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE,CAAC,GAAG,GAAG,CAAC,GAAG,CAAC,IAAI,IAAI,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;QACjE,8EAA8E;QAC9E,8EAA8E;QAC9E,MAAM,SAAS,GAAG,CAAC,OAAO,CAAC,KAAK,GAAG,OAAO,CAAC,GAAG,GAAG,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC,GAAG,KAAK,CAAC;QACtE,IAAI,CAAC,GAAG,CAAC,CAAC;QACV,KAAK,MAAM,GAAG,IAAI,GAAG,EAAE,CAAC;YACvB,MAAM,IAAI,GAAG,GAAG,CAAC,IAAI,IAAI,CAAC,CAAC;YAC3B,MAAM,KAAK,GAAG,SAAS,GAAG,IAAI,GAAG,OAAO,CAAC,GAAG,GAAG,CAAC,IAAI,GAAG,CAAC,CAAC,CAAC;YAC1D,IAAI,CAAC,IAAI,CAAC,EAAE,GAAG,EAAE,CAAC,EAAE,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,CAAC,SAAS,EAAE,CAAC,CAAC;YAC3D,CAAC,IAAI,KAAK,GAAG,OAAO,CAAC,GAAG,CAAC;QAC1B,CAAC;QACD,CAAC,IAAI,OAAO,CAAC,SAAS,GAAG,OAAO,CAAC,GAAG,CAAC;IACtC,CAAC;IACD,OAAO,IAAI,CAAC;AACb,CAAC;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,MAAM,OAAO,gBAAiB,SAAQ,SAAS;IAG9C,YAAY,MAAsB,EAAE,OAA0B;QAC7D,KAAK,EAAE,CAAC;QACR,MAAM,GAAG,GAAG,KAAK,EAAE,CAAC,OAAO,CAAC;QAC5B,MAAM,SAAS,GAAG,IAAI,CAAC,GAAG,CAAC,KAAK,EAAE,CAAC,IAAI,CAAC,IAAI,GAAG,CAAC,EAAE,EAAE,CAAC,CAAC;QAEtD,IAAI,IAAI,GAAG,CAAC,CAAC;QACb,KAAK,MAAM,IAAI,IAAI,UAAU,CAAC,MAAM,EAAE,EAAE,KAAK,EAAE,OAAO,CAAC,KAAK,EAAE,SAAS,EAAE,GAAG,EAAE,CAAC,EAAE,CAAC;YACjF,MAAM,MAAM,GAAG,IAAI,MAAM,CAAC;gBACzB,KAAK,EAAE,IAAI,CAAC,KAAK;gBACjB,MAAM,EAAE,IAAI,CAAC,MAAM;gBACnB,IAAI,EAAE,IAAI,CAAC,GAAG,CAAC,KAAK;gBACpB,OAAO,EAAE,GAAG,EAAE,CAAC,QAAQ,CAAC,IAAI,CAAC,GAAG,CAAC;aACjC,CAAC,CAAC;YACH,MAAM,CAAC,QAAQ,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC;YACpC,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;YACtB,IAAI,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC,GAAG,IAAI,CAAC,MAAM,CAAC,CAAC;QAC7C,CAAC;QACD,IAAI,CAAC,aAAa,GAAG,IAAI,CAAC;IAC3B,CAAC;IAED,4EAA4E;IAC5E,IAAI,aAAa;QAChB,OAAO,IAAI,CAAC,aAAa,CAAC;IAC3B,CAAC;CACD"}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { type Action } from '../../core/Input.ts';
|
|
2
|
+
import { Window } from './Window.ts';
|
|
3
|
+
import { type ScreenReader } from './a11y.ts';
|
|
4
|
+
import { type KeyboardLayout } from './OnScreenKeyboard.ts';
|
|
5
|
+
export interface TextPromptOptions {
|
|
6
|
+
width: number;
|
|
7
|
+
height: number;
|
|
8
|
+
/** the window title; also announced by `Window` itself */
|
|
9
|
+
title?: string;
|
|
10
|
+
/** the one-line question above the field, announced politely on open */
|
|
11
|
+
message?: string;
|
|
12
|
+
/** the field's starting text; the caret starts after it */
|
|
13
|
+
initialValue?: string;
|
|
14
|
+
/** the most characters the field will hold; unlimited when omitted */
|
|
15
|
+
maxLength?: number;
|
|
16
|
+
/**
|
|
17
|
+
* Rows of keys rendered beneath the field for pointer-only devices, feeding the same
|
|
18
|
+
* `onText`/`onAction` paths a physical key travels. Omit on a keyboard-first platform.
|
|
19
|
+
*/
|
|
20
|
+
keyboard?: KeyboardLayout;
|
|
21
|
+
/**
|
|
22
|
+
* Decides whether the current value may be confirmed: return an error message to
|
|
23
|
+
* keep the prompt open, or null (or '') when the value is fine. Runs only on
|
|
24
|
+
* `confirm`, so typing never nags; the error clears on the next edit.
|
|
25
|
+
*/
|
|
26
|
+
validate?: (value: string) => string | null;
|
|
27
|
+
/** called with the value, then the prompt closes */
|
|
28
|
+
onConfirm: (value: string) => void;
|
|
29
|
+
/** called on `cancel`, then the prompt closes; omit to just close */
|
|
30
|
+
onCancel?: () => void;
|
|
31
|
+
/** dim the world behind; true for a modal window, which this is */
|
|
32
|
+
dims?: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Whether a click under the prompt is swallowed rather than reaching the game
|
|
35
|
+
* behind it. On by default: a name not yet given is not something a stray click
|
|
36
|
+
* on the world should be able to act past.
|
|
37
|
+
*/
|
|
38
|
+
blocker?: boolean;
|
|
39
|
+
/** where the stack puts the prompt */
|
|
40
|
+
anchor?: 'center' | 'bottom' | 'top';
|
|
41
|
+
/**
|
|
42
|
+
* Announce the message on open and a validation error on `confirm` through an
|
|
43
|
+
* `a11y.ScreenReader`. Default true; the calls are no-ops where there is no DOM.
|
|
44
|
+
*/
|
|
45
|
+
announce?: boolean;
|
|
46
|
+
/** the reader announcements go to; the shared one by default, a fake in tests */
|
|
47
|
+
announcer?: Pick<ScreenReader, 'announce'>;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* A modal single-line text field: a name entry, a save-slot label, a seed.
|
|
51
|
+
*
|
|
52
|
+
* Typing arrives through `core.Input`'s `onText`, which is the signal a free text field
|
|
53
|
+
* listens to, so this plays no part in key bindings for characters: whatever the
|
|
54
|
+
* platform turns a key press into lands in the field. Editing and movement arrive as
|
|
55
|
+
* actions instead, which a game binds the way it binds everything else: `left`/`right`
|
|
56
|
+
* move the caret, `backspace`/`delete` edit, `confirm` accepts subject to `validate`,
|
|
57
|
+
* `cancel` gives up. The prompt is modal and swallows each of those while open, so
|
|
58
|
+
* the game underneath never walks away mid-name.
|
|
59
|
+
*
|
|
60
|
+
* `onText` wakes the most recently added listener first, so when two prompts overlap
|
|
61
|
+
* the topmost one gets the keystrokes - the same order the window stack shows them in.
|
|
62
|
+
*
|
|
63
|
+
* An in-progress IME composition previews below the field and only becomes value on
|
|
64
|
+
* commit, so CJK, dead keys and emoji pickers type through the same path the DOM gives
|
|
65
|
+
* every other input rather than through a game-specific workaround.
|
|
66
|
+
*
|
|
67
|
+
* @example
|
|
68
|
+
* ```ts
|
|
69
|
+
* import { TextPrompt } from '@datamoc/mw_games/two-d/ui';
|
|
70
|
+
*
|
|
71
|
+
* const prompt = new TextPrompt({
|
|
72
|
+
* width: 320,
|
|
73
|
+
* height: 140,
|
|
74
|
+
* title: 'New hero',
|
|
75
|
+
* message: 'Name your hero (Enter keeps it)',
|
|
76
|
+
* maxLength: 12,
|
|
77
|
+
* validate: (value) => (value.trim() === '' ? 'Give them a name first.' : null),
|
|
78
|
+
* onConfirm: (value) => console.log('named', value),
|
|
79
|
+
* });
|
|
80
|
+
* ```
|
|
81
|
+
*/
|
|
82
|
+
export declare class TextPrompt extends Window {
|
|
83
|
+
private readonly model;
|
|
84
|
+
private readonly validate?;
|
|
85
|
+
private readonly onConfirm;
|
|
86
|
+
private readonly onCancel?;
|
|
87
|
+
private readonly announce;
|
|
88
|
+
private readonly announcer;
|
|
89
|
+
private preview;
|
|
90
|
+
private promptError;
|
|
91
|
+
private readonly keyboard;
|
|
92
|
+
private messageLabel;
|
|
93
|
+
private valueLabel;
|
|
94
|
+
private previewLabel;
|
|
95
|
+
private errorLabel;
|
|
96
|
+
private caretBar;
|
|
97
|
+
private readonly measurer;
|
|
98
|
+
private readonly lineHeight;
|
|
99
|
+
private readonly valueY;
|
|
100
|
+
private canMeasureCaret;
|
|
101
|
+
private blinkOn;
|
|
102
|
+
private blinkElapsed;
|
|
103
|
+
private readonly promptThemeListener;
|
|
104
|
+
private readonly textListener;
|
|
105
|
+
private readonly compositionListener;
|
|
106
|
+
constructor(options: TextPromptOptions);
|
|
107
|
+
/** the field's current text */
|
|
108
|
+
get value(): string;
|
|
109
|
+
/** the caret's index into `value` */
|
|
110
|
+
get caretIndex(): number;
|
|
111
|
+
/** the last validation error, or null when the value currently passes */
|
|
112
|
+
get error(): string | null;
|
|
113
|
+
handleAction(action: Action): boolean;
|
|
114
|
+
update(dt: number): void;
|
|
115
|
+
destroy(options?: Parameters<Window['destroy']>[0]): void;
|
|
116
|
+
/** runs an edit, dropping a stale validation error only when the text changed */
|
|
117
|
+
private edit;
|
|
118
|
+
private confirm;
|
|
119
|
+
private restylePrompt;
|
|
120
|
+
private render;
|
|
121
|
+
}
|
|
122
|
+
//# sourceMappingURL=TextPrompt.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"TextPrompt.d.ts","sourceRoot":"","sources":["../../../src/two-d/ui/TextPrompt.ts"],"names":[],"mappings":"AACA,OAAO,EAAyB,KAAK,MAAM,EAAyB,MAAM,qBAAqB,CAAC;AAEhG,OAAO,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAGrC,OAAO,EAAgB,KAAK,YAAY,EAAE,MAAM,WAAW,CAAC;AAC5D,OAAO,EAAoB,KAAK,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAK9E,MAAM,WAAW,iBAAiB;IACjC,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IAEf,0DAA0D;IAC1D,KAAK,CAAC,EAAE,MAAM,CAAC;IAEf,wEAAwE;IACxE,OAAO,CAAC,EAAE,MAAM,CAAC;IAEjB,2DAA2D;IAC3D,YAAY,CAAC,EAAE,MAAM,CAAC;IAEtB,sEAAsE;IACtE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;OAGG;IACH,QAAQ,CAAC,EAAE,cAAc,CAAC;IAE1B;;;;OAIG;IACH,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,GAAG,IAAI,CAAC;IAE5C,oDAAoD;IACpD,SAAS,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAEnC,qEAAqE;IACrE,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;IAEtB,mEAAmE;IACnE,IAAI,CAAC,EAAE,OAAO,CAAC;IAEf;;;;OAIG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAElB,sCAAsC;IACtC,MAAM,CAAC,EAAE,QAAQ,GAAG,QAAQ,GAAG,KAAK,CAAC;IAErC;;;OAGG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IAEnB,iFAAiF;IACjF,SAAS,CAAC,EAAE,IAAI,CAAC,YAAY,EAAE,UAAU,CAAC,CAAC;CAC3C;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,qBAAa,UAAW,SAAQ,MAAM;IACrC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAY;IAClC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAmC;IAC7D,OAAO,CAAC,QAAQ,CAAC,SAAS,CAA0B;IACpD,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAa;IACvC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAU;IACnC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAiC;IAE3D,OAAO,CAAC,OAAO,CAAM;IACrB,OAAO,CAAC,WAAW,CAAuB;IAE1C,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAiC;IAC1D,OAAO,CAAC,YAAY,CAAsB;IAC1C,OAAO,CAAC,UAAU,CAAQ;IAC1B,OAAO,CAAC,YAAY,CAAQ;IAC5B,OAAO,CAAC,UAAU,CAAQ;IAC1B,OAAO,CAAC,QAAQ,CAAkB;IAClC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAe;IACxC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IACpC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAEhC,OAAO,CAAC,eAAe,CAAmC;IAC1D,OAAO,CAAC,OAAO,CAAQ;IACvB,OAAO,CAAC,YAAY,CAAK;IAEzB,OAAO,CAAC,QAAQ,CAAC,mBAAmB,CAA8B;IAElE,OAAO,CAAC,QAAQ,CAAC,YAAY,CAI3B;IAEF,OAAO,CAAC,QAAQ,CAAC,mBAAmB,CAYlC;IAEF,YAAY,OAAO,EAAE,iBAAiB,EAyDrC;IAED,+BAA+B;IAC/B,IAAI,KAAK,IAAI,MAAM,CAElB;IAED,qCAAqC;IACrC,IAAI,UAAU,IAAI,MAAM,CAEvB;IAED,yEAAyE;IACzE,IAAI,KAAK,IAAI,MAAM,GAAG,IAAI,CAEzB;IAEQ,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CA6B7C;IAEQ,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,CAMhC;IAEQ,OAAO,CAAC,OAAO,CAAC,EAAE,UAAU,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,IAAI,CAMjE;IAED,iFAAiF;IACjF,OAAO,CAAC,IAAI;IAOZ,OAAO,CAAC,OAAO;IAYf,OAAO,CAAC,aAAa;IAOrB,OAAO,CAAC,MAAM;CA4Bd"}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { Graphics } from 'pixi.js';
|
|
2
|
+
import { onComposition, onText } from '../../core/Input.js';
|
|
3
|
+
import { TextModel } from './TextModel.js';
|
|
4
|
+
import { Window } from './Window.js';
|
|
5
|
+
import { Label } from './Label.js';
|
|
6
|
+
import { theme, themeChanged } from './theme.js';
|
|
7
|
+
import { screenReader } from './a11y.js';
|
|
8
|
+
import { OnScreenKeyboard } from './OnScreenKeyboard.js';
|
|
9
|
+
/** seconds per caret blink half-cycle */
|
|
10
|
+
const BLINK_PERIOD = 0.5;
|
|
11
|
+
/**
|
|
12
|
+
* A modal single-line text field: a name entry, a save-slot label, a seed.
|
|
13
|
+
*
|
|
14
|
+
* Typing arrives through `core.Input`'s `onText`, which is the signal a free text field
|
|
15
|
+
* listens to, so this plays no part in key bindings for characters: whatever the
|
|
16
|
+
* platform turns a key press into lands in the field. Editing and movement arrive as
|
|
17
|
+
* actions instead, which a game binds the way it binds everything else: `left`/`right`
|
|
18
|
+
* move the caret, `backspace`/`delete` edit, `confirm` accepts subject to `validate`,
|
|
19
|
+
* `cancel` gives up. The prompt is modal and swallows each of those while open, so
|
|
20
|
+
* the game underneath never walks away mid-name.
|
|
21
|
+
*
|
|
22
|
+
* `onText` wakes the most recently added listener first, so when two prompts overlap
|
|
23
|
+
* the topmost one gets the keystrokes - the same order the window stack shows them in.
|
|
24
|
+
*
|
|
25
|
+
* An in-progress IME composition previews below the field and only becomes value on
|
|
26
|
+
* commit, so CJK, dead keys and emoji pickers type through the same path the DOM gives
|
|
27
|
+
* every other input rather than through a game-specific workaround.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* ```ts
|
|
31
|
+
* import { TextPrompt } from '@datamoc/mw_games/two-d/ui';
|
|
32
|
+
*
|
|
33
|
+
* const prompt = new TextPrompt({
|
|
34
|
+
* width: 320,
|
|
35
|
+
* height: 140,
|
|
36
|
+
* title: 'New hero',
|
|
37
|
+
* message: 'Name your hero (Enter keeps it)',
|
|
38
|
+
* maxLength: 12,
|
|
39
|
+
* validate: (value) => (value.trim() === '' ? 'Give them a name first.' : null),
|
|
40
|
+
* onConfirm: (value) => console.log('named', value),
|
|
41
|
+
* });
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
44
|
+
export class TextPrompt extends Window {
|
|
45
|
+
constructor(options) {
|
|
46
|
+
super({
|
|
47
|
+
width: options.width,
|
|
48
|
+
height: options.height,
|
|
49
|
+
title: options.title,
|
|
50
|
+
modal: true,
|
|
51
|
+
closable: false,
|
|
52
|
+
dims: options.dims,
|
|
53
|
+
blocker: options.blocker ?? true,
|
|
54
|
+
anchor: options.anchor ?? 'center',
|
|
55
|
+
});
|
|
56
|
+
this.preview = '';
|
|
57
|
+
this.promptError = null;
|
|
58
|
+
this.keyboard = null;
|
|
59
|
+
this.messageLabel = null;
|
|
60
|
+
this.caretBar = new Graphics();
|
|
61
|
+
this.measurer = new Label();
|
|
62
|
+
this.canMeasureCaret = typeof document !== 'undefined';
|
|
63
|
+
this.blinkOn = true;
|
|
64
|
+
this.blinkElapsed = 0;
|
|
65
|
+
this.promptThemeListener = () => this.restylePrompt();
|
|
66
|
+
this.textListener = (text) => {
|
|
67
|
+
if (this.closed)
|
|
68
|
+
return false;
|
|
69
|
+
this.edit(() => this.model.insert(text));
|
|
70
|
+
return true;
|
|
71
|
+
};
|
|
72
|
+
this.compositionListener = (input) => {
|
|
73
|
+
if (this.closed)
|
|
74
|
+
return false;
|
|
75
|
+
if (input.phase === 'end') {
|
|
76
|
+
this.edit(() => {
|
|
77
|
+
this.preview = '';
|
|
78
|
+
this.model.insert(input.text);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
this.preview = input.phase === 'start' ? '' : input.text;
|
|
83
|
+
this.render();
|
|
84
|
+
}
|
|
85
|
+
return true;
|
|
86
|
+
};
|
|
87
|
+
this.model = new TextModel({ value: options.initialValue ?? '', maxLength: options.maxLength });
|
|
88
|
+
this.validate = options.validate;
|
|
89
|
+
this.onConfirm = options.onConfirm;
|
|
90
|
+
this.onCancel = options.onCancel;
|
|
91
|
+
this.announce = options.announce ?? true;
|
|
92
|
+
this.announcer = options.announcer ?? screenReader;
|
|
93
|
+
const t = theme();
|
|
94
|
+
this.lineHeight = t.font.size * t.font.lineHeight;
|
|
95
|
+
const gap = t.spacing;
|
|
96
|
+
if (options.message !== undefined) {
|
|
97
|
+
this.messageLabel = new Label({ text: options.message });
|
|
98
|
+
this.content.addChild(this.messageLabel);
|
|
99
|
+
}
|
|
100
|
+
this.valueY = this.messageLabel ? this.lineHeight + gap : 0;
|
|
101
|
+
this.valueLabel = new Label();
|
|
102
|
+
this.valueLabel.y = this.valueY;
|
|
103
|
+
this.content.addChild(this.valueLabel);
|
|
104
|
+
this.previewLabel = new Label({ color: t.color.textDim });
|
|
105
|
+
this.previewLabel.y = this.valueY + this.lineHeight + gap;
|
|
106
|
+
this.content.addChild(this.previewLabel);
|
|
107
|
+
this.errorLabel = new Label({ color: t.color.textHighlight });
|
|
108
|
+
this.errorLabel.y = this.previewLabel.y + this.lineHeight + gap;
|
|
109
|
+
this.content.addChild(this.errorLabel);
|
|
110
|
+
this.content.addChild(this.caretBar);
|
|
111
|
+
//the touch half: rows of keys under the field, sized into the window
|
|
112
|
+
if (options.keyboard) {
|
|
113
|
+
this.keyboard = new OnScreenKeyboard(options.keyboard, { width: this.contentWidth });
|
|
114
|
+
this.keyboard.y = this.errorLabel.y + this.lineHeight + gap;
|
|
115
|
+
this.content.addChild(this.keyboard);
|
|
116
|
+
this.resize(options.width, options.height + this.keyboard.contentHeight + gap);
|
|
117
|
+
}
|
|
118
|
+
onText.add(this.textListener);
|
|
119
|
+
onComposition.add(this.compositionListener);
|
|
120
|
+
themeChanged.add(this.promptThemeListener);
|
|
121
|
+
this.render();
|
|
122
|
+
if (this.announce && options.message !== undefined)
|
|
123
|
+
this.announcer.announce(options.message);
|
|
124
|
+
}
|
|
125
|
+
/** the field's current text */
|
|
126
|
+
get value() {
|
|
127
|
+
return this.model.value;
|
|
128
|
+
}
|
|
129
|
+
/** the caret's index into `value` */
|
|
130
|
+
get caretIndex() {
|
|
131
|
+
return this.model.caret;
|
|
132
|
+
}
|
|
133
|
+
/** the last validation error, or null when the value currently passes */
|
|
134
|
+
get error() {
|
|
135
|
+
return this.promptError;
|
|
136
|
+
}
|
|
137
|
+
handleAction(action) {
|
|
138
|
+
if (this.delegate)
|
|
139
|
+
return super.handleAction(action);
|
|
140
|
+
if (this.closed)
|
|
141
|
+
return false;
|
|
142
|
+
switch (action) {
|
|
143
|
+
case 'left':
|
|
144
|
+
this.model.moveCaret(-1);
|
|
145
|
+
this.render();
|
|
146
|
+
return true;
|
|
147
|
+
case 'right':
|
|
148
|
+
this.model.moveCaret(1);
|
|
149
|
+
this.render();
|
|
150
|
+
return true;
|
|
151
|
+
case 'backspace':
|
|
152
|
+
this.edit(() => this.model.backspace());
|
|
153
|
+
return true;
|
|
154
|
+
case 'delete':
|
|
155
|
+
this.edit(() => this.model.deleteForward());
|
|
156
|
+
return true;
|
|
157
|
+
case 'confirm':
|
|
158
|
+
this.confirm();
|
|
159
|
+
return true;
|
|
160
|
+
case 'cancel':
|
|
161
|
+
this.onCancel?.();
|
|
162
|
+
this.close();
|
|
163
|
+
return true;
|
|
164
|
+
default:
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
update(dt) {
|
|
169
|
+
this.blinkElapsed += dt;
|
|
170
|
+
if (this.blinkElapsed < BLINK_PERIOD)
|
|
171
|
+
return;
|
|
172
|
+
this.blinkElapsed = 0;
|
|
173
|
+
this.blinkOn = !this.blinkOn;
|
|
174
|
+
this.caretBar.visible = this.blinkOn && this.canMeasureCaret;
|
|
175
|
+
}
|
|
176
|
+
destroy(options) {
|
|
177
|
+
onText.remove(this.textListener);
|
|
178
|
+
onComposition.remove(this.compositionListener);
|
|
179
|
+
themeChanged.remove(this.promptThemeListener);
|
|
180
|
+
this.measurer.destroy();
|
|
181
|
+
super.destroy(options);
|
|
182
|
+
}
|
|
183
|
+
/** runs an edit, dropping a stale validation error only when the text changed */
|
|
184
|
+
edit(run) {
|
|
185
|
+
const before = this.model.value;
|
|
186
|
+
run();
|
|
187
|
+
if (this.model.value !== before)
|
|
188
|
+
this.promptError = null;
|
|
189
|
+
this.render();
|
|
190
|
+
}
|
|
191
|
+
confirm() {
|
|
192
|
+
const problem = this.validate?.(this.model.value) ?? null;
|
|
193
|
+
if (problem) {
|
|
194
|
+
this.promptError = problem;
|
|
195
|
+
this.render();
|
|
196
|
+
if (this.announce)
|
|
197
|
+
this.announcer.announce(problem, { assertive: true });
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
this.onConfirm(this.model.value);
|
|
201
|
+
this.close();
|
|
202
|
+
}
|
|
203
|
+
restylePrompt() {
|
|
204
|
+
const t = theme();
|
|
205
|
+
this.previewLabel.setColor(t.color.textDim);
|
|
206
|
+
this.errorLabel.setColor(t.color.textHighlight);
|
|
207
|
+
this.render();
|
|
208
|
+
}
|
|
209
|
+
render() {
|
|
210
|
+
this.valueLabel.setText(this.model.value);
|
|
211
|
+
//the caret sits after the text before it, measured through a label that is never
|
|
212
|
+
//added to the scene: reading any width needs a canvas, which `node --test` has
|
|
213
|
+
//not got, so where there is no document the position is unknowable and the caret
|
|
214
|
+
//stays hidden rather than guessing
|
|
215
|
+
this.measurer.setText(this.model.value.slice(0, this.model.caret));
|
|
216
|
+
let caretX = 0;
|
|
217
|
+
if (this.canMeasureCaret) {
|
|
218
|
+
try {
|
|
219
|
+
caretX = this.measurer.width;
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
this.canMeasureCaret = false;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
this.caretBar
|
|
226
|
+
.clear()
|
|
227
|
+
.rect(this.valueLabel.x + caretX, this.valueY, 2, this.lineHeight)
|
|
228
|
+
.fill({ color: theme().color.text });
|
|
229
|
+
this.caretBar.visible = this.blinkOn && this.canMeasureCaret;
|
|
230
|
+
this.previewLabel.setText(this.preview);
|
|
231
|
+
this.previewLabel.visible = this.preview.length > 0;
|
|
232
|
+
this.errorLabel.setText(this.promptError ?? '');
|
|
233
|
+
this.errorLabel.visible = this.promptError !== null;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
//# sourceMappingURL=TextPrompt.js.map
|