@driftengine/ui2d 3.63.0 → 4.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.
- package/NOTICE +20 -0
- package/README.md +1 -1
- package/dist/a11y.d.ts +25 -0
- package/dist/a11y.js +48 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +19 -0
- package/dist/richText.d.ts +25 -0
- package/dist/richText.js +49 -0
- package/dist/textHost.d.ts +41 -0
- package/dist/textHost.js +44 -0
- package/dist/textModel.d.ts +45 -0
- package/dist/textModel.js +84 -0
- package/dist/theme.d.ts +30 -0
- package/dist/theme.js +36 -0
- package/dist/uiClip.d.ts +28 -0
- package/dist/uiClip.js +46 -0
- package/dist/uiDirty.d.ts +47 -0
- package/dist/uiDirty.js +116 -0
- package/dist/uiLayer.d.ts +18 -0
- package/dist/uiLayer.js +30 -0
- package/dist/uiLayout.js +29 -2
- package/dist/uiNode.d.ts +29 -0
- package/dist/uiNode.js +6 -0
- package/dist/uiPointer.d.ts +47 -0
- package/dist/uiPointer.js +50 -0
- package/dist/uiScroll.d.ts +38 -0
- package/dist/uiScroll.js +65 -0
- package/dist/uiSlice.d.ts +25 -0
- package/dist/uiSlice.js +51 -0
- package/dist/virtualList.d.ts +24 -0
- package/dist/virtualList.js +49 -0
- package/package.json +2 -2
- package/src/a11y.ts +73 -0
- package/src/index.ts +54 -0
- package/src/richText.ts +69 -0
- package/src/textHost.ts +66 -0
- package/src/textModel.ts +126 -0
- package/src/theme.ts +57 -0
- package/src/uiClip.ts +69 -0
- package/src/uiDirty.ts +164 -0
- package/src/uiLayer.ts +47 -0
- package/src/uiLayout.ts +30 -2
- package/src/uiNode.ts +35 -0
- package/src/uiPointer.ts +84 -0
- package/src/uiScroll.ts +90 -0
- package/src/uiSlice.ts +90 -0
- package/src/virtualList.ts +74 -0
package/src/a11y.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The interface tree in the shape assistive technology needs, and the seam that publishes it.
|
|
3
|
+
*
|
|
4
|
+
* **A seam, because whether anything can receive this is the host's business.** A browser host can
|
|
5
|
+
* mirror the tree into a hidden structure that a screen reader walks. A native host may have a
|
|
6
|
+
* platform accessibility service, or may have nothing — and `createNullA11yHost` is the honest
|
|
7
|
+
* answer in that case rather than an absence nobody noticed.
|
|
8
|
+
*/
|
|
9
|
+
import type { UiNode } from './uiNode.ts';
|
|
10
|
+
|
|
11
|
+
export interface A11yNode {
|
|
12
|
+
role: string;
|
|
13
|
+
label: string;
|
|
14
|
+
x: number;
|
|
15
|
+
y: number;
|
|
16
|
+
w: number;
|
|
17
|
+
h: number;
|
|
18
|
+
focused: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface A11yHost {
|
|
22
|
+
publish(nodes: readonly A11yNode[], count: number): void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A host that accepts a tree and does nothing with it. See the header. */
|
|
26
|
+
export function createNullA11yHost(): A11yHost {
|
|
27
|
+
return { publish() {} };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* What role this node plays, derived rather than declared.
|
|
32
|
+
*
|
|
33
|
+
* Derived, because a role a caller has to set is a role most callers will not set, and an
|
|
34
|
+
* accessibility tree that is mostly unlabelled is worse than none: it reads as though the interface
|
|
35
|
+
* has been described when it has not.
|
|
36
|
+
*/
|
|
37
|
+
function roleOf(node: UiNode): string {
|
|
38
|
+
if (node.focusable && node.children.length === 0) return 'button';
|
|
39
|
+
if (node.clip && (node.contentSpanX > node.rect.w || node.contentSpanY > node.rect.h)) {
|
|
40
|
+
return 'scrollable';
|
|
41
|
+
}
|
|
42
|
+
return 'text';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function contributes(node: UiNode): boolean {
|
|
46
|
+
return node.interactive || node.focusable || node.text.length > 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function gather(node: UiNode, out: A11yNode[], at: number): number {
|
|
50
|
+
if (node.hidden) return at;
|
|
51
|
+
let next = at;
|
|
52
|
+
if (contributes(node)) {
|
|
53
|
+
const slot = out[next] ?? { role: '', label: '', x: 0, y: 0, w: 0, h: 0, focused: false };
|
|
54
|
+
slot.role = roleOf(node);
|
|
55
|
+
slot.label = node.text;
|
|
56
|
+
slot.x = node.rect.x;
|
|
57
|
+
slot.y = node.rect.y;
|
|
58
|
+
slot.w = node.rect.w;
|
|
59
|
+
slot.h = node.rect.h;
|
|
60
|
+
slot.focused = false;
|
|
61
|
+
out[next] = slot;
|
|
62
|
+
next += 1;
|
|
63
|
+
}
|
|
64
|
+
for (const child of node.children) next = gather(child, out, next);
|
|
65
|
+
return next;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Fill `out` with every node worth describing, in tree order. Returns how many. */
|
|
69
|
+
export function a11yTree(root: UiNode, out: A11yNode[]): number {
|
|
70
|
+
const count = gather(root, out, 0);
|
|
71
|
+
out.length = count;
|
|
72
|
+
return count;
|
|
73
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -53,3 +53,57 @@ export { uiFocusNext, uiFocusOrder, uiFocusPrevious, uiHitTest } from './uiFocus
|
|
|
53
53
|
|
|
54
54
|
export { createUiInput, resetUiInput, routeUiKey, routeUiPointer, setUiFocus } from './uiInput.ts';
|
|
55
55
|
export type { UiInput } from './uiInput.ts';
|
|
56
|
+
|
|
57
|
+
/*
|
|
58
|
+
* The interface framework: what an editor needs from a 2D layer and a game's interface wants too.
|
|
59
|
+
*
|
|
60
|
+
* Every platform touch below is a capability the caller supplies — `TextHost` for composition and
|
|
61
|
+
* the clipboard, `A11yHost` for assistive technology — because this has to run under a host with
|
|
62
|
+
* no document object model. That is the same rule `AGENTS.md` already applies to persistence.
|
|
63
|
+
*/
|
|
64
|
+
export { clipRectFor, intersectClip } from './uiClip.ts';
|
|
65
|
+
export { clampScroll, routeScrollWheel, scrollBy, scrollExtent } from './uiScroll.ts';
|
|
66
|
+
export { layerOrder } from './uiLayer.ts';
|
|
67
|
+
export { SLICE_STRIDE, sliceInto } from './uiSlice.ts';
|
|
68
|
+
export type { SliceInset } from './uiSlice.ts';
|
|
69
|
+
export {
|
|
70
|
+
createTextModel,
|
|
71
|
+
deleteBackward,
|
|
72
|
+
deleteForward,
|
|
73
|
+
insertText,
|
|
74
|
+
moveCaret,
|
|
75
|
+
moveToLineEdge,
|
|
76
|
+
selectAll,
|
|
77
|
+
selectedText,
|
|
78
|
+
selectionRange,
|
|
79
|
+
} from './textModel.ts';
|
|
80
|
+
export type { TextModel } from './textModel.ts';
|
|
81
|
+
export { applyComposition, createNullTextHost } from './textHost.ts';
|
|
82
|
+
export type { TextHost } from './textHost.ts';
|
|
83
|
+
export { runsFor } from './richText.ts';
|
|
84
|
+
export type { TextRun } from './richText.ts';
|
|
85
|
+
export { visibleRange, visibleRangeVariable } from './virtualList.ts';
|
|
86
|
+
export type { RowWindow } from './virtualList.ts';
|
|
87
|
+
export {
|
|
88
|
+
createTheme,
|
|
89
|
+
deriveTheme,
|
|
90
|
+
themeColour,
|
|
91
|
+
themeRgba,
|
|
92
|
+
themeSize,
|
|
93
|
+
unpackRgba,
|
|
94
|
+
} from './theme.ts';
|
|
95
|
+
export type { Theme } from './theme.ts';
|
|
96
|
+
export { clearDirty, createDirtyTracker, diffTree, dirtyBounds, markDirty } from './uiDirty.ts';
|
|
97
|
+
export type { DirtyTracker } from './uiDirty.ts';
|
|
98
|
+
export {
|
|
99
|
+
beginDrag,
|
|
100
|
+
capturePointer,
|
|
101
|
+
createPointerState,
|
|
102
|
+
dropTarget,
|
|
103
|
+
endDrag,
|
|
104
|
+
pointerTarget,
|
|
105
|
+
releasePointer,
|
|
106
|
+
} from './uiPointer.ts';
|
|
107
|
+
export type { PointerState } from './uiPointer.ts';
|
|
108
|
+
export { a11yTree, createNullA11yHost } from './a11y.ts';
|
|
109
|
+
export type { A11yHost, A11yNode } from './a11y.ts';
|
package/src/richText.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Overlapping style runs, normalised into disjoint ones that cover every character once.
|
|
3
|
+
*
|
|
4
|
+
* **The normalisation is the whole job.** A syntax highlighter emits overlapping runs — a rule for
|
|
5
|
+
* the string, a more specific one for the escape inside it — and a renderer needs disjoint runs in
|
|
6
|
+
* order with no gaps. Every consumer that skips this step draws a character twice or not at all,
|
|
7
|
+
* and both look like a font problem.
|
|
8
|
+
*
|
|
9
|
+
* **Later wins the overlap**, because a later rule is the more specific one in every highlighter
|
|
10
|
+
* that emits them in that order.
|
|
11
|
+
*/
|
|
12
|
+
export interface TextRun {
|
|
13
|
+
from: number;
|
|
14
|
+
to: number;
|
|
15
|
+
colour: number;
|
|
16
|
+
bold: boolean;
|
|
17
|
+
italic: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/* Reused across calls: this runs per frame for a visible document. */
|
|
21
|
+
const BOUNDS: number[] = [];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Fill `out` with disjoint, ordered, gap-free runs covering `[0, text.length)`.
|
|
25
|
+
*
|
|
26
|
+
* Returns how many were written. With no input runs the answer is one run covering everything,
|
|
27
|
+
* which is what a plain string is.
|
|
28
|
+
*/
|
|
29
|
+
export function runsFor(text: string, runs: readonly TextRun[], out: TextRun[]): number {
|
|
30
|
+
const end = text.length;
|
|
31
|
+
if (end === 0) return 0;
|
|
32
|
+
|
|
33
|
+
BOUNDS.length = 0;
|
|
34
|
+
BOUNDS.push(0, end);
|
|
35
|
+
for (const run of runs) {
|
|
36
|
+
const from = Math.max(0, Math.min(end, run.from));
|
|
37
|
+
const to = Math.max(0, Math.min(end, run.to));
|
|
38
|
+
if (to > from) BOUNDS.push(from, to);
|
|
39
|
+
}
|
|
40
|
+
BOUNDS.sort((a, b) => a - b);
|
|
41
|
+
|
|
42
|
+
let count = 0;
|
|
43
|
+
let previous = -1;
|
|
44
|
+
for (let i = 0; i < BOUNDS.length - 1; i += 1) {
|
|
45
|
+
const from = BOUNDS[i] as number;
|
|
46
|
+
const to = BOUNDS[i + 1] as number;
|
|
47
|
+
if (from === to || from === previous) continue;
|
|
48
|
+
previous = from;
|
|
49
|
+
|
|
50
|
+
/* Last covering run wins; none covering means the default, which is what index -1 encodes. */
|
|
51
|
+
let chosen = -1;
|
|
52
|
+
for (let r = 0; r < runs.length; r += 1) {
|
|
53
|
+
const run = runs[r] as TextRun;
|
|
54
|
+
if (run.from <= from && run.to >= to) chosen = r;
|
|
55
|
+
}
|
|
56
|
+
const style = chosen === -1 ? null : (runs[chosen] as TextRun);
|
|
57
|
+
|
|
58
|
+
const slot = out[count] ?? { from: 0, to: 0, colour: 0xffffffff, bold: false, italic: false };
|
|
59
|
+
slot.from = from;
|
|
60
|
+
slot.to = to;
|
|
61
|
+
slot.colour = style?.colour ?? 0xffffffff;
|
|
62
|
+
slot.bold = style?.bold ?? false;
|
|
63
|
+
slot.italic = style?.italic ?? false;
|
|
64
|
+
out[count] = slot;
|
|
65
|
+
count += 1;
|
|
66
|
+
}
|
|
67
|
+
out.length = count;
|
|
68
|
+
return count;
|
|
69
|
+
}
|
package/src/textHost.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The platform services text entry needs, taken as a parameter.
|
|
3
|
+
*
|
|
4
|
+
* **Composition — the multi-keystroke entry every language that is not typed one character at a
|
|
5
|
+
* time depends on — is a platform service.** The browser has one, a native host has a different
|
|
6
|
+
* one, and a test has none. `AGENTS.md` requires a capability be taken as a parameter with a
|
|
7
|
+
* browser implementation shipped as the default; this is that rule applied to text, and it is why
|
|
8
|
+
* this package can run under a host with no document object model at all.
|
|
9
|
+
*/
|
|
10
|
+
import type { TextModel } from './textModel.ts';
|
|
11
|
+
import { selectionRange } from './textModel.ts';
|
|
12
|
+
|
|
13
|
+
export interface TextHost {
|
|
14
|
+
/** Tell the host a field is focused at this rectangle, so it can place a composition window. */
|
|
15
|
+
focusField(x: number, y: number, w: number, h: number): void;
|
|
16
|
+
/** Tell the host nothing is focused. */
|
|
17
|
+
blurField(): void;
|
|
18
|
+
/** Read the clipboard, if the host has one. Asynchronous everywhere one exists. */
|
|
19
|
+
readClipboard(): Promise<string>;
|
|
20
|
+
/** Write the clipboard, if the host has one. */
|
|
21
|
+
writeClipboard(text: string): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A host that focuses nothing and whose clipboard is empty.
|
|
26
|
+
*
|
|
27
|
+
* What a test uses, and what a headless build uses. **An empty clipboard rather than a missing
|
|
28
|
+
* one**, so a caller never has to branch on whether it has a host.
|
|
29
|
+
*/
|
|
30
|
+
export function createNullTextHost(): TextHost {
|
|
31
|
+
return {
|
|
32
|
+
focusField() {},
|
|
33
|
+
blurField() {},
|
|
34
|
+
readClipboard: async () => '',
|
|
35
|
+
writeClipboard: async () => {},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Fold one composition update into the model.
|
|
41
|
+
*
|
|
42
|
+
* An update replaces whatever the previous update of this composition put there, rather than the
|
|
43
|
+
* whole field — which is what makes a five-keystroke entry produce one word instead of five.
|
|
44
|
+
*
|
|
45
|
+
* **`done` clears the range, and that is the step everyone forgets.** A finished composition that
|
|
46
|
+
* leaves its range set means the *next* keystroke overwrites the text that was just committed,
|
|
47
|
+
* which presents as characters vanishing one word behind the caret.
|
|
48
|
+
*/
|
|
49
|
+
export function applyComposition(model: TextModel, state: { text: string; done: boolean }): void {
|
|
50
|
+
if (model.composeFrom === -1) {
|
|
51
|
+
const { from, to } = selectionRange(model);
|
|
52
|
+
model.text = model.text.slice(0, from) + state.text + model.text.slice(to);
|
|
53
|
+
model.composeFrom = from;
|
|
54
|
+
model.composeTo = from + state.text.length;
|
|
55
|
+
} else {
|
|
56
|
+
model.text =
|
|
57
|
+
model.text.slice(0, model.composeFrom) + state.text + model.text.slice(model.composeTo);
|
|
58
|
+
model.composeTo = model.composeFrom + state.text.length;
|
|
59
|
+
}
|
|
60
|
+
model.caret = model.composeTo;
|
|
61
|
+
model.anchor = model.caret;
|
|
62
|
+
if (state.done) {
|
|
63
|
+
model.composeFrom = -1;
|
|
64
|
+
model.composeTo = -1;
|
|
65
|
+
}
|
|
66
|
+
}
|
package/src/textModel.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A caret, a selection, and the operations that move them.
|
|
3
|
+
*
|
|
4
|
+
* **Pure, and it is pure on purpose.** Every editing defect anybody has met lives here rather than
|
|
5
|
+
* in the platform: a caret inside a character, a right-to-left selection that deletes the wrong
|
|
6
|
+
* end, a backspace that removes half an emoji. Pure, each of those is a test that runs in under a
|
|
7
|
+
* millisecond. Wired to a host, none of them is.
|
|
8
|
+
*
|
|
9
|
+
* **Offsets are into the string and are always on a code-point boundary.** JavaScript strings are
|
|
10
|
+
* UTF-16, so an astral character is two units and a caret between them is a caret inside a
|
|
11
|
+
* character — which renders as a replacement glyph and deletes as a lone surrogate. Every move and
|
|
12
|
+
* every delete steps over the pair.
|
|
13
|
+
*
|
|
14
|
+
* `anchor` is where a selection started and `caret` is where it is now, so `anchor > caret` is an
|
|
15
|
+
* ordinary right-to-left selection rather than an error. `selectionRange` is the only thing that
|
|
16
|
+
* orders them and everything else goes through it.
|
|
17
|
+
*/
|
|
18
|
+
export interface TextModel {
|
|
19
|
+
text: string;
|
|
20
|
+
/** Where the caret is, as a UTF-16 offset on a code-point boundary. */
|
|
21
|
+
caret: number;
|
|
22
|
+
/** Where the current selection began. Equal to `caret` when nothing is selected. */
|
|
23
|
+
anchor: number;
|
|
24
|
+
/**
|
|
25
|
+
* The range an in-progress composition occupies, or -1 when none is in progress.
|
|
26
|
+
*
|
|
27
|
+
* Written only by `applyComposition` in `textHost.ts`. It lives on the model rather than in the
|
|
28
|
+
* host because the range is a property of the text, and a host that crashed mid-composition
|
|
29
|
+
* would otherwise leave the field permanently convinced it was still composing.
|
|
30
|
+
*/
|
|
31
|
+
composeFrom: number;
|
|
32
|
+
composeTo: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function createTextModel(text = ''): TextModel {
|
|
36
|
+
return { text, caret: text.length, anchor: text.length, composeFrom: -1, composeTo: -1 };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function selectionRange(model: TextModel): { from: number; to: number } {
|
|
40
|
+
return model.anchor <= model.caret
|
|
41
|
+
? { from: model.anchor, to: model.caret }
|
|
42
|
+
: { from: model.caret, to: model.anchor };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function selectedText(model: TextModel): string {
|
|
46
|
+
const { from, to } = selectionRange(model);
|
|
47
|
+
return model.text.slice(from, to);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Whether the unit at `at` is the low half of a surrogate pair, so `at` is mid-character. */
|
|
51
|
+
function insidePair(text: string, at: number): boolean {
|
|
52
|
+
if (at <= 0 || at >= text.length) return false;
|
|
53
|
+
const low = text.charCodeAt(at);
|
|
54
|
+
const high = text.charCodeAt(at - 1);
|
|
55
|
+
return low >= 0xdc00 && low <= 0xdfff && high >= 0xd800 && high <= 0xdbff;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function stepRight(text: string, at: number): number {
|
|
59
|
+
const next = Math.min(text.length, at + 1);
|
|
60
|
+
return insidePair(text, next) ? Math.min(text.length, next + 1) : next;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function stepLeft(text: string, at: number): number {
|
|
64
|
+
const next = Math.max(0, at - 1);
|
|
65
|
+
return insidePair(text, next) ? Math.max(0, next - 1) : next;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function replaceSelection(model: TextModel, s: string): void {
|
|
69
|
+
const { from, to } = selectionRange(model);
|
|
70
|
+
model.text = model.text.slice(0, from) + s + model.text.slice(to);
|
|
71
|
+
model.caret = from + s.length;
|
|
72
|
+
model.anchor = model.caret;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function insertText(model: TextModel, s: string): void {
|
|
76
|
+
replaceSelection(model, s);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function deleteBackward(model: TextModel): void {
|
|
80
|
+
if (model.anchor !== model.caret) {
|
|
81
|
+
replaceSelection(model, '');
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const from = stepLeft(model.text, model.caret);
|
|
85
|
+
model.text = model.text.slice(0, from) + model.text.slice(model.caret);
|
|
86
|
+
model.caret = from;
|
|
87
|
+
model.anchor = from;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function deleteForward(model: TextModel): void {
|
|
91
|
+
if (model.anchor !== model.caret) {
|
|
92
|
+
replaceSelection(model, '');
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const to = stepRight(model.text, model.caret);
|
|
96
|
+
model.text = model.text.slice(0, model.caret) + model.text.slice(to);
|
|
97
|
+
model.anchor = model.caret;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function moveCaret(model: TextModel, delta: number, select: boolean): void {
|
|
101
|
+
let at = model.caret;
|
|
102
|
+
const steps = Math.abs(delta);
|
|
103
|
+
for (let i = 0; i < steps; i += 1) {
|
|
104
|
+
at = delta > 0 ? stepRight(model.text, at) : stepLeft(model.text, at);
|
|
105
|
+
}
|
|
106
|
+
model.caret = at;
|
|
107
|
+
if (!select) model.anchor = at;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function moveToLineEdge(model: TextModel, forward: boolean, select: boolean): void {
|
|
111
|
+
let at: number;
|
|
112
|
+
if (forward) {
|
|
113
|
+
const found = model.text.indexOf('\n', model.caret);
|
|
114
|
+
at = found === -1 ? model.text.length : found;
|
|
115
|
+
} else {
|
|
116
|
+
const found = model.text.lastIndexOf('\n', Math.max(0, model.caret - 1));
|
|
117
|
+
at = found === -1 ? 0 : found + 1;
|
|
118
|
+
}
|
|
119
|
+
model.caret = at;
|
|
120
|
+
if (!select) model.anchor = at;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function selectAll(model: TextModel): void {
|
|
124
|
+
model.anchor = 0;
|
|
125
|
+
model.caret = model.text.length;
|
|
126
|
+
}
|
package/src/theme.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Named colours and sizes, so an interface stops repeating its own constants.
|
|
3
|
+
*
|
|
4
|
+
* **Flattened at derivation rather than chained at lookup.** A token is read per node per frame, so
|
|
5
|
+
* a chain of parent themes would be a walk on every one of those reads. Spreading once when a
|
|
6
|
+
* theme is derived costs a record and makes every lookup a single property access.
|
|
7
|
+
*/
|
|
8
|
+
export interface Theme {
|
|
9
|
+
readonly values: Readonly<Record<string, number>>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function createTheme(values: Record<string, number>): Theme {
|
|
13
|
+
return { values: { ...values } };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A new theme with `overrides` applied over `base`. `base` is not modified. */
|
|
17
|
+
export function deriveTheme(base: Theme, overrides: Record<string, number>): Theme {
|
|
18
|
+
return { values: { ...base.values, ...overrides } };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function themeColour(theme: Theme, name: string, fallback: number): number {
|
|
22
|
+
return theme.values[name] ?? fallback;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function themeSize(theme: Theme, name: string, fallback: number): number {
|
|
26
|
+
return theme.values[name] ?? fallback;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A packed `0xRRGGBBAA` token as the four floats a node's `background` and `tint` are.
|
|
31
|
+
*
|
|
32
|
+
* **Two of this package's own APIs did not meet**: a theme token is one number and a `UiNode`
|
|
33
|
+
* colour is a `Float32Array`, so every consumer wrote the same four shifts. The editor found it by
|
|
34
|
+
* being the first thing to build widgets on both, which is what a consumer outside `packages/` is
|
|
35
|
+
* for.
|
|
36
|
+
*
|
|
37
|
+
* `>>>` rather than `>>`, because a token with red at or above `0x80` has the sign bit set and an
|
|
38
|
+
* arithmetic shift returns it negative — a red that turns the whole colour into a large negative
|
|
39
|
+
* number, which is exactly the shape of bug that survives review.
|
|
40
|
+
*/
|
|
41
|
+
export function unpackRgba(packed: number, out: Float32Array): Float32Array {
|
|
42
|
+
out[0] = ((packed >>> 24) & 0xff) / 255;
|
|
43
|
+
out[1] = ((packed >>> 16) & 0xff) / 255;
|
|
44
|
+
out[2] = ((packed >>> 8) & 0xff) / 255;
|
|
45
|
+
out[3] = (packed & 0xff) / 255;
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** `themeColour` and `unpackRgba` in one call, which is how a widget uses both. */
|
|
50
|
+
export function themeRgba(
|
|
51
|
+
theme: Theme,
|
|
52
|
+
name: string,
|
|
53
|
+
fallback: number,
|
|
54
|
+
out: Float32Array,
|
|
55
|
+
): Float32Array {
|
|
56
|
+
return unpackRgba(themeColour(theme, name, fallback), out);
|
|
57
|
+
}
|
package/src/uiClip.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How far a node may draw, given the clipping ancestors above it.
|
|
3
|
+
*
|
|
4
|
+
* **A rectangle rather than a stack of them.** Successive clips intersect, and the intersection of
|
|
5
|
+
* any number of rectangles is one rectangle, so the whole history above a node collapses into four
|
|
6
|
+
* numbers. That is what makes this cheap enough to compute per node per frame rather than
|
|
7
|
+
* maintained as state that can go stale.
|
|
8
|
+
*
|
|
9
|
+
* **An empty result is reported, not encoded.** A node clipped entirely away would otherwise
|
|
10
|
+
* produce a negative width, which every consumer would then have to remember to check; instead
|
|
11
|
+
* `clipRectFor` returns false and the caller skips the subtree. The rectangle it leaves behind is
|
|
12
|
+
* degenerate but never negative, because a negative size reaching a scissor call is a validation
|
|
13
|
+
* error on one backend and a silent full-screen draw on the other.
|
|
14
|
+
*
|
|
15
|
+
* **A clipping node does not clip itself.** It bounds its descendants; cutting it to its own
|
|
16
|
+
* rectangle is a no-op in the ordinary case and wrong in the case where a caller has given it a
|
|
17
|
+
* border that draws outside its content box.
|
|
18
|
+
*/
|
|
19
|
+
import type { UiNode, UiRect } from './uiNode.ts';
|
|
20
|
+
|
|
21
|
+
/** Write the overlap of two rectangles into `out`. Never negative; see the header. */
|
|
22
|
+
export function intersectClip(out: UiRect, parent: UiRect, child: UiRect): void {
|
|
23
|
+
const left = Math.max(parent.x, child.x);
|
|
24
|
+
const top = Math.max(parent.y, child.y);
|
|
25
|
+
const right = Math.min(parent.x + parent.w, child.x + child.w);
|
|
26
|
+
const bottom = Math.min(parent.y + parent.h, child.y + child.h);
|
|
27
|
+
out.x = left;
|
|
28
|
+
out.y = top;
|
|
29
|
+
out.w = Math.max(0, right - left);
|
|
30
|
+
out.h = Math.max(0, bottom - top);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/* Reused across calls: this runs per node per frame and the chain is its only allocation. */
|
|
34
|
+
const CHAIN: UiNode[] = [];
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The rectangle `node` may draw into, cut by every clipping ancestor between it and `root`.
|
|
38
|
+
*
|
|
39
|
+
* Returns false when nothing of it survives, which is the caller's signal to skip the subtree
|
|
40
|
+
* rather than issue a draw that covers no pixels.
|
|
41
|
+
*/
|
|
42
|
+
export function clipRectFor(root: UiNode, node: UiNode, out: UiRect): boolean {
|
|
43
|
+
out.x = node.rect.x;
|
|
44
|
+
out.y = node.rect.y;
|
|
45
|
+
out.w = node.rect.w;
|
|
46
|
+
out.h = node.rect.h;
|
|
47
|
+
|
|
48
|
+
/*
|
|
49
|
+
* Collected on the way up and applied on the way down, because the intersection has to run
|
|
50
|
+
* outermost-first: an inner clip can only narrow an outer one, so the wrong order happens to be
|
|
51
|
+
* right for nested rectangles and is wrong for offset ones.
|
|
52
|
+
*/
|
|
53
|
+
let depth = 0;
|
|
54
|
+
let walk: UiNode | null = node.parent;
|
|
55
|
+
while (walk !== null) {
|
|
56
|
+
CHAIN[depth] = walk;
|
|
57
|
+
depth += 1;
|
|
58
|
+
if (walk === root) break;
|
|
59
|
+
walk = walk.parent;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
for (let i = depth - 1; i >= 0; i -= 1) {
|
|
63
|
+
const ancestor = CHAIN[i] as UiNode;
|
|
64
|
+
if (!ancestor.clip) continue;
|
|
65
|
+
intersectClip(out, ancestor.rect, out);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return out.w > 0 && out.h > 0;
|
|
69
|
+
}
|
package/src/uiDirty.ts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What changed since last frame, and therefore what has to be redrawn.
|
|
3
|
+
*
|
|
4
|
+
* An editor is idle most of the time. A framework that redraws the whole window every frame
|
|
5
|
+
* because a caret is blinking keeps a laptop fan running, which is the difference between a tool
|
|
6
|
+
* somebody uses all day and one they close.
|
|
7
|
+
*
|
|
8
|
+
* **A node that became hidden marks where it used to be.** That is the bug every dirty-rectangle
|
|
9
|
+
* implementation ships with first: the thing that disappeared leaves its pixels behind, because
|
|
10
|
+
* nothing thought to dirty a rectangle belonging to a node that is no longer drawn.
|
|
11
|
+
*
|
|
12
|
+
* **Keyed by traversal index, with the node identity checked.** A tree that changed shape makes
|
|
13
|
+
* every index past the change compare against a different node, which reports dirty — conservative
|
|
14
|
+
* and correct. A map keyed by node would be exact and would allocate on the frame path.
|
|
15
|
+
*
|
|
16
|
+
* **One union rather than a list of rectangles.** A union over-draws and is right; a list needs
|
|
17
|
+
* merging logic that is itself a source of defects, and an editor's redraws are overwhelmingly one
|
|
18
|
+
* panel anyway.
|
|
19
|
+
*/
|
|
20
|
+
import type { UiNode, UiRect } from './uiNode.ts';
|
|
21
|
+
|
|
22
|
+
export interface DirtyTracker {
|
|
23
|
+
/** The node seen at each traversal index last frame. */
|
|
24
|
+
nodes: (UiNode | null)[];
|
|
25
|
+
/** Six numbers per index: x, y, w, h, and the visual state packed into two. */
|
|
26
|
+
state: Float64Array;
|
|
27
|
+
/** How many indices the previous walk filled. */
|
|
28
|
+
count: number;
|
|
29
|
+
/** The union of everything marked this frame, and whether anything was. */
|
|
30
|
+
x: number;
|
|
31
|
+
y: number;
|
|
32
|
+
w: number;
|
|
33
|
+
h: number;
|
|
34
|
+
any: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const STRIDE = 6;
|
|
38
|
+
|
|
39
|
+
export function createDirtyTracker(capacity: number): DirtyTracker {
|
|
40
|
+
return {
|
|
41
|
+
nodes: Array.from({ length: capacity }, () => null),
|
|
42
|
+
state: new Float64Array(capacity * STRIDE),
|
|
43
|
+
count: 0,
|
|
44
|
+
x: 0,
|
|
45
|
+
y: 0,
|
|
46
|
+
w: 0,
|
|
47
|
+
h: 0,
|
|
48
|
+
any: false,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function clearDirty(tracker: DirtyTracker): void {
|
|
53
|
+
tracker.any = false;
|
|
54
|
+
tracker.x = 0;
|
|
55
|
+
tracker.y = 0;
|
|
56
|
+
tracker.w = 0;
|
|
57
|
+
tracker.h = 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Union `rect` into what must be redrawn. A zero-area rectangle still counts as a change. */
|
|
61
|
+
export function markDirty(tracker: DirtyTracker, rect: UiRect): void {
|
|
62
|
+
if (!tracker.any) {
|
|
63
|
+
tracker.x = rect.x;
|
|
64
|
+
tracker.y = rect.y;
|
|
65
|
+
tracker.w = rect.w;
|
|
66
|
+
tracker.h = rect.h;
|
|
67
|
+
tracker.any = true;
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const left = Math.min(tracker.x, rect.x);
|
|
71
|
+
const top = Math.min(tracker.y, rect.y);
|
|
72
|
+
const right = Math.max(tracker.x + tracker.w, rect.x + rect.w);
|
|
73
|
+
const bottom = Math.max(tracker.y + tracker.h, rect.y + rect.h);
|
|
74
|
+
tracker.x = left;
|
|
75
|
+
tracker.y = top;
|
|
76
|
+
tracker.w = right - left;
|
|
77
|
+
tracker.h = bottom - top;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The union of everything marked. Returns false when nothing was. */
|
|
81
|
+
export function dirtyBounds(tracker: DirtyTracker, out: UiRect): boolean {
|
|
82
|
+
out.x = tracker.x;
|
|
83
|
+
out.y = tracker.y;
|
|
84
|
+
out.w = tracker.w;
|
|
85
|
+
out.h = tracker.h;
|
|
86
|
+
return tracker.any;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Visual state that changes pixels, packed so one comparison covers all of it. */
|
|
90
|
+
function visualOf(node: UiNode): number {
|
|
91
|
+
return (
|
|
92
|
+
(node.hidden ? 1 : 0) |
|
|
93
|
+
(node.hovered ? 2 : 0) |
|
|
94
|
+
(node.pressed ? 4 : 0) |
|
|
95
|
+
((node.texture + 1) * 8)
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const SCRATCH: UiRect = { x: 0, y: 0, w: 0, h: 0 };
|
|
100
|
+
|
|
101
|
+
function visit(tracker: DirtyTracker, node: UiNode, at: number): number {
|
|
102
|
+
const base = at * STRIDE;
|
|
103
|
+
const previous = tracker.nodes[at] ?? null;
|
|
104
|
+
const visual = visualOf(node);
|
|
105
|
+
const textHash = node.text.length;
|
|
106
|
+
|
|
107
|
+
const moved =
|
|
108
|
+
previous !== node ||
|
|
109
|
+
tracker.state[base] !== node.rect.x ||
|
|
110
|
+
tracker.state[base + 1] !== node.rect.y ||
|
|
111
|
+
tracker.state[base + 2] !== node.rect.w ||
|
|
112
|
+
tracker.state[base + 3] !== node.rect.h ||
|
|
113
|
+
tracker.state[base + 4] !== visual ||
|
|
114
|
+
tracker.state[base + 5] !== textHash;
|
|
115
|
+
|
|
116
|
+
if (moved) {
|
|
117
|
+
/* Where it was, so a node that moved or vanished does not leave its pixels behind. */
|
|
118
|
+
if (previous !== null) {
|
|
119
|
+
SCRATCH.x = tracker.state[base] ?? 0;
|
|
120
|
+
SCRATCH.y = tracker.state[base + 1] ?? 0;
|
|
121
|
+
SCRATCH.w = tracker.state[base + 2] ?? 0;
|
|
122
|
+
SCRATCH.h = tracker.state[base + 3] ?? 0;
|
|
123
|
+
markDirty(tracker, SCRATCH);
|
|
124
|
+
}
|
|
125
|
+
if (!node.hidden) markDirty(tracker, node.rect);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
tracker.nodes[at] = node;
|
|
129
|
+
tracker.state[base] = node.rect.x;
|
|
130
|
+
tracker.state[base + 1] = node.rect.y;
|
|
131
|
+
tracker.state[base + 2] = node.rect.w;
|
|
132
|
+
tracker.state[base + 3] = node.rect.h;
|
|
133
|
+
tracker.state[base + 4] = visual;
|
|
134
|
+
tracker.state[base + 5] = textHash;
|
|
135
|
+
|
|
136
|
+
let next = at + 1;
|
|
137
|
+
for (const child of node.children) next = visit(tracker, child, next);
|
|
138
|
+
return next;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Compare the tree against last frame and mark what changed.
|
|
143
|
+
*
|
|
144
|
+
* Walks hidden nodes too, because a node that just became hidden is exactly the one whose previous
|
|
145
|
+
* rectangle has to be marked.
|
|
146
|
+
*/
|
|
147
|
+
export function diffTree(tracker: DirtyTracker, root: UiNode): boolean {
|
|
148
|
+
clearDirty(tracker);
|
|
149
|
+
const count = visit(tracker, root, 0);
|
|
150
|
+
/* A node that existed last frame and is gone now: its rectangle must be repainted. */
|
|
151
|
+
for (let at = count; at < tracker.count; at += 1) {
|
|
152
|
+
const previous = tracker.nodes[at];
|
|
153
|
+
if (previous === null || previous === undefined) continue;
|
|
154
|
+
const base = at * STRIDE;
|
|
155
|
+
SCRATCH.x = tracker.state[base] ?? 0;
|
|
156
|
+
SCRATCH.y = tracker.state[base + 1] ?? 0;
|
|
157
|
+
SCRATCH.w = tracker.state[base + 2] ?? 0;
|
|
158
|
+
SCRATCH.h = tracker.state[base + 3] ?? 0;
|
|
159
|
+
markDirty(tracker, SCRATCH);
|
|
160
|
+
tracker.nodes[at] = null;
|
|
161
|
+
}
|
|
162
|
+
tracker.count = count;
|
|
163
|
+
return tracker.any;
|
|
164
|
+
}
|