@driftengine/ui2d 3.63.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 +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/uiLayer.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
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
|
+
|
|
18
|
+
/* Reused across calls: this runs per frame and these are its only allocations. */
|
|
19
|
+
const FLAT: UiNode[] = [];
|
|
20
|
+
const LAYERS: number[] = [];
|
|
21
|
+
const ORDER: number[] = [];
|
|
22
|
+
|
|
23
|
+
function gather(node: UiNode, inherited: number, at: number): number {
|
|
24
|
+
if (node.hidden) return at;
|
|
25
|
+
const layer = node.layer === 0 ? inherited : node.layer;
|
|
26
|
+
FLAT[at] = node;
|
|
27
|
+
LAYERS[at] = layer;
|
|
28
|
+
let next = at + 1;
|
|
29
|
+
for (const child of node.children) next = gather(child, layer, next);
|
|
30
|
+
return next;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Fill `out` with the tree in draw order and return how many entries were written. */
|
|
34
|
+
export function layerOrder(root: UiNode, out: UiNode[]): number {
|
|
35
|
+
const count = gather(root, 0, 0);
|
|
36
|
+
|
|
37
|
+
ORDER.length = 0;
|
|
38
|
+
for (let i = 0; i < count; i += 1) ORDER.push(i);
|
|
39
|
+
ORDER.sort((a, b) => {
|
|
40
|
+
const byLayer = (LAYERS[a] ?? 0) - (LAYERS[b] ?? 0);
|
|
41
|
+
return byLayer !== 0 ? byLayer : a - b;
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
for (let i = 0; i < count; i += 1) out[i] = FLAT[ORDER[i] ?? 0] as UiNode;
|
|
45
|
+
out.length = count;
|
|
46
|
+
return count;
|
|
47
|
+
}
|
package/src/uiLayout.ts
CHANGED
|
@@ -76,8 +76,14 @@ function place(node: UiNode, x: number, y: number, w: number, h: number): void {
|
|
|
76
76
|
node.rect.h = h;
|
|
77
77
|
|
|
78
78
|
const row = node.direction === 'row';
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
/*
|
|
80
|
+
* Scrolling is subtracted here, at placement, rather than applied as a transform at draw.
|
|
81
|
+
* Applied at draw, hit testing would need the inverse and every consumer would eventually get
|
|
82
|
+
* it wrong in one direction; applied here, a scrolled child's rectangle simply *is* where it is,
|
|
83
|
+
* and hit testing, focus and clipping all keep working without knowing scrolling exists.
|
|
84
|
+
*/
|
|
85
|
+
const contentX = x + node.paddingLeft - node.scrollX;
|
|
86
|
+
const contentY = y + node.paddingTop - node.scrollY;
|
|
81
87
|
const contentW = Math.max(0, w - node.paddingLeft - node.paddingRight);
|
|
82
88
|
const contentH = Math.max(0, h - node.paddingTop - node.paddingBottom);
|
|
83
89
|
const contentAlong = row ? contentW : contentH;
|
|
@@ -135,6 +141,8 @@ function place(node: UiNode, x: number, y: number, w: number, h: number): void {
|
|
|
135
141
|
}
|
|
136
142
|
cursor += sizeAlong + node.gap + between;
|
|
137
143
|
}
|
|
144
|
+
|
|
145
|
+
measureSpan(node, contentX, contentY);
|
|
138
146
|
}
|
|
139
147
|
|
|
140
148
|
/**
|
|
@@ -144,6 +152,26 @@ function place(node: UiNode, x: number, y: number, w: number, h: number): void {
|
|
|
144
152
|
* where the panel's contents start — which is where a caller reading the padding expects it, and
|
|
145
153
|
* the only reading under which `x: 0` means the same thing for an absolute child as for a flow one.
|
|
146
154
|
*/
|
|
155
|
+
/**
|
|
156
|
+
* How far the placed children reach past the content origin.
|
|
157
|
+
*
|
|
158
|
+
* Measured after placement and relative to `contentX`/`contentY`, which the scroll has already
|
|
159
|
+
* moved — so the offset cancels and the answer is the same whatever the node is scrolled to.
|
|
160
|
+
* `uiScroll.ts` clamps against this and would be wrong clamping against anything derived from the
|
|
161
|
+
* live scroll value.
|
|
162
|
+
*/
|
|
163
|
+
function measureSpan(node: UiNode, contentX: number, contentY: number): void {
|
|
164
|
+
let spanX = 0;
|
|
165
|
+
let spanY = 0;
|
|
166
|
+
for (const child of node.children) {
|
|
167
|
+
if (child.hidden) continue;
|
|
168
|
+
spanX = Math.max(spanX, child.rect.x + child.rect.w - contentX);
|
|
169
|
+
spanY = Math.max(spanY, child.rect.y + child.rect.h - contentY);
|
|
170
|
+
}
|
|
171
|
+
node.contentSpanX = spanX;
|
|
172
|
+
node.contentSpanY = spanY;
|
|
173
|
+
}
|
|
174
|
+
|
|
147
175
|
function placeAbsolute(
|
|
148
176
|
child: UiNode,
|
|
149
177
|
contentX: number,
|
package/src/uiNode.ts
CHANGED
|
@@ -45,6 +45,18 @@ export interface UiNodeOptions {
|
|
|
45
45
|
readonly contentWidth?: number;
|
|
46
46
|
readonly contentHeight?: number;
|
|
47
47
|
readonly hidden?: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* Whether this node cuts its descendants to its own rectangle.
|
|
50
|
+
*
|
|
51
|
+
* Off by default, because most nodes do not clip and one that does costs a scissor change in
|
|
52
|
+
* the pass. A scroll container sets it and a panel sets it; a row of buttons does not.
|
|
53
|
+
*/
|
|
54
|
+
readonly clip?: boolean;
|
|
55
|
+
/** How far this node's contents are moved under it. Only meaningful with `clip`. */
|
|
56
|
+
readonly scrollX?: number;
|
|
57
|
+
readonly scrollY?: number;
|
|
58
|
+
/** Draw order beyond tree order. Inherited by descendants that do not set their own. */
|
|
59
|
+
readonly layer?: number;
|
|
48
60
|
readonly background?: ArrayLike<number> | null;
|
|
49
61
|
/** The sprite slot this node draws from, or `-1` for none. */
|
|
50
62
|
readonly texture?: number;
|
|
@@ -86,6 +98,23 @@ export interface UiNode {
|
|
|
86
98
|
contentWidth: number;
|
|
87
99
|
contentHeight: number;
|
|
88
100
|
hidden: boolean;
|
|
101
|
+
/** Whether this node cuts its descendants to its own rectangle. See `uiClip.ts`. */
|
|
102
|
+
clip: boolean;
|
|
103
|
+
/** How far this node's contents are moved under it. See `uiScroll.ts`. */
|
|
104
|
+
scrollX: number;
|
|
105
|
+
scrollY: number;
|
|
106
|
+
/**
|
|
107
|
+
* How far the placed children reach past this node's content origin, along each axis.
|
|
108
|
+
*
|
|
109
|
+
* Written by `layoutUiTree` and **independent of the current scroll**, because it is measured
|
|
110
|
+
* relative to the content origin that the scroll already moved — the offset cancels. That
|
|
111
|
+
* independence is the point: an extent derived from placed positions plus the live scroll value
|
|
112
|
+
* is wrong the moment the two disagree, which is exactly when a caller is scrolling.
|
|
113
|
+
*/
|
|
114
|
+
contentSpanX: number;
|
|
115
|
+
contentSpanY: number;
|
|
116
|
+
/** Draw order beyond tree order. See `uiLayer.ts`. */
|
|
117
|
+
layer: number;
|
|
89
118
|
|
|
90
119
|
/** Where this node ended up. Meaningless until `layoutUiTree` has run over its root. */
|
|
91
120
|
readonly rect: UiRect;
|
|
@@ -137,6 +166,12 @@ export function createUiNode(options: UiNodeOptions = {}): UiNode {
|
|
|
137
166
|
contentWidth: options.contentWidth ?? 0,
|
|
138
167
|
contentHeight: options.contentHeight ?? 0,
|
|
139
168
|
hidden: options.hidden ?? false,
|
|
169
|
+
clip: options.clip ?? false,
|
|
170
|
+
scrollX: options.scrollX ?? 0,
|
|
171
|
+
scrollY: options.scrollY ?? 0,
|
|
172
|
+
contentSpanX: 0,
|
|
173
|
+
contentSpanY: 0,
|
|
174
|
+
layer: options.layer ?? 0,
|
|
140
175
|
rect: { x: 0, y: 0, w: 0, h: 0 },
|
|
141
176
|
measuredWidth: 0,
|
|
142
177
|
measuredHeight: 0,
|
package/src/uiPointer.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
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
|
+
import { uiHitTest } from './uiFocus.ts';
|
|
14
|
+
|
|
15
|
+
export interface PointerState {
|
|
16
|
+
/** The node holding the pointer, or null. */
|
|
17
|
+
captured: UiNode | null;
|
|
18
|
+
/** Where a drag began, or null when none is in progress. */
|
|
19
|
+
dragSource: UiNode | null;
|
|
20
|
+
/** The last node the drag was over, excluding the source itself. */
|
|
21
|
+
dragTarget: UiNode | null;
|
|
22
|
+
/** What the drag is carrying. Opaque to this module. */
|
|
23
|
+
dragPayload: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function createPointerState(): PointerState {
|
|
27
|
+
return { captured: null, dragSource: null, dragTarget: null, dragPayload: '' };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function capturePointer(state: PointerState, node: UiNode): void {
|
|
31
|
+
state.captured = node;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function releasePointer(state: PointerState): void {
|
|
35
|
+
state.captured = null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Who should receive pointer input at this position.
|
|
40
|
+
*
|
|
41
|
+
* A captured node receives it wherever the pointer is, including outside the window. Otherwise it
|
|
42
|
+
* is an ordinary hit test — and while a drag is in progress, the result is remembered as the
|
|
43
|
+
* prospective drop target.
|
|
44
|
+
*/
|
|
45
|
+
export function pointerTarget(
|
|
46
|
+
state: PointerState,
|
|
47
|
+
root: UiNode,
|
|
48
|
+
x: number,
|
|
49
|
+
y: number,
|
|
50
|
+
): UiNode | null {
|
|
51
|
+
if (state.captured !== null) return state.captured;
|
|
52
|
+
const hit = uiHitTest(root, x, y);
|
|
53
|
+
if (state.dragSource !== null) state.dragTarget = hit === state.dragSource ? null : hit;
|
|
54
|
+
return hit;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function beginDrag(state: PointerState, source: UiNode, payload: string): void {
|
|
58
|
+
state.dragSource = source;
|
|
59
|
+
state.dragTarget = null;
|
|
60
|
+
state.dragPayload = payload;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The node a drop would land on, or null. */
|
|
64
|
+
export function dropTarget(state: PointerState): UiNode | null {
|
|
65
|
+
return state.dragTarget;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Finish a drag and report where it landed. Null when no drag was in progress.
|
|
70
|
+
*
|
|
71
|
+
* A null `target` is an ordinary outcome — a drop on nothing — and is reported rather than
|
|
72
|
+
* suppressed, because a caller often wants to know a drag ended even where it achieved nothing.
|
|
73
|
+
*/
|
|
74
|
+
export function endDrag(
|
|
75
|
+
state: PointerState,
|
|
76
|
+
): { source: UiNode; target: UiNode | null; payload: string } | null {
|
|
77
|
+
const source = state.dragSource;
|
|
78
|
+
if (source === null) return null;
|
|
79
|
+
const result = { source, target: state.dragTarget, payload: state.dragPayload };
|
|
80
|
+
state.dragSource = null;
|
|
81
|
+
state.dragTarget = null;
|
|
82
|
+
state.dragPayload = '';
|
|
83
|
+
return result;
|
|
84
|
+
}
|
package/src/uiScroll.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Moving a clipping node's contents underneath it.
|
|
3
|
+
*
|
|
4
|
+
* **Scrolling is an offset applied at placement, not a transform applied at draw.** `uiLayout.ts`
|
|
5
|
+
* subtracts it from the content origin, so a scrolled child's rectangle simply is where it is —
|
|
6
|
+
* and hit testing, focus and clipping keep working without any of them knowing that scrolling
|
|
7
|
+
* exists. Applied at draw, each of those would need the inverse and one of them would eventually
|
|
8
|
+
* get it wrong in one direction only.
|
|
9
|
+
*
|
|
10
|
+
* **A scroll that moves nothing says so.** Nested containers are the case: a wheel over an inner
|
|
11
|
+
* list that has reached its end should scroll the outer one rather than stopping dead, and a
|
|
12
|
+
* caller that cannot tell "consumed" from "already at the end" cannot implement that. The boolean
|
|
13
|
+
* is the whole mechanism.
|
|
14
|
+
*/
|
|
15
|
+
import type { UiNode } from './uiNode.ts';
|
|
16
|
+
import { uiRectHolds } from './uiNode.ts';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* How far this node's contents extend past its own box, along each axis.
|
|
20
|
+
*
|
|
21
|
+
* Read from the span `layoutUiTree` measured, which is relative to the content origin and so does
|
|
22
|
+
* not change as the node scrolls. **Deriving it from placed rectangles plus the live scroll value
|
|
23
|
+
* is wrong**, and wrong in the one situation it is asked about: after a caller has moved the
|
|
24
|
+
* scroll, the rectangles still reflect the previous value and the two no longer agree.
|
|
25
|
+
*/
|
|
26
|
+
export function scrollExtent(node: UiNode): { x: number; y: number } {
|
|
27
|
+
const contentW = Math.max(0, node.rect.w - node.paddingLeft - node.paddingRight);
|
|
28
|
+
const contentH = Math.max(0, node.rect.h - node.paddingTop - node.paddingBottom);
|
|
29
|
+
return {
|
|
30
|
+
x: Math.max(0, node.contentSpanX - contentW),
|
|
31
|
+
y: Math.max(0, node.contentSpanY - contentH),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function clampScroll(node: UiNode): void {
|
|
36
|
+
const extent = scrollExtent(node);
|
|
37
|
+
node.scrollX = Math.min(Math.max(0, node.scrollX), extent.x);
|
|
38
|
+
node.scrollY = Math.min(Math.max(0, node.scrollY), extent.y);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Scroll, clamped. Returns whether anything actually moved — see the header for why. */
|
|
42
|
+
export function scrollBy(node: UiNode, dx: number, dy: number): boolean {
|
|
43
|
+
const beforeX = node.scrollX;
|
|
44
|
+
const beforeY = node.scrollY;
|
|
45
|
+
node.scrollX += dx;
|
|
46
|
+
node.scrollY += dy;
|
|
47
|
+
clampScroll(node);
|
|
48
|
+
return node.scrollX !== beforeX || node.scrollY !== beforeY;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/* Reused across calls: this runs per wheel event and the chain is its only allocation. */
|
|
52
|
+
const CHAIN: UiNode[] = [];
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Give a wheel to the innermost container under the pointer that can use it, then outwards.
|
|
56
|
+
*
|
|
57
|
+
* Innermost first is the order a person expects and the opposite of the order the tree makes
|
|
58
|
+
* convenient, so the chain is collected on the way down and walked back up. Returns the node that
|
|
59
|
+
* consumed it, or null when nothing did — which is a caller's signal to let the page have it.
|
|
60
|
+
*/
|
|
61
|
+
export function routeScrollWheel(
|
|
62
|
+
root: UiNode,
|
|
63
|
+
x: number,
|
|
64
|
+
y: number,
|
|
65
|
+
dx: number,
|
|
66
|
+
dy: number,
|
|
67
|
+
): UiNode | null {
|
|
68
|
+
let depth = 0;
|
|
69
|
+
let walk: UiNode | null = root;
|
|
70
|
+
while (walk !== null) {
|
|
71
|
+
CHAIN[depth] = walk;
|
|
72
|
+
depth += 1;
|
|
73
|
+
let next: UiNode | null = null;
|
|
74
|
+
for (let i = walk.children.length - 1; i >= 0; i -= 1) {
|
|
75
|
+
const child = walk.children[i] as UiNode;
|
|
76
|
+
if (!child.hidden && uiRectHolds(child, x, y)) {
|
|
77
|
+
next = child;
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
walk = next;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
for (let i = depth - 1; i >= 0; i -= 1) {
|
|
85
|
+
const node = CHAIN[i] as UiNode;
|
|
86
|
+
if (!node.clip) continue;
|
|
87
|
+
if (scrollBy(node, dx, dy)) return node;
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
package/src/uiSlice.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A panel background that stretches without stretching its corners.
|
|
3
|
+
*
|
|
4
|
+
* Nine cells: the four corners keep their source size, the four edges stretch along one axis, and
|
|
5
|
+
* the middle stretches along both. Written into a caller's array as
|
|
6
|
+
* `x, y, w, h, u0, v0, u1, v1` per cell, in row-major order — top-left, top, top-right, left,
|
|
7
|
+
* middle, right, bottom-left, bottom, bottom-right — so the middle is always index 4.
|
|
8
|
+
*
|
|
9
|
+
* **A box narrower than its own insets scales them down rather than letting them overlap.**
|
|
10
|
+
* Overlapping corners draw the same alpha twice, which on a translucent panel is a visible seam
|
|
11
|
+
* exactly where the design was trying to be careful.
|
|
12
|
+
*
|
|
13
|
+
* **The source size is a parameter because `SpriteFrame` carries only texture coordinates.** The
|
|
14
|
+
* insets are in texels, so splitting the frame needs to know what the frame's texels span.
|
|
15
|
+
*/
|
|
16
|
+
import type { SpriteFrame } from './spriteSheet.ts';
|
|
17
|
+
|
|
18
|
+
/** Floats per placement: x, y, w, h, u0, v0, u1, v1. */
|
|
19
|
+
export const SLICE_STRIDE = 8;
|
|
20
|
+
|
|
21
|
+
export interface SliceInset {
|
|
22
|
+
readonly left: number;
|
|
23
|
+
readonly top: number;
|
|
24
|
+
readonly right: number;
|
|
25
|
+
readonly bottom: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function sliceInto(
|
|
29
|
+
out: Float32Array,
|
|
30
|
+
frame: SpriteFrame,
|
|
31
|
+
sourceWidth: number,
|
|
32
|
+
sourceHeight: number,
|
|
33
|
+
inset: SliceInset,
|
|
34
|
+
x: number,
|
|
35
|
+
y: number,
|
|
36
|
+
w: number,
|
|
37
|
+
h: number,
|
|
38
|
+
): number {
|
|
39
|
+
/*
|
|
40
|
+
* Scaled down together when the box cannot hold both, so the two corners meet exactly at the
|
|
41
|
+
* midpoint instead of overlapping. The texture coordinates are deliberately *not* scaled with
|
|
42
|
+
* them: the corner draws a smaller piece of the box out of the same piece of the source, which
|
|
43
|
+
* is squashing rather than seaming and is the better of the two wrong pictures available.
|
|
44
|
+
*/
|
|
45
|
+
const sideX = inset.left + inset.right;
|
|
46
|
+
const kx = sideX > w && sideX > 0 ? w / sideX : 1;
|
|
47
|
+
const left = inset.left * kx;
|
|
48
|
+
const right = inset.right * kx;
|
|
49
|
+
|
|
50
|
+
const sideY = inset.top + inset.bottom;
|
|
51
|
+
const ky = sideY > h && sideY > 0 ? h / sideY : 1;
|
|
52
|
+
const top = inset.top * ky;
|
|
53
|
+
const bottom = inset.bottom * ky;
|
|
54
|
+
|
|
55
|
+
const colX = [x, x + left, x + w - right];
|
|
56
|
+
const colW = [left, Math.max(0, w - left - right), right];
|
|
57
|
+
const rowY = [y, y + top, y + h - bottom];
|
|
58
|
+
const rowH = [top, Math.max(0, h - top - bottom), bottom];
|
|
59
|
+
|
|
60
|
+
const du = frame.u1 - frame.u0;
|
|
61
|
+
const dv = frame.v1 - frame.v0;
|
|
62
|
+
const uAt = [
|
|
63
|
+
frame.u0,
|
|
64
|
+
frame.u0 + (inset.left / sourceWidth) * du,
|
|
65
|
+
frame.u1 - (inset.right / sourceWidth) * du,
|
|
66
|
+
frame.u1,
|
|
67
|
+
];
|
|
68
|
+
const vAt = [
|
|
69
|
+
frame.v0,
|
|
70
|
+
frame.v0 + (inset.top / sourceHeight) * dv,
|
|
71
|
+
frame.v1 - (inset.bottom / sourceHeight) * dv,
|
|
72
|
+
frame.v1,
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
let at = 0;
|
|
76
|
+
for (let r = 0; r < 3; r += 1) {
|
|
77
|
+
for (let c = 0; c < 3; c += 1) {
|
|
78
|
+
out[at] = colX[c] as number;
|
|
79
|
+
out[at + 1] = rowY[r] as number;
|
|
80
|
+
out[at + 2] = colW[c] as number;
|
|
81
|
+
out[at + 3] = rowH[r] as number;
|
|
82
|
+
out[at + 4] = uAt[c] as number;
|
|
83
|
+
out[at + 5] = vAt[r] as number;
|
|
84
|
+
out[at + 6] = uAt[c + 1] as number;
|
|
85
|
+
out[at + 7] = vAt[r + 1] as number;
|
|
86
|
+
at += SLICE_STRIDE;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return 9;
|
|
90
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which rows a scroll offset makes visible, so that a list costs a screenful rather than a list.
|
|
3
|
+
*
|
|
4
|
+
* An asset browser with ten thousand entries has to cost what a screenful costs. Without this the
|
|
5
|
+
* scene tree is unusable the first time somebody opens a real project, which is the moment nobody
|
|
6
|
+
* wants to discover it.
|
|
7
|
+
*
|
|
8
|
+
* **Overscan is applied before clamping, not after.** Applied after, a window near either end
|
|
9
|
+
* runs past the row count and a caller indexes rows that do not exist.
|
|
10
|
+
*/
|
|
11
|
+
export interface RowWindow {
|
|
12
|
+
first: number;
|
|
13
|
+
count: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function clampWindow(first: number, lastExclusive: number, rowCount: number): RowWindow {
|
|
17
|
+
const lo = Math.max(0, Math.min(rowCount, first));
|
|
18
|
+
const hi = Math.max(lo, Math.min(rowCount, lastExclusive));
|
|
19
|
+
return { first: lo, count: hi - lo };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Uniform row heights, which is arithmetic. */
|
|
23
|
+
export function visibleRange(
|
|
24
|
+
scrollY: number,
|
|
25
|
+
viewHeight: number,
|
|
26
|
+
rowHeight: number,
|
|
27
|
+
rowCount: number,
|
|
28
|
+
overscan: number,
|
|
29
|
+
): RowWindow {
|
|
30
|
+
if (rowHeight <= 0 || rowCount <= 0) return { first: 0, count: 0 };
|
|
31
|
+
const top = Math.max(0, scrollY);
|
|
32
|
+
const first = Math.floor(top / rowHeight) - overscan;
|
|
33
|
+
const lastExclusive = Math.ceil((top + viewHeight) / rowHeight) + overscan;
|
|
34
|
+
return clampWindow(first, lastExclusive, rowCount);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Variable row heights, through a prefix-sum table of row tops the caller owns.
|
|
39
|
+
*
|
|
40
|
+
* **The caller owns the table** because only the caller knows when a row's height changed, and
|
|
41
|
+
* recomputing it here every frame would be the cost this module exists to avoid. `offsets` holds
|
|
42
|
+
* `rowCount + 1` entries: the top of each row, then the bottom of the last.
|
|
43
|
+
*/
|
|
44
|
+
export function visibleRangeVariable(
|
|
45
|
+
scrollY: number,
|
|
46
|
+
viewHeight: number,
|
|
47
|
+
offsets: Float64Array,
|
|
48
|
+
rowCount: number,
|
|
49
|
+
overscan: number,
|
|
50
|
+
): RowWindow {
|
|
51
|
+
if (rowCount <= 0) return { first: 0, count: 0 };
|
|
52
|
+
const top = Math.max(0, scrollY);
|
|
53
|
+
const bottom = top + viewHeight;
|
|
54
|
+
|
|
55
|
+
/* Largest index whose top is at or before the window's top. */
|
|
56
|
+
let lo = 0;
|
|
57
|
+
let hi = rowCount;
|
|
58
|
+
while (lo < hi) {
|
|
59
|
+
const mid = (lo + hi + 1) >> 1;
|
|
60
|
+
if ((offsets[mid] ?? 0) <= top) lo = mid;
|
|
61
|
+
else hi = mid - 1;
|
|
62
|
+
}
|
|
63
|
+
const first = lo - overscan;
|
|
64
|
+
|
|
65
|
+
/* Smallest index whose top is at or after the window's bottom. */
|
|
66
|
+
let a = 0;
|
|
67
|
+
let b = rowCount;
|
|
68
|
+
while (a < b) {
|
|
69
|
+
const mid = (a + b) >> 1;
|
|
70
|
+
if ((offsets[mid] ?? 0) >= bottom) b = mid;
|
|
71
|
+
else a = mid + 1;
|
|
72
|
+
}
|
|
73
|
+
return clampWindow(first, a + overscan, rowCount);
|
|
74
|
+
}
|