@driftengine/ui2d 3.62.0 → 4.0.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 +28 -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/spriteGl.js +19 -3
- package/dist/spriteGpu.d.ts +5 -0
- package/dist/spriteGpu.js +63 -2
- package/dist/spriteTexture.d.ts +30 -0
- package/dist/spriteTexture.js +1 -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/spriteGl.ts +22 -3
- package/src/spriteGpu.ts +73 -3
- package/src/spriteTexture.ts +31 -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
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
export function createTextModel(text = '') {
|
|
2
|
+
return { text, caret: text.length, anchor: text.length, composeFrom: -1, composeTo: -1 };
|
|
3
|
+
}
|
|
4
|
+
export function selectionRange(model) {
|
|
5
|
+
return model.anchor <= model.caret
|
|
6
|
+
? { from: model.anchor, to: model.caret }
|
|
7
|
+
: { from: model.caret, to: model.anchor };
|
|
8
|
+
}
|
|
9
|
+
export function selectedText(model) {
|
|
10
|
+
const { from, to } = selectionRange(model);
|
|
11
|
+
return model.text.slice(from, to);
|
|
12
|
+
}
|
|
13
|
+
/** Whether the unit at `at` is the low half of a surrogate pair, so `at` is mid-character. */
|
|
14
|
+
function insidePair(text, at) {
|
|
15
|
+
if (at <= 0 || at >= text.length)
|
|
16
|
+
return false;
|
|
17
|
+
const low = text.charCodeAt(at);
|
|
18
|
+
const high = text.charCodeAt(at - 1);
|
|
19
|
+
return low >= 0xdc00 && low <= 0xdfff && high >= 0xd800 && high <= 0xdbff;
|
|
20
|
+
}
|
|
21
|
+
function stepRight(text, at) {
|
|
22
|
+
const next = Math.min(text.length, at + 1);
|
|
23
|
+
return insidePair(text, next) ? Math.min(text.length, next + 1) : next;
|
|
24
|
+
}
|
|
25
|
+
function stepLeft(text, at) {
|
|
26
|
+
const next = Math.max(0, at - 1);
|
|
27
|
+
return insidePair(text, next) ? Math.max(0, next - 1) : next;
|
|
28
|
+
}
|
|
29
|
+
function replaceSelection(model, s) {
|
|
30
|
+
const { from, to } = selectionRange(model);
|
|
31
|
+
model.text = model.text.slice(0, from) + s + model.text.slice(to);
|
|
32
|
+
model.caret = from + s.length;
|
|
33
|
+
model.anchor = model.caret;
|
|
34
|
+
}
|
|
35
|
+
export function insertText(model, s) {
|
|
36
|
+
replaceSelection(model, s);
|
|
37
|
+
}
|
|
38
|
+
export function deleteBackward(model) {
|
|
39
|
+
if (model.anchor !== model.caret) {
|
|
40
|
+
replaceSelection(model, '');
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const from = stepLeft(model.text, model.caret);
|
|
44
|
+
model.text = model.text.slice(0, from) + model.text.slice(model.caret);
|
|
45
|
+
model.caret = from;
|
|
46
|
+
model.anchor = from;
|
|
47
|
+
}
|
|
48
|
+
export function deleteForward(model) {
|
|
49
|
+
if (model.anchor !== model.caret) {
|
|
50
|
+
replaceSelection(model, '');
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const to = stepRight(model.text, model.caret);
|
|
54
|
+
model.text = model.text.slice(0, model.caret) + model.text.slice(to);
|
|
55
|
+
model.anchor = model.caret;
|
|
56
|
+
}
|
|
57
|
+
export function moveCaret(model, delta, select) {
|
|
58
|
+
let at = model.caret;
|
|
59
|
+
const steps = Math.abs(delta);
|
|
60
|
+
for (let i = 0; i < steps; i += 1) {
|
|
61
|
+
at = delta > 0 ? stepRight(model.text, at) : stepLeft(model.text, at);
|
|
62
|
+
}
|
|
63
|
+
model.caret = at;
|
|
64
|
+
if (!select)
|
|
65
|
+
model.anchor = at;
|
|
66
|
+
}
|
|
67
|
+
export function moveToLineEdge(model, forward, select) {
|
|
68
|
+
let at;
|
|
69
|
+
if (forward) {
|
|
70
|
+
const found = model.text.indexOf('\n', model.caret);
|
|
71
|
+
at = found === -1 ? model.text.length : found;
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
const found = model.text.lastIndexOf('\n', Math.max(0, model.caret - 1));
|
|
75
|
+
at = found === -1 ? 0 : found + 1;
|
|
76
|
+
}
|
|
77
|
+
model.caret = at;
|
|
78
|
+
if (!select)
|
|
79
|
+
model.anchor = at;
|
|
80
|
+
}
|
|
81
|
+
export function selectAll(model) {
|
|
82
|
+
model.anchor = 0;
|
|
83
|
+
model.caret = model.text.length;
|
|
84
|
+
}
|
package/dist/theme.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
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
|
+
export declare function createTheme(values: Record<string, number>): Theme;
|
|
12
|
+
/** A new theme with `overrides` applied over `base`. `base` is not modified. */
|
|
13
|
+
export declare function deriveTheme(base: Theme, overrides: Record<string, number>): Theme;
|
|
14
|
+
export declare function themeColour(theme: Theme, name: string, fallback: number): number;
|
|
15
|
+
export declare function themeSize(theme: Theme, name: string, fallback: number): number;
|
|
16
|
+
/**
|
|
17
|
+
* A packed `0xRRGGBBAA` token as the four floats a node's `background` and `tint` are.
|
|
18
|
+
*
|
|
19
|
+
* **Two of this package's own APIs did not meet**: a theme token is one number and a `UiNode`
|
|
20
|
+
* colour is a `Float32Array`, so every consumer wrote the same four shifts. The editor found it by
|
|
21
|
+
* being the first thing to build widgets on both, which is what a consumer outside `packages/` is
|
|
22
|
+
* for.
|
|
23
|
+
*
|
|
24
|
+
* `>>>` rather than `>>`, because a token with red at or above `0x80` has the sign bit set and an
|
|
25
|
+
* arithmetic shift returns it negative — a red that turns the whole colour into a large negative
|
|
26
|
+
* number, which is exactly the shape of bug that survives review.
|
|
27
|
+
*/
|
|
28
|
+
export declare function unpackRgba(packed: number, out: Float32Array): Float32Array;
|
|
29
|
+
/** `themeColour` and `unpackRgba` in one call, which is how a widget uses both. */
|
|
30
|
+
export declare function themeRgba(theme: Theme, name: string, fallback: number, out: Float32Array): Float32Array;
|
package/dist/theme.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export function createTheme(values) {
|
|
2
|
+
return { values: { ...values } };
|
|
3
|
+
}
|
|
4
|
+
/** A new theme with `overrides` applied over `base`. `base` is not modified. */
|
|
5
|
+
export function deriveTheme(base, overrides) {
|
|
6
|
+
return { values: { ...base.values, ...overrides } };
|
|
7
|
+
}
|
|
8
|
+
export function themeColour(theme, name, fallback) {
|
|
9
|
+
return theme.values[name] ?? fallback;
|
|
10
|
+
}
|
|
11
|
+
export function themeSize(theme, name, fallback) {
|
|
12
|
+
return theme.values[name] ?? fallback;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* A packed `0xRRGGBBAA` token as the four floats a node's `background` and `tint` are.
|
|
16
|
+
*
|
|
17
|
+
* **Two of this package's own APIs did not meet**: a theme token is one number and a `UiNode`
|
|
18
|
+
* colour is a `Float32Array`, so every consumer wrote the same four shifts. The editor found it by
|
|
19
|
+
* being the first thing to build widgets on both, which is what a consumer outside `packages/` is
|
|
20
|
+
* for.
|
|
21
|
+
*
|
|
22
|
+
* `>>>` rather than `>>`, because a token with red at or above `0x80` has the sign bit set and an
|
|
23
|
+
* arithmetic shift returns it negative — a red that turns the whole colour into a large negative
|
|
24
|
+
* number, which is exactly the shape of bug that survives review.
|
|
25
|
+
*/
|
|
26
|
+
export function unpackRgba(packed, out) {
|
|
27
|
+
out[0] = ((packed >>> 24) & 0xff) / 255;
|
|
28
|
+
out[1] = ((packed >>> 16) & 0xff) / 255;
|
|
29
|
+
out[2] = ((packed >>> 8) & 0xff) / 255;
|
|
30
|
+
out[3] = (packed & 0xff) / 255;
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
/** `themeColour` and `unpackRgba` in one call, which is how a widget uses both. */
|
|
34
|
+
export function themeRgba(theme, name, fallback, out) {
|
|
35
|
+
return unpackRgba(themeColour(theme, name, fallback), out);
|
|
36
|
+
}
|
package/dist/uiClip.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
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
|
+
/** Write the overlap of two rectangles into `out`. Never negative; see the header. */
|
|
21
|
+
export declare function intersectClip(out: UiRect, parent: UiRect, child: UiRect): void;
|
|
22
|
+
/**
|
|
23
|
+
* The rectangle `node` may draw into, cut by every clipping ancestor between it and `root`.
|
|
24
|
+
*
|
|
25
|
+
* Returns false when nothing of it survives, which is the caller's signal to skip the subtree
|
|
26
|
+
* rather than issue a draw that covers no pixels.
|
|
27
|
+
*/
|
|
28
|
+
export declare function clipRectFor(root: UiNode, node: UiNode, out: UiRect): boolean;
|
package/dist/uiClip.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** Write the overlap of two rectangles into `out`. Never negative; see the header. */
|
|
2
|
+
export function intersectClip(out, parent, child) {
|
|
3
|
+
const left = Math.max(parent.x, child.x);
|
|
4
|
+
const top = Math.max(parent.y, child.y);
|
|
5
|
+
const right = Math.min(parent.x + parent.w, child.x + child.w);
|
|
6
|
+
const bottom = Math.min(parent.y + parent.h, child.y + child.h);
|
|
7
|
+
out.x = left;
|
|
8
|
+
out.y = top;
|
|
9
|
+
out.w = Math.max(0, right - left);
|
|
10
|
+
out.h = Math.max(0, bottom - top);
|
|
11
|
+
}
|
|
12
|
+
/* Reused across calls: this runs per node per frame and the chain is its only allocation. */
|
|
13
|
+
const CHAIN = [];
|
|
14
|
+
/**
|
|
15
|
+
* The rectangle `node` may draw into, cut by every clipping ancestor between it and `root`.
|
|
16
|
+
*
|
|
17
|
+
* Returns false when nothing of it survives, which is the caller's signal to skip the subtree
|
|
18
|
+
* rather than issue a draw that covers no pixels.
|
|
19
|
+
*/
|
|
20
|
+
export function clipRectFor(root, node, out) {
|
|
21
|
+
out.x = node.rect.x;
|
|
22
|
+
out.y = node.rect.y;
|
|
23
|
+
out.w = node.rect.w;
|
|
24
|
+
out.h = node.rect.h;
|
|
25
|
+
/*
|
|
26
|
+
* Collected on the way up and applied on the way down, because the intersection has to run
|
|
27
|
+
* outermost-first: an inner clip can only narrow an outer one, so the wrong order happens to be
|
|
28
|
+
* right for nested rectangles and is wrong for offset ones.
|
|
29
|
+
*/
|
|
30
|
+
let depth = 0;
|
|
31
|
+
let walk = node.parent;
|
|
32
|
+
while (walk !== null) {
|
|
33
|
+
CHAIN[depth] = walk;
|
|
34
|
+
depth += 1;
|
|
35
|
+
if (walk === root)
|
|
36
|
+
break;
|
|
37
|
+
walk = walk.parent;
|
|
38
|
+
}
|
|
39
|
+
for (let i = depth - 1; i >= 0; i -= 1) {
|
|
40
|
+
const ancestor = CHAIN[i];
|
|
41
|
+
if (!ancestor.clip)
|
|
42
|
+
continue;
|
|
43
|
+
intersectClip(out, ancestor.rect, out);
|
|
44
|
+
}
|
|
45
|
+
return out.w > 0 && out.h > 0;
|
|
46
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
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
|
+
export interface DirtyTracker {
|
|
22
|
+
/** The node seen at each traversal index last frame. */
|
|
23
|
+
nodes: (UiNode | null)[];
|
|
24
|
+
/** Six numbers per index: x, y, w, h, and the visual state packed into two. */
|
|
25
|
+
state: Float64Array;
|
|
26
|
+
/** How many indices the previous walk filled. */
|
|
27
|
+
count: number;
|
|
28
|
+
/** The union of everything marked this frame, and whether anything was. */
|
|
29
|
+
x: number;
|
|
30
|
+
y: number;
|
|
31
|
+
w: number;
|
|
32
|
+
h: number;
|
|
33
|
+
any: boolean;
|
|
34
|
+
}
|
|
35
|
+
export declare function createDirtyTracker(capacity: number): DirtyTracker;
|
|
36
|
+
export declare function clearDirty(tracker: DirtyTracker): void;
|
|
37
|
+
/** Union `rect` into what must be redrawn. A zero-area rectangle still counts as a change. */
|
|
38
|
+
export declare function markDirty(tracker: DirtyTracker, rect: UiRect): void;
|
|
39
|
+
/** The union of everything marked. Returns false when nothing was. */
|
|
40
|
+
export declare function dirtyBounds(tracker: DirtyTracker, out: UiRect): boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Compare the tree against last frame and mark what changed.
|
|
43
|
+
*
|
|
44
|
+
* Walks hidden nodes too, because a node that just became hidden is exactly the one whose previous
|
|
45
|
+
* rectangle has to be marked.
|
|
46
|
+
*/
|
|
47
|
+
export declare function diffTree(tracker: DirtyTracker, root: UiNode): boolean;
|
package/dist/uiDirty.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
const STRIDE = 6;
|
|
2
|
+
export function createDirtyTracker(capacity) {
|
|
3
|
+
return {
|
|
4
|
+
nodes: Array.from({ length: capacity }, () => null),
|
|
5
|
+
state: new Float64Array(capacity * STRIDE),
|
|
6
|
+
count: 0,
|
|
7
|
+
x: 0,
|
|
8
|
+
y: 0,
|
|
9
|
+
w: 0,
|
|
10
|
+
h: 0,
|
|
11
|
+
any: false,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
export function clearDirty(tracker) {
|
|
15
|
+
tracker.any = false;
|
|
16
|
+
tracker.x = 0;
|
|
17
|
+
tracker.y = 0;
|
|
18
|
+
tracker.w = 0;
|
|
19
|
+
tracker.h = 0;
|
|
20
|
+
}
|
|
21
|
+
/** Union `rect` into what must be redrawn. A zero-area rectangle still counts as a change. */
|
|
22
|
+
export function markDirty(tracker, rect) {
|
|
23
|
+
if (!tracker.any) {
|
|
24
|
+
tracker.x = rect.x;
|
|
25
|
+
tracker.y = rect.y;
|
|
26
|
+
tracker.w = rect.w;
|
|
27
|
+
tracker.h = rect.h;
|
|
28
|
+
tracker.any = true;
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const left = Math.min(tracker.x, rect.x);
|
|
32
|
+
const top = Math.min(tracker.y, rect.y);
|
|
33
|
+
const right = Math.max(tracker.x + tracker.w, rect.x + rect.w);
|
|
34
|
+
const bottom = Math.max(tracker.y + tracker.h, rect.y + rect.h);
|
|
35
|
+
tracker.x = left;
|
|
36
|
+
tracker.y = top;
|
|
37
|
+
tracker.w = right - left;
|
|
38
|
+
tracker.h = bottom - top;
|
|
39
|
+
}
|
|
40
|
+
/** The union of everything marked. Returns false when nothing was. */
|
|
41
|
+
export function dirtyBounds(tracker, out) {
|
|
42
|
+
out.x = tracker.x;
|
|
43
|
+
out.y = tracker.y;
|
|
44
|
+
out.w = tracker.w;
|
|
45
|
+
out.h = tracker.h;
|
|
46
|
+
return tracker.any;
|
|
47
|
+
}
|
|
48
|
+
/** Visual state that changes pixels, packed so one comparison covers all of it. */
|
|
49
|
+
function visualOf(node) {
|
|
50
|
+
return ((node.hidden ? 1 : 0) |
|
|
51
|
+
(node.hovered ? 2 : 0) |
|
|
52
|
+
(node.pressed ? 4 : 0) |
|
|
53
|
+
((node.texture + 1) * 8));
|
|
54
|
+
}
|
|
55
|
+
const SCRATCH = { x: 0, y: 0, w: 0, h: 0 };
|
|
56
|
+
function visit(tracker, node, at) {
|
|
57
|
+
const base = at * STRIDE;
|
|
58
|
+
const previous = tracker.nodes[at] ?? null;
|
|
59
|
+
const visual = visualOf(node);
|
|
60
|
+
const textHash = node.text.length;
|
|
61
|
+
const moved = previous !== node ||
|
|
62
|
+
tracker.state[base] !== node.rect.x ||
|
|
63
|
+
tracker.state[base + 1] !== node.rect.y ||
|
|
64
|
+
tracker.state[base + 2] !== node.rect.w ||
|
|
65
|
+
tracker.state[base + 3] !== node.rect.h ||
|
|
66
|
+
tracker.state[base + 4] !== visual ||
|
|
67
|
+
tracker.state[base + 5] !== textHash;
|
|
68
|
+
if (moved) {
|
|
69
|
+
/* Where it was, so a node that moved or vanished does not leave its pixels behind. */
|
|
70
|
+
if (previous !== null) {
|
|
71
|
+
SCRATCH.x = tracker.state[base] ?? 0;
|
|
72
|
+
SCRATCH.y = tracker.state[base + 1] ?? 0;
|
|
73
|
+
SCRATCH.w = tracker.state[base + 2] ?? 0;
|
|
74
|
+
SCRATCH.h = tracker.state[base + 3] ?? 0;
|
|
75
|
+
markDirty(tracker, SCRATCH);
|
|
76
|
+
}
|
|
77
|
+
if (!node.hidden)
|
|
78
|
+
markDirty(tracker, node.rect);
|
|
79
|
+
}
|
|
80
|
+
tracker.nodes[at] = node;
|
|
81
|
+
tracker.state[base] = node.rect.x;
|
|
82
|
+
tracker.state[base + 1] = node.rect.y;
|
|
83
|
+
tracker.state[base + 2] = node.rect.w;
|
|
84
|
+
tracker.state[base + 3] = node.rect.h;
|
|
85
|
+
tracker.state[base + 4] = visual;
|
|
86
|
+
tracker.state[base + 5] = textHash;
|
|
87
|
+
let next = at + 1;
|
|
88
|
+
for (const child of node.children)
|
|
89
|
+
next = visit(tracker, child, next);
|
|
90
|
+
return next;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Compare the tree against last frame and mark what changed.
|
|
94
|
+
*
|
|
95
|
+
* Walks hidden nodes too, because a node that just became hidden is exactly the one whose previous
|
|
96
|
+
* rectangle has to be marked.
|
|
97
|
+
*/
|
|
98
|
+
export function diffTree(tracker, root) {
|
|
99
|
+
clearDirty(tracker);
|
|
100
|
+
const count = visit(tracker, root, 0);
|
|
101
|
+
/* A node that existed last frame and is gone now: its rectangle must be repainted. */
|
|
102
|
+
for (let at = count; at < tracker.count; at += 1) {
|
|
103
|
+
const previous = tracker.nodes[at];
|
|
104
|
+
if (previous === null || previous === undefined)
|
|
105
|
+
continue;
|
|
106
|
+
const base = at * STRIDE;
|
|
107
|
+
SCRATCH.x = tracker.state[base] ?? 0;
|
|
108
|
+
SCRATCH.y = tracker.state[base + 1] ?? 0;
|
|
109
|
+
SCRATCH.w = tracker.state[base + 2] ?? 0;
|
|
110
|
+
SCRATCH.h = tracker.state[base + 3] ?? 0;
|
|
111
|
+
markDirty(tracker, SCRATCH);
|
|
112
|
+
tracker.nodes[at] = null;
|
|
113
|
+
}
|
|
114
|
+
tracker.count = count;
|
|
115
|
+
return tracker.any;
|
|
116
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Draw order when tree order is not enough.
|
|
3
|
+
*
|
|
4
|
+
* A dropdown is owned by the control that opens it and must cover the panel that control sits in;
|
|
5
|
+
* a drag preview is owned by the thing being dragged and must cover everything. Tree order cannot
|
|
6
|
+
* express either, because ownership and covering point in opposite directions.
|
|
7
|
+
*
|
|
8
|
+
* **A layer is inherited.** A popup's own children have to ride with it, or a menu draws above the
|
|
9
|
+
* panel and its labels draw below it — which is the first bug any other arrangement produces.
|
|
10
|
+
*
|
|
11
|
+
* **A stable sort, and the stability is a requirement rather than a nicety.** Two nodes on one
|
|
12
|
+
* layer that swap between frames produce a flicker nobody can reproduce on demand. Sorting the
|
|
13
|
+
* traversal indices rather than the nodes makes the tiebreaker the traversal order by
|
|
14
|
+
* construction, so it cannot be lost to whatever the sort implementation guarantees.
|
|
15
|
+
*/
|
|
16
|
+
import type { UiNode } from './uiNode.ts';
|
|
17
|
+
/** Fill `out` with the tree in draw order and return how many entries were written. */
|
|
18
|
+
export declare function layerOrder(root: UiNode, out: UiNode[]): number;
|
package/dist/uiLayer.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/* Reused across calls: this runs per frame and these are its only allocations. */
|
|
2
|
+
const FLAT = [];
|
|
3
|
+
const LAYERS = [];
|
|
4
|
+
const ORDER = [];
|
|
5
|
+
function gather(node, inherited, at) {
|
|
6
|
+
if (node.hidden)
|
|
7
|
+
return at;
|
|
8
|
+
const layer = node.layer === 0 ? inherited : node.layer;
|
|
9
|
+
FLAT[at] = node;
|
|
10
|
+
LAYERS[at] = layer;
|
|
11
|
+
let next = at + 1;
|
|
12
|
+
for (const child of node.children)
|
|
13
|
+
next = gather(child, layer, next);
|
|
14
|
+
return next;
|
|
15
|
+
}
|
|
16
|
+
/** Fill `out` with the tree in draw order and return how many entries were written. */
|
|
17
|
+
export function layerOrder(root, out) {
|
|
18
|
+
const count = gather(root, 0, 0);
|
|
19
|
+
ORDER.length = 0;
|
|
20
|
+
for (let i = 0; i < count; i += 1)
|
|
21
|
+
ORDER.push(i);
|
|
22
|
+
ORDER.sort((a, b) => {
|
|
23
|
+
const byLayer = (LAYERS[a] ?? 0) - (LAYERS[b] ?? 0);
|
|
24
|
+
return byLayer !== 0 ? byLayer : a - b;
|
|
25
|
+
});
|
|
26
|
+
for (let i = 0; i < count; i += 1)
|
|
27
|
+
out[i] = FLAT[ORDER[i] ?? 0];
|
|
28
|
+
out.length = count;
|
|
29
|
+
return count;
|
|
30
|
+
}
|
package/dist/uiLayout.js
CHANGED
|
@@ -66,8 +66,14 @@ function place(node, x, y, w, h) {
|
|
|
66
66
|
node.rect.w = w;
|
|
67
67
|
node.rect.h = h;
|
|
68
68
|
const row = node.direction === 'row';
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
/*
|
|
70
|
+
* Scrolling is subtracted here, at placement, rather than applied as a transform at draw.
|
|
71
|
+
* Applied at draw, hit testing would need the inverse and every consumer would eventually get
|
|
72
|
+
* it wrong in one direction; applied here, a scrolled child's rectangle simply *is* where it is,
|
|
73
|
+
* and hit testing, focus and clipping all keep working without knowing scrolling exists.
|
|
74
|
+
*/
|
|
75
|
+
const contentX = x + node.paddingLeft - node.scrollX;
|
|
76
|
+
const contentY = y + node.paddingTop - node.scrollY;
|
|
71
77
|
const contentW = Math.max(0, w - node.paddingLeft - node.paddingRight);
|
|
72
78
|
const contentH = Math.max(0, h - node.paddingTop - node.paddingBottom);
|
|
73
79
|
const contentAlong = row ? contentW : contentH;
|
|
@@ -129,6 +135,7 @@ function place(node, x, y, w, h) {
|
|
|
129
135
|
}
|
|
130
136
|
cursor += sizeAlong + node.gap + between;
|
|
131
137
|
}
|
|
138
|
+
measureSpan(node, contentX, contentY);
|
|
132
139
|
}
|
|
133
140
|
/**
|
|
134
141
|
* A child taken out of the flow: at its own offset inside the parent's content box.
|
|
@@ -137,6 +144,26 @@ function place(node, x, y, w, h) {
|
|
|
137
144
|
* where the panel's contents start — which is where a caller reading the padding expects it, and
|
|
138
145
|
* the only reading under which `x: 0` means the same thing for an absolute child as for a flow one.
|
|
139
146
|
*/
|
|
147
|
+
/**
|
|
148
|
+
* How far the placed children reach past the content origin.
|
|
149
|
+
*
|
|
150
|
+
* Measured after placement and relative to `contentX`/`contentY`, which the scroll has already
|
|
151
|
+
* moved — so the offset cancels and the answer is the same whatever the node is scrolled to.
|
|
152
|
+
* `uiScroll.ts` clamps against this and would be wrong clamping against anything derived from the
|
|
153
|
+
* live scroll value.
|
|
154
|
+
*/
|
|
155
|
+
function measureSpan(node, contentX, contentY) {
|
|
156
|
+
let spanX = 0;
|
|
157
|
+
let spanY = 0;
|
|
158
|
+
for (const child of node.children) {
|
|
159
|
+
if (child.hidden)
|
|
160
|
+
continue;
|
|
161
|
+
spanX = Math.max(spanX, child.rect.x + child.rect.w - contentX);
|
|
162
|
+
spanY = Math.max(spanY, child.rect.y + child.rect.h - contentY);
|
|
163
|
+
}
|
|
164
|
+
node.contentSpanX = spanX;
|
|
165
|
+
node.contentSpanY = spanY;
|
|
166
|
+
}
|
|
140
167
|
function placeAbsolute(child, contentX, contentY, contentW, contentH) {
|
|
141
168
|
const w = child.width === 'grow' ? contentW : child.measuredWidth;
|
|
142
169
|
const h = child.height === 'grow' ? contentH : child.measuredHeight;
|
package/dist/uiNode.d.ts
CHANGED
|
@@ -39,6 +39,18 @@ export interface UiNodeOptions {
|
|
|
39
39
|
readonly contentWidth?: number;
|
|
40
40
|
readonly contentHeight?: number;
|
|
41
41
|
readonly hidden?: boolean;
|
|
42
|
+
/**
|
|
43
|
+
* Whether this node cuts its descendants to its own rectangle.
|
|
44
|
+
*
|
|
45
|
+
* Off by default, because most nodes do not clip and one that does costs a scissor change in
|
|
46
|
+
* the pass. A scroll container sets it and a panel sets it; a row of buttons does not.
|
|
47
|
+
*/
|
|
48
|
+
readonly clip?: boolean;
|
|
49
|
+
/** How far this node's contents are moved under it. Only meaningful with `clip`. */
|
|
50
|
+
readonly scrollX?: number;
|
|
51
|
+
readonly scrollY?: number;
|
|
52
|
+
/** Draw order beyond tree order. Inherited by descendants that do not set their own. */
|
|
53
|
+
readonly layer?: number;
|
|
42
54
|
readonly background?: ArrayLike<number> | null;
|
|
43
55
|
/** The sprite slot this node draws from, or `-1` for none. */
|
|
44
56
|
readonly texture?: number;
|
|
@@ -78,6 +90,23 @@ export interface UiNode {
|
|
|
78
90
|
contentWidth: number;
|
|
79
91
|
contentHeight: number;
|
|
80
92
|
hidden: boolean;
|
|
93
|
+
/** Whether this node cuts its descendants to its own rectangle. See `uiClip.ts`. */
|
|
94
|
+
clip: boolean;
|
|
95
|
+
/** How far this node's contents are moved under it. See `uiScroll.ts`. */
|
|
96
|
+
scrollX: number;
|
|
97
|
+
scrollY: number;
|
|
98
|
+
/**
|
|
99
|
+
* How far the placed children reach past this node's content origin, along each axis.
|
|
100
|
+
*
|
|
101
|
+
* Written by `layoutUiTree` and **independent of the current scroll**, because it is measured
|
|
102
|
+
* relative to the content origin that the scroll already moved — the offset cancels. That
|
|
103
|
+
* independence is the point: an extent derived from placed positions plus the live scroll value
|
|
104
|
+
* is wrong the moment the two disagree, which is exactly when a caller is scrolling.
|
|
105
|
+
*/
|
|
106
|
+
contentSpanX: number;
|
|
107
|
+
contentSpanY: number;
|
|
108
|
+
/** Draw order beyond tree order. See `uiLayer.ts`. */
|
|
109
|
+
layer: number;
|
|
81
110
|
/** Where this node ended up. Meaningless until `layoutUiTree` has run over its root. */
|
|
82
111
|
readonly rect: UiRect;
|
|
83
112
|
/** What it measured to along each axis, before the parent distributed anything. */
|
package/dist/uiNode.js
CHANGED
|
@@ -30,6 +30,12 @@ export function createUiNode(options = {}) {
|
|
|
30
30
|
contentWidth: options.contentWidth ?? 0,
|
|
31
31
|
contentHeight: options.contentHeight ?? 0,
|
|
32
32
|
hidden: options.hidden ?? false,
|
|
33
|
+
clip: options.clip ?? false,
|
|
34
|
+
scrollX: options.scrollX ?? 0,
|
|
35
|
+
scrollY: options.scrollY ?? 0,
|
|
36
|
+
contentSpanX: 0,
|
|
37
|
+
contentSpanY: 0,
|
|
38
|
+
layer: options.layer ?? 0,
|
|
33
39
|
rect: { x: 0, y: 0, w: 0, h: 0 },
|
|
34
40
|
measuredWidth: 0,
|
|
35
41
|
measuredHeight: 0,
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who the pointer belongs to, and what a drag is carrying.
|
|
3
|
+
*
|
|
4
|
+
* **Capture is the half without which every slider in the interface is wrong in the same way.** A
|
|
5
|
+
* control being dragged has to keep receiving motion after the pointer leaves its rectangle — that
|
|
6
|
+
* is what dragging *is* — and a framework that routes purely by hit test cannot express it.
|
|
7
|
+
*
|
|
8
|
+
* **A drag records its target as the pointer moves**, rather than hit-testing once at release,
|
|
9
|
+
* because the release position is the only thing a drop handler could otherwise use and a pointer
|
|
10
|
+
* that leaves the window never produces one.
|
|
11
|
+
*/
|
|
12
|
+
import type { UiNode } from './uiNode.ts';
|
|
13
|
+
export interface PointerState {
|
|
14
|
+
/** The node holding the pointer, or null. */
|
|
15
|
+
captured: UiNode | null;
|
|
16
|
+
/** Where a drag began, or null when none is in progress. */
|
|
17
|
+
dragSource: UiNode | null;
|
|
18
|
+
/** The last node the drag was over, excluding the source itself. */
|
|
19
|
+
dragTarget: UiNode | null;
|
|
20
|
+
/** What the drag is carrying. Opaque to this module. */
|
|
21
|
+
dragPayload: string;
|
|
22
|
+
}
|
|
23
|
+
export declare function createPointerState(): PointerState;
|
|
24
|
+
export declare function capturePointer(state: PointerState, node: UiNode): void;
|
|
25
|
+
export declare function releasePointer(state: PointerState): void;
|
|
26
|
+
/**
|
|
27
|
+
* Who should receive pointer input at this position.
|
|
28
|
+
*
|
|
29
|
+
* A captured node receives it wherever the pointer is, including outside the window. Otherwise it
|
|
30
|
+
* is an ordinary hit test — and while a drag is in progress, the result is remembered as the
|
|
31
|
+
* prospective drop target.
|
|
32
|
+
*/
|
|
33
|
+
export declare function pointerTarget(state: PointerState, root: UiNode, x: number, y: number): UiNode | null;
|
|
34
|
+
export declare function beginDrag(state: PointerState, source: UiNode, payload: string): void;
|
|
35
|
+
/** The node a drop would land on, or null. */
|
|
36
|
+
export declare function dropTarget(state: PointerState): UiNode | null;
|
|
37
|
+
/**
|
|
38
|
+
* Finish a drag and report where it landed. Null when no drag was in progress.
|
|
39
|
+
*
|
|
40
|
+
* A null `target` is an ordinary outcome — a drop on nothing — and is reported rather than
|
|
41
|
+
* suppressed, because a caller often wants to know a drag ended even where it achieved nothing.
|
|
42
|
+
*/
|
|
43
|
+
export declare function endDrag(state: PointerState): {
|
|
44
|
+
source: UiNode;
|
|
45
|
+
target: UiNode | null;
|
|
46
|
+
payload: string;
|
|
47
|
+
} | null;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { uiHitTest } from './uiFocus.js';
|
|
2
|
+
export function createPointerState() {
|
|
3
|
+
return { captured: null, dragSource: null, dragTarget: null, dragPayload: '' };
|
|
4
|
+
}
|
|
5
|
+
export function capturePointer(state, node) {
|
|
6
|
+
state.captured = node;
|
|
7
|
+
}
|
|
8
|
+
export function releasePointer(state) {
|
|
9
|
+
state.captured = null;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Who should receive pointer input at this position.
|
|
13
|
+
*
|
|
14
|
+
* A captured node receives it wherever the pointer is, including outside the window. Otherwise it
|
|
15
|
+
* is an ordinary hit test — and while a drag is in progress, the result is remembered as the
|
|
16
|
+
* prospective drop target.
|
|
17
|
+
*/
|
|
18
|
+
export function pointerTarget(state, root, x, y) {
|
|
19
|
+
if (state.captured !== null)
|
|
20
|
+
return state.captured;
|
|
21
|
+
const hit = uiHitTest(root, x, y);
|
|
22
|
+
if (state.dragSource !== null)
|
|
23
|
+
state.dragTarget = hit === state.dragSource ? null : hit;
|
|
24
|
+
return hit;
|
|
25
|
+
}
|
|
26
|
+
export function beginDrag(state, source, payload) {
|
|
27
|
+
state.dragSource = source;
|
|
28
|
+
state.dragTarget = null;
|
|
29
|
+
state.dragPayload = payload;
|
|
30
|
+
}
|
|
31
|
+
/** The node a drop would land on, or null. */
|
|
32
|
+
export function dropTarget(state) {
|
|
33
|
+
return state.dragTarget;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Finish a drag and report where it landed. Null when no drag was in progress.
|
|
37
|
+
*
|
|
38
|
+
* A null `target` is an ordinary outcome — a drop on nothing — and is reported rather than
|
|
39
|
+
* suppressed, because a caller often wants to know a drag ended even where it achieved nothing.
|
|
40
|
+
*/
|
|
41
|
+
export function endDrag(state) {
|
|
42
|
+
const source = state.dragSource;
|
|
43
|
+
if (source === null)
|
|
44
|
+
return null;
|
|
45
|
+
const result = { source, target: state.dragTarget, payload: state.dragPayload };
|
|
46
|
+
state.dragSource = null;
|
|
47
|
+
state.dragTarget = null;
|
|
48
|
+
state.dragPayload = '';
|
|
49
|
+
return result;
|
|
50
|
+
}
|