lgimgui 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +111 -0
- package/demo/public/materials.json +809 -0
- package/package.json +39 -0
- package/src/core/curve.js +71 -0
- package/src/core/frame.js +272 -0
- package/src/core/geometry.js +61 -0
- package/src/core/ids.js +31 -0
- package/src/core/input.js +294 -0
- package/src/core/layout.js +220 -0
- package/src/core/spring.js +113 -0
- package/src/core/state.js +54 -0
- package/src/core/theme.js +77 -0
- package/src/index.js +221 -0
- package/src/material/adapt.js +72 -0
- package/src/material/material.js +131 -0
- package/src/material/optics.js +49 -0
- package/src/material/params.js +83 -0
- package/src/material/presets.js +291 -0
- package/src/render/batch.js +236 -0
- package/src/render/context.js +138 -0
- package/src/render/glyphs.js +177 -0
- package/src/render/probe.js +64 -0
- package/src/render/pyramid.js +48 -0
- package/src/render/renderer.js +296 -0
- package/src/render/shaders/color.js +58 -0
- package/src/render/shaders/glass.js +241 -0
- package/src/render/shaders/ui.js +194 -0
- package/src/widgets/composite.js +355 -0
- package/src/widgets/controls.js +545 -0
- package/src/widgets/desktop.js +144 -0
- package/src/widgets/glass.js +326 -0
- package/src/widgets/primitives.js +355 -0
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import { createNode, translateNode, scaleNode } from '../core/frame.js'
|
|
2
|
+
import { springs } from '../core/spring.js'
|
|
3
|
+
import { resolveTheme, tintFamily, ACCENT } from '../core/theme.js'
|
|
4
|
+
import { rectUnion, clamp } from '../core/geometry.js'
|
|
5
|
+
import { ROLE_PRESETS, ROLE_SURFACE } from '../material/presets.js'
|
|
6
|
+
import { mixMaterial, unfocusedMaterial, darkMaterial, lenslessMaterial, thickenedMaterial, accessibleMaterial } from '../material/material.js'
|
|
7
|
+
import { analyzeBackdrop, resolvePolarity, resolveBorderFade, adaptiveScales } from '../material/adapt.js'
|
|
8
|
+
|
|
9
|
+
const ROLE_VARIANT = { clear: 'clear', thumb: 'lens', accent: 'lens' }
|
|
10
|
+
|
|
11
|
+
const ROLE_RADIUS = { window: 36, dock: 28, clear: 14, thumb: 14, control: 12, toolbar: 12, menu: 16, pane: 32, thin: 14, studio: 40 }
|
|
12
|
+
|
|
13
|
+
export function shapeRadius(props, rect, fallback) {
|
|
14
|
+
const shape = props.shape
|
|
15
|
+
if (shape === 'pill' || shape === 'circle') return Math.min(rect.w, rect.h) / 2
|
|
16
|
+
if (typeof props.radius === 'number') return props.radius
|
|
17
|
+
return fallback
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function smoothstep(a, b, x) {
|
|
21
|
+
const t = clamp((x - a) / (b - a), 0, 1)
|
|
22
|
+
return t * t * (3 - 2 * t)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function createGlass(core) {
|
|
26
|
+
const { driver, state, clock, ids, settings, materials } = core
|
|
27
|
+
|
|
28
|
+
function resolveDark(st, analysis, surface) {
|
|
29
|
+
if (settings.appearance === 'dark') return clock.animate(st, 'dark', 1, { stiffness: 120, damping: 20 })
|
|
30
|
+
if (settings.appearance === 'light') return clock.animate(st, 'dark', 0, { stiffness: 120, damping: 20 })
|
|
31
|
+
return resolvePolarity(st, analysis, surface, clock)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function presetFor(role) {
|
|
35
|
+
return materials[role] ?? ROLE_PRESETS[role] ?? ROLE_PRESETS.control
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function resolveMaterial(props, st, options) {
|
|
39
|
+
const role = props.role ?? 'control'
|
|
40
|
+
const variant = props.variant ?? ROLE_VARIANT[role] ?? 'regular'
|
|
41
|
+
let material = presetFor(role)
|
|
42
|
+
const focus = clock.animate(st, 'focus', options.focused ? 1 : 0, springs.gentle)
|
|
43
|
+
if (focus < 1) material = mixMaterial(materials.unfocused?.[role] ?? unfocusedMaterial(material), material, focus)
|
|
44
|
+
if (variant === 'regular' && options.dark > 0) material = mixMaterial(material, materials.dark?.[role] ?? darkMaterial(material), options.dark)
|
|
45
|
+
if (options.thick > 0) material = thickenedMaterial(material, options.thick)
|
|
46
|
+
if (props.tint) {
|
|
47
|
+
const roleTint = props.tint === 'accent' && !options.accent
|
|
48
|
+
const tintColor = roleTint
|
|
49
|
+
? [material.tint.r / 255, material.tint.g / 255, material.tint.b / 255, 1]
|
|
50
|
+
: props.tint === 'accent'
|
|
51
|
+
? options.accent
|
|
52
|
+
: props.tint
|
|
53
|
+
const family = tintFamily(tintColor, options.analysis.luminance)
|
|
54
|
+
const recede = 1 - focus
|
|
55
|
+
const greyed = family.map((channel, i) => (i < 3 ? channel + (0.62 - channel) * recede : channel))
|
|
56
|
+
material = {
|
|
57
|
+
...material,
|
|
58
|
+
tint: { r: greyed[0] * 255, g: greyed[1] * 255, b: greyed[2] * 255, a: Math.max(material.tint.a, props.tintOpacity ?? 0.62) },
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (variant === 'regular') {
|
|
62
|
+
const scales = adaptiveScales(options.analysis)
|
|
63
|
+
const tintResponse = (material.adaptTint ?? 100) / 100
|
|
64
|
+
const shadowResponse = (material.adaptShadow ?? 100) / 100
|
|
65
|
+
const tintScale = 1 + (scales.tint - 1) * tintResponse
|
|
66
|
+
material = { ...material, tint: { ...material.tint, a: Math.min(1, material.tint.a * tintScale) } }
|
|
67
|
+
options.shadowScale = 1 + (scales.shadow - 1) * shadowResponse
|
|
68
|
+
}
|
|
69
|
+
material = accessibleMaterial(material, settings, options.dark > 0.5 || variant === 'clear', Boolean(props.tint))
|
|
70
|
+
return { material, variant, role, focus }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function parentGlassRecede(ctx) {
|
|
74
|
+
return ctx.glass?.theme?.recede ?? 0
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function childShapes(node, radiusFallback) {
|
|
78
|
+
const shapes = []
|
|
79
|
+
for (const child of node.children) {
|
|
80
|
+
if (child.props.detached || child.props.shape === 'none') continue
|
|
81
|
+
const r = child.rect
|
|
82
|
+
if (r.w < 2 || r.h < 2) continue
|
|
83
|
+
shapes.push({
|
|
84
|
+
x: r.x,
|
|
85
|
+
y: r.y,
|
|
86
|
+
w: r.w,
|
|
87
|
+
h: r.h,
|
|
88
|
+
radius: shapeRadius(child.props, r, shapeRadius(node.props, r, radiusFallback)),
|
|
89
|
+
roundness: child.props.roundness ?? node.props.roundness ?? 5,
|
|
90
|
+
fade: child.props.reveal ?? 1,
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
return shapes
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function paint(node, ctx) {
|
|
97
|
+
const props = node.props
|
|
98
|
+
const id = node.id
|
|
99
|
+
if (ctx.glass && !props.lift && !props.inset) {
|
|
100
|
+
throw new Error(
|
|
101
|
+
`glass on glass: "${id}" is inside glass "${ctx.glass.id}". Controls inside glass are fills; use ui.pane for a thin inset pane or lift: true for a transient lifted element.`,
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
const st = state.get(id)
|
|
105
|
+
driver.markGhostSeen(id)
|
|
106
|
+
|
|
107
|
+
const target = node.rect
|
|
108
|
+
let shapeRect = target
|
|
109
|
+
const morph = props.morph ?? false
|
|
110
|
+
if (morph) {
|
|
111
|
+
if (st.mx == null) {
|
|
112
|
+
const from = (typeof props.from === 'function' ? props.from() : props.from) ?? target
|
|
113
|
+
st.mx = { value: from.x, velocity: 0 }
|
|
114
|
+
st.my = { value: from.y, velocity: 0 }
|
|
115
|
+
st.mw = { value: from.w, velocity: 0 }
|
|
116
|
+
st.mh = { value: from.h, velocity: 0 }
|
|
117
|
+
st.restArea = from.w * from.h
|
|
118
|
+
}
|
|
119
|
+
const morphSpring = props.morphSpring ?? springs.morph
|
|
120
|
+
const w = clock.animate(st, 'mw', target.w, morphSpring)
|
|
121
|
+
const h = clock.animate(st, 'mh', target.h, morphSpring)
|
|
122
|
+
if (morph === 'size') {
|
|
123
|
+
st.mx.value = target.x
|
|
124
|
+
st.my.value = target.y
|
|
125
|
+
st.mx.velocity = 0
|
|
126
|
+
st.my.velocity = 0
|
|
127
|
+
}
|
|
128
|
+
shapeRect = {
|
|
129
|
+
x: clock.animate(st, 'mx', target.x, morphSpring),
|
|
130
|
+
y: clock.animate(st, 'my', target.y, morphSpring),
|
|
131
|
+
w,
|
|
132
|
+
h,
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const scale = clock.animate(st, 'press', props.pressed ? 0.965 : 1, props.pressed ? springs.snappy : springs.fade)
|
|
136
|
+
const morphOffset = { x: shapeRect.x - target.x, y: shapeRect.y - target.y }
|
|
137
|
+
const centerX = shapeRect.x + shapeRect.w / 2
|
|
138
|
+
const centerY = shapeRect.y + shapeRect.h / 2
|
|
139
|
+
if (scale !== 1) {
|
|
140
|
+
shapeRect = { x: centerX - (shapeRect.w * scale) / 2, y: centerY - (shapeRect.h * scale) / 2, w: shapeRect.w * scale, h: shapeRect.h * scale }
|
|
141
|
+
}
|
|
142
|
+
const offset = { x: shapeRect.x - target.x, y: shapeRect.y - target.y }
|
|
143
|
+
for (const child of node.children) {
|
|
144
|
+
translateNode(child, morphOffset.x, morphOffset.y)
|
|
145
|
+
scaleNode(child, centerX, centerY, scale)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const presenceSpring = props.presenceSpring ?? springs.presence
|
|
149
|
+
const presence = clock.animateFrom(st, 'presence', props.appear === false ? 1 : 0, 1, presenceSpring)
|
|
150
|
+
const role = props.role ?? 'control'
|
|
151
|
+
const surface = props.surface ?? ROLE_SURFACE[role] ?? 'small'
|
|
152
|
+
const variant = props.variant ?? ROLE_VARIANT[role] ?? 'regular'
|
|
153
|
+
const glowTarget = props.pressed ? 1 : 0
|
|
154
|
+
if (props.pressed && !st.wasPressed) {
|
|
155
|
+
st.glowRadius = { value: 0, velocity: 0 }
|
|
156
|
+
if (props.pressPoint) st.glowPoint = props.pressPoint
|
|
157
|
+
}
|
|
158
|
+
st.wasPressed = Boolean(props.pressed)
|
|
159
|
+
const glow = clock.animate(st, 'glow', glowTarget, glowTarget ? springs.snappy : springs.fade)
|
|
160
|
+
const fullRadius = Math.max(shapeRect.w, shapeRect.h) * 1.4
|
|
161
|
+
const glowRadius = props.pressed ? clock.animate(st, 'glowRadius', fullRadius, springs.snappy) : (st.glowRadius?.value ?? fullRadius)
|
|
162
|
+
const glowPoint = st.glowPoint ?? { x: shapeRect.x + shapeRect.w / 2, y: shapeRect.y + shapeRect.h / 2 }
|
|
163
|
+
|
|
164
|
+
const drawScale = node.drawScale ?? 1
|
|
165
|
+
const radiusFallback = (materials[role]?.cornerRadius ?? ROLE_RADIUS[role] ?? 12) * drawScale
|
|
166
|
+
const roundnessFallback = materials[role]?.cornerRoundness ?? 5
|
|
167
|
+
const ownRadius = () => (typeof props.radius === 'number' ? props.radius * drawScale : shapeRadius(props, shapeRect, radiusFallback))
|
|
168
|
+
const shapes = props.merge
|
|
169
|
+
? childShapes(node, radiusFallback)
|
|
170
|
+
: [{ ...shapeRect, radius: ownRadius(), roundness: props.roundness ?? roundnessFallback }]
|
|
171
|
+
if (shapes.length === 0) shapes.push({ ...shapeRect, radius: ownRadius(), roundness: props.roundness ?? roundnessFallback })
|
|
172
|
+
let hardFirst = false
|
|
173
|
+
if (props.merge && props.bridge > 0.02) {
|
|
174
|
+
let union = null
|
|
175
|
+
for (const child of node.children) {
|
|
176
|
+
if (child.props.detached || child.props.shape === 'none') continue
|
|
177
|
+
union = rectUnion(union, child.rect)
|
|
178
|
+
}
|
|
179
|
+
if (union && union.h > 0) {
|
|
180
|
+
const h = union.h * Math.min(1, props.bridge)
|
|
181
|
+
shapes.unshift({ x: union.x, y: union.y + (union.h - h) / 2, w: union.w, h, radius: h / 2, roundness: 2 })
|
|
182
|
+
hardFirst = true
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const mergeRate = props.merge ? (props.mergeRate ?? Math.min(shapeRect.w, shapeRect.h) * 0.35) : 0
|
|
187
|
+
let bounds = null
|
|
188
|
+
for (const shape of shapes) bounds = rectUnion(bounds, shape)
|
|
189
|
+
const pad = mergeRate + 2
|
|
190
|
+
bounds = { x: bounds.x - pad, y: bounds.y - pad, w: bounds.w + pad * 2, h: bounds.h + pad * 2 }
|
|
191
|
+
|
|
192
|
+
const stratum = ctx.stratumFor(bounds, ctx.stratum + 1)
|
|
193
|
+
const analysis = analyzeBackdrop(ctx.probeFor(stratum - 1), shapeRect)
|
|
194
|
+
|
|
195
|
+
const dark = variant === 'regular' ? resolveDark(st, analysis, surface) : 0
|
|
196
|
+
const growth = st.restArea > 0 ? clamp(1 - st.restArea / Math.max(1, shapeRect.w * shapeRect.h), 0, 1) : 0
|
|
197
|
+
const focused = props.focused ?? ctx.glass?.focused ?? true
|
|
198
|
+
const accent = props.accent ?? ctx.glass?.accent ?? null
|
|
199
|
+
const options = { dark, thick: props.thick ?? Math.min(growth, 0.6), analysis, shadowScale: 1, focused, accent }
|
|
200
|
+
const resolved = resolveMaterial(props, st, options)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
const full = resolved.material
|
|
204
|
+
const material = presence < 1 ? mixMaterial(lenslessMaterial(full), full, presence) : full
|
|
205
|
+
st.resolved = material
|
|
206
|
+
const dimming = variant === 'clear' ? (props.dimming ?? 0.22) * presence : 0
|
|
207
|
+
const borderFade = material.borderAdapt && material.borderWidth > 0 ? 1 - resolveBorderFade(st, analysis, clock) : 1
|
|
208
|
+
const border = settings.increaseContrast ? 1.5 : material.borderWidth * presence * (borderFade > 0.02 ? 1 : 0)
|
|
209
|
+
const theme = variant === 'lens' && ctx.theme ? { ...ctx.theme } : resolveTheme(variant === 'clear' ? Math.max(dark, 1) : dark, settings)
|
|
210
|
+
if (accent && !settings.increaseContrast) {
|
|
211
|
+
theme.accent = accent
|
|
212
|
+
theme.accentPress = [accent[0] * 0.88, accent[1] * 0.88, accent[2] * 0.88, accent[3]]
|
|
213
|
+
theme.focusRing = [accent[0], accent[1], accent[2], 0.7]
|
|
214
|
+
}
|
|
215
|
+
if (props.tint) theme.fg = theme.onAccent
|
|
216
|
+
const inheritedRecede = parentGlassRecede(ctx)
|
|
217
|
+
const recede = Math.max(inheritedRecede, settings.increaseContrast ? 0 : 1 - resolved.focus)
|
|
218
|
+
theme.recede = recede
|
|
219
|
+
if (recede > 0) {
|
|
220
|
+
const toward = (color, target, amount) => color.map((channel, i) => channel + (target[i] - channel) * amount)
|
|
221
|
+
const grey = theme.fg2.slice(0, 3).concat([1])
|
|
222
|
+
theme.fg = toward(theme.fg, theme.fg2, recede)
|
|
223
|
+
theme.accent = toward(theme.accent, [grey[0], grey[1], grey[2], 0.55], recede)
|
|
224
|
+
theme.switchOn = toward(theme.switchOn, [grey[0], grey[1], grey[2], 0.5], recede)
|
|
225
|
+
theme.field = [theme.field[0], theme.field[1], theme.field[2], theme.field[3] * (1 - recede * 0.45)]
|
|
226
|
+
theme.accentPress = theme.accent
|
|
227
|
+
theme.knob = toward(theme.knob, [0.97, 0.97, 0.98, 1], recede * 0.6)
|
|
228
|
+
theme.track = [theme.track[0], theme.track[1], theme.track[2], Math.min(1, theme.track[3] * (1 + recede * 0.25))]
|
|
229
|
+
theme.selected = [theme.selected[0], theme.selected[1], theme.selected[2], theme.selected[3] * (1 - recede * 0.4)]
|
|
230
|
+
theme.hover = [theme.hover[0], theme.hover[1], theme.hover[2], theme.hover[3] * (1 - recede * 0.5)]
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const op = {
|
|
234
|
+
type: 'glass',
|
|
235
|
+
id,
|
|
236
|
+
shapes,
|
|
237
|
+
mergeRate,
|
|
238
|
+
hardFirst,
|
|
239
|
+
material,
|
|
240
|
+
dimming,
|
|
241
|
+
glow: [glowPoint.x, glowPoint.y, glowRadius, glow * (props.glowStrength ?? 1)],
|
|
242
|
+
coverageScale: smoothstep(0, 0.35, presence),
|
|
243
|
+
border,
|
|
244
|
+
borderColor: settings.increaseContrast
|
|
245
|
+
? (dark > 0.5 ? [1, 1, 1, 0.9] : [0, 0, 0, 0.85])
|
|
246
|
+
: [material.borderColor.r / 255, material.borderColor.g / 255, material.borderColor.b / 255, material.borderColor.a * borderFade],
|
|
247
|
+
shadowScale: options.shadowScale * presence,
|
|
248
|
+
shadowClip: ctx.glass?.shape ?? null,
|
|
249
|
+
bounds,
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const parentGlass = ctx.glass
|
|
253
|
+
const parentTheme = ctx.theme
|
|
254
|
+
const parentStratum = ctx.stratum
|
|
255
|
+
ctx.stratum = stratum
|
|
256
|
+
ctx.emitGlass(op)
|
|
257
|
+
if (props.hit) ctx.hit(node, props.hitRect ?? shapeRect)
|
|
258
|
+
ctx.glass = { id, dark, theme, rect: shapeRect, stratum: ctx.stratum, variant, offset, presence, focused, accent, shape: shapes[shapes.length - 1] }
|
|
259
|
+
ctx.theme = theme
|
|
260
|
+
const previousClip = props.clipContent !== false ? ctx.pushClip(shapeRect) : ctx.clip
|
|
261
|
+
const layer = ctx.ensure(ctx.stratum)
|
|
262
|
+
const start = layer.ops.length
|
|
263
|
+
ctx.paintChildren(node, ctx)
|
|
264
|
+
const overlays = layer.ops.slice(start)
|
|
265
|
+
if (props.clipContent !== false) ctx.popClip(previousClip)
|
|
266
|
+
if (props.resizable) registerResizeHits(node, ctx, shapeRect)
|
|
267
|
+
ctx.glass = parentGlass
|
|
268
|
+
ctx.theme = parentTheme
|
|
269
|
+
ctx.stratum = parentStratum
|
|
270
|
+
|
|
271
|
+
if (props.appear !== false) {
|
|
272
|
+
driver.ghost(id, (ghostCtx) => {
|
|
273
|
+
const ghostState = state.get(id)
|
|
274
|
+
const fade = clock.animate(ghostState, 'presence', 0, presenceSpring)
|
|
275
|
+
if (fade <= 0.02) return true
|
|
276
|
+
ghostCtx.stratum = ghostCtx.stratumFor(op.bounds, parentStratum + 1)
|
|
277
|
+
ghostCtx.clip = op.clip
|
|
278
|
+
ghostCtx.emitGlass({
|
|
279
|
+
...op,
|
|
280
|
+
material: mixMaterial(lenslessMaterial(full), full, fade),
|
|
281
|
+
coverageScale: smoothstep(0, 0.35, fade),
|
|
282
|
+
shadowScale: options.shadowScale * fade,
|
|
283
|
+
glow: [0, 0, 0, 0],
|
|
284
|
+
})
|
|
285
|
+
const target = ghostCtx.ensure(ghostCtx.stratum).ops
|
|
286
|
+
for (const overlay of overlays) {
|
|
287
|
+
if (!overlay.color) continue
|
|
288
|
+
target.push({ ...overlay, color: [overlay.color[0], overlay.color[1], overlay.color[2], overlay.color[3] * fade] })
|
|
289
|
+
}
|
|
290
|
+
return false
|
|
291
|
+
})
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function registerResizeHits(node, ctx, rect) {
|
|
296
|
+
const band = 8
|
|
297
|
+
const corner = 18
|
|
298
|
+
const half = band / 2
|
|
299
|
+
const regions = {
|
|
300
|
+
left: { x: rect.x - half, y: rect.y + corner, w: band, h: rect.h - corner * 2 },
|
|
301
|
+
right: { x: rect.x + rect.w - half, y: rect.y + corner, w: band, h: rect.h - corner * 2 },
|
|
302
|
+
top: { x: rect.x + corner, y: rect.y - half, w: rect.w - corner * 2, h: band },
|
|
303
|
+
bottom: { x: rect.x + corner, y: rect.y + rect.h - half, w: rect.w - corner * 2, h: band },
|
|
304
|
+
topLeft: { x: rect.x - half, y: rect.y - half, w: corner, h: corner },
|
|
305
|
+
topRight: { x: rect.x + rect.w - corner + half, y: rect.y - half, w: corner, h: corner },
|
|
306
|
+
bottomLeft: { x: rect.x - half, y: rect.y + rect.h - corner + half, w: corner, h: corner },
|
|
307
|
+
bottomRight: { x: rect.x + rect.w - corner + half, y: rect.y + rect.h - corner + half, w: corner, h: corner },
|
|
308
|
+
}
|
|
309
|
+
const previousClip = ctx.clip
|
|
310
|
+
ctx.clip = null
|
|
311
|
+
for (const [name, region] of Object.entries(regions)) ctx.hit({ id: node.id + '/resize/' + name, layer: node.layer }, region)
|
|
312
|
+
ctx.clip = previousClip
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function glass(props, fn) {
|
|
316
|
+
const id = props.id ?? ids.make(props.key ?? props.label ?? 'glass')
|
|
317
|
+
const node = createNode('glass', { direction: 'column', ...props, id })
|
|
318
|
+
node.paint = paint
|
|
319
|
+
driver.open(node)
|
|
320
|
+
if (fn) fn(node)
|
|
321
|
+
driver.close()
|
|
322
|
+
return node
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return { glass, paint }
|
|
326
|
+
}
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import { createNode } from '../core/frame.js'
|
|
2
|
+
import { cubicPoints, strokeQuads } from '../core/curve.js'
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_FONT = {
|
|
5
|
+
family: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Helvetica Neue', 'Segoe UI', system-ui, sans-serif",
|
|
6
|
+
size: 13,
|
|
7
|
+
weight: 500,
|
|
8
|
+
lineHeight: 1.3,
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function createPrimitives(core) {
|
|
12
|
+
const { driver, renderer, ids, state, clock } = core
|
|
13
|
+
const atlas = renderer.atlas
|
|
14
|
+
|
|
15
|
+
function font(props) {
|
|
16
|
+
return {
|
|
17
|
+
...DEFAULT_FONT,
|
|
18
|
+
...(props.font ?? {}),
|
|
19
|
+
size: props.size ?? props.font?.size ?? DEFAULT_FONT.size,
|
|
20
|
+
weight: props.weight ?? props.font?.weight ?? DEFAULT_FONT.weight,
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function box(props = {}, fn) {
|
|
25
|
+
const node = createNode('box', props)
|
|
26
|
+
if (props.fill || props.stroke) node.paint = paintBox
|
|
27
|
+
driver.open(node)
|
|
28
|
+
if (fn) fn(node)
|
|
29
|
+
driver.close()
|
|
30
|
+
return node
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function paintBox(node, ctx) {
|
|
34
|
+
const props = node.props
|
|
35
|
+
const k = node.drawScale ?? 1
|
|
36
|
+
if (props.fill) {
|
|
37
|
+
ctx.emit({ type: 'rect', rect: node.rect, radius: (props.radius ?? 0) * k, roundness: props.roundness ?? 5, color: resolveColor(props.fill, ctx) })
|
|
38
|
+
}
|
|
39
|
+
if (props.stroke) {
|
|
40
|
+
ctx.emit({ type: 'stroke', rect: node.rect, radius: (props.radius ?? 0) * k, roundness: props.roundness ?? 5, color: resolveColor(props.stroke, ctx), width: (props.strokeWidth ?? 1) * k })
|
|
41
|
+
}
|
|
42
|
+
ctx.paintChildren(node, ctx)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function transform(props = {}, fn) {
|
|
46
|
+
const { scale, tx, ty, ...rest } = props
|
|
47
|
+
const node = createNode('transform', { direction: 'stack', x: 0, y: 0, w: 'fill', h: 'fill', ...rest, transform: { scale: scale ?? 1, tx: tx ?? 0, ty: ty ?? 0 } })
|
|
48
|
+
driver.open(node)
|
|
49
|
+
if (fn) fn(node)
|
|
50
|
+
driver.close()
|
|
51
|
+
return node
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function emitStroke(ctx, points, props) {
|
|
55
|
+
const color = resolveColor(props.color ?? 'fg', ctx)
|
|
56
|
+
for (const quad of strokeQuads(points, { width: props.width ?? 2, dash: props.dash, gap: props.gap })) {
|
|
57
|
+
ctx.emit({ type: 'rect', rect: quad.rect, radius: quad.radius, roundness: 2, rotation: quad.rotation, color })
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function polyline(points, props = {}) {
|
|
62
|
+
const node = createNode('polyline', { x: 0, y: 0, w: 0, h: 0, ...props })
|
|
63
|
+
node.paint = (n, ctx) => emitStroke(ctx, points, props)
|
|
64
|
+
return driver.add(node)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function curve(shape, props = {}) {
|
|
68
|
+
const node = createNode('curve', { x: 0, y: 0, w: 0, h: 0, ...props })
|
|
69
|
+
node.paint = (n, ctx) => emitStroke(ctx, cubicPoints(shape, { step: props.step }), props)
|
|
70
|
+
return driver.add(node)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function resolveColor(color, ctx) {
|
|
74
|
+
if (typeof color === 'string') return ctx.theme[color] ?? [1, 0, 1, 1]
|
|
75
|
+
return color
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function row(props = {}, fn) {
|
|
79
|
+
return box({ direction: 'row', align: 'center', ...props }, fn)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function column(props = {}, fn) {
|
|
83
|
+
return box({ direction: 'column', align: 'stretch', ...props }, fn)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function stack(props = {}, fn) {
|
|
87
|
+
return box({ direction: 'stack', align: 'stretch', ...props }, fn)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function spacer(size) {
|
|
91
|
+
if (size == null) return driver.add(createNode('spacer', { fr: 1, w: 'fill', h: 'fill' }))
|
|
92
|
+
return driver.add(createNode('spacer', { w: size, h: size }))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function separator(props = {}) {
|
|
96
|
+
const node = createNode('separator', { h: props.vertical ? 'fill' : 1, w: props.vertical ? 1 : 'fill', ...props })
|
|
97
|
+
node.paint = (n, ctx) => {
|
|
98
|
+
ctx.emit({ type: 'rect', rect: n.rect, color: ctx.theme.separator })
|
|
99
|
+
}
|
|
100
|
+
return driver.add(node)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function text(value, props = {}) {
|
|
104
|
+
const f = font(props)
|
|
105
|
+
const content = String(value ?? '')
|
|
106
|
+
const node = createNode('text', { ...props, label: content })
|
|
107
|
+
node.intrinsic = () => {
|
|
108
|
+
const size = atlas.measure(content, f)
|
|
109
|
+
return { w: props.maxWidth ? Math.min(size.w, props.maxWidth) : size.w, h: size.h }
|
|
110
|
+
}
|
|
111
|
+
node.paint = (n, ctx) => {
|
|
112
|
+
const color = props.color ? resolveColor(props.color, ctx) : ctx.theme.fg
|
|
113
|
+
const drawScale = n.drawScale ?? 1
|
|
114
|
+
const naturalW = n.natural.w * drawScale
|
|
115
|
+
const naturalH = n.natural.h * drawScale
|
|
116
|
+
const shown = drawScale === 1 && n.rect.w < naturalW - 0.5 ? atlas.truncate(content, f, n.rect.w) : content
|
|
117
|
+
const width = shown === content ? naturalW : atlas.measure(shown, f).w
|
|
118
|
+
let x = n.rect.x
|
|
119
|
+
if (props.align === 'center') x = n.rect.x + (n.rect.w - width) / 2
|
|
120
|
+
else if (props.align === 'end') x = n.rect.x + n.rect.w - width
|
|
121
|
+
const y = n.rect.y + (n.rect.h - naturalH) / 2
|
|
122
|
+
ctx.emit({ type: 'text', x, y, text: shown, font: f, color, scale: drawScale, vibrancy: ctx.glass ? (props.vibrancy ?? 0.35) : 0 })
|
|
123
|
+
}
|
|
124
|
+
return driver.add(node)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function label(value, props = {}) {
|
|
128
|
+
return text(value, { color: 'fg2', weight: 500, ...props })
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function heading(value, props = {}) {
|
|
132
|
+
return text(value, { size: 17, weight: 700, ...props })
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function rect(props = {}) {
|
|
136
|
+
const node = createNode('rect', props)
|
|
137
|
+
node.paint = (n, ctx) => {
|
|
138
|
+
const radius = (props.radius ?? 0) * (n.drawScale ?? 1)
|
|
139
|
+
if (props.gradient) {
|
|
140
|
+
ctx.emit({ type: 'gradient', rect: n.rect, radius, roundness: props.roundness ?? 5, color: resolveColor(props.fill, ctx), color2: resolveColor(props.gradient, ctx), horizontal: props.horizontal })
|
|
141
|
+
} else {
|
|
142
|
+
ctx.emit({ type: 'rect', rect: n.rect, radius, roundness: props.roundness ?? 5, color: resolveColor(props.fill ?? 'fg', ctx), rotation: props.rotation })
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return driver.add(node)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function image(source, props = {}) {
|
|
149
|
+
const node = createNode('image', props)
|
|
150
|
+
node.intrinsic = () => ({ w: props.w ?? source?.width ?? 0, h: props.h ?? source?.height ?? 0 })
|
|
151
|
+
node.paint = (n, ctx) => {
|
|
152
|
+
if (!source) return
|
|
153
|
+
const fit = props.fit ?? 'cover'
|
|
154
|
+
let uv = [0, 0, 1, 1]
|
|
155
|
+
const r = n.rect
|
|
156
|
+
const imageAspect = source.width / source.height
|
|
157
|
+
const rectAspect = r.w / r.h
|
|
158
|
+
if (fit === 'cover') {
|
|
159
|
+
if (rectAspect > imageAspect) {
|
|
160
|
+
const scale = imageAspect / rectAspect
|
|
161
|
+
uv = [0, 0.5 - scale / 2, 1, 0.5 + scale / 2]
|
|
162
|
+
} else {
|
|
163
|
+
const scale = rectAspect / imageAspect
|
|
164
|
+
uv = [0.5 - scale / 2, 0, 0.5 + scale / 2, 1]
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
ctx.emit({ type: 'image', rect: r, image: source, uv, radius: props.radius ?? 0, roundness: props.roundness ?? 5, color: props.color ?? [1, 1, 1, props.opacity ?? 1] })
|
|
168
|
+
}
|
|
169
|
+
return driver.add(node)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const ICONS = {
|
|
173
|
+
close: (r, c, emit) => {
|
|
174
|
+
emit({ rect: bar(r, 0.62, 0.11), radius: 1, color: c, rotation: Math.PI / 4 })
|
|
175
|
+
emit({ rect: bar(r, 0.62, 0.11), radius: 1, color: c, rotation: -Math.PI / 4 })
|
|
176
|
+
},
|
|
177
|
+
plus: (r, c, emit) => {
|
|
178
|
+
emit({ rect: bar(r, 0.62, 0.11), radius: 1, color: c })
|
|
179
|
+
emit({ rect: bar(r, 0.62, 0.11), radius: 1, color: c, rotation: Math.PI / 2 })
|
|
180
|
+
},
|
|
181
|
+
minus: (r, c, emit) => emit({ rect: bar(r, 0.62, 0.11), radius: 1, color: c }),
|
|
182
|
+
check: (r, c, emit) => {
|
|
183
|
+
const w = r.w
|
|
184
|
+
emit({ rect: { x: r.x + w * 0.22, y: r.y + w * 0.56, w: w * 0.3, h: w * 0.12 }, radius: 1, color: c, rotation: Math.PI / 4 })
|
|
185
|
+
emit({ rect: { x: r.x + w * 0.38, y: r.y + w * 0.45, w: w * 0.52, h: w * 0.12 }, radius: 1, color: c, rotation: -Math.PI / 4 })
|
|
186
|
+
},
|
|
187
|
+
chevronDown: (r, c, emit) => chevron(r, c, emit, Math.PI),
|
|
188
|
+
chevronUp: (r, c, emit) => chevron(r, c, emit, 0),
|
|
189
|
+
chevronRight: (r, c, emit) => chevron(r, c, emit, Math.PI / 2),
|
|
190
|
+
chevronLeft: (r, c, emit) => chevron(r, c, emit, -Math.PI / 2),
|
|
191
|
+
dot: (r, c, emit) => emit({ rect: inset(r, 0.3), radius: 99, color: c }),
|
|
192
|
+
circle: (r, c, emit) => emit({ type: 'stroke', rect: inset(r, 0.12), radius: 99, color: c, width: r.w * 0.1 }),
|
|
193
|
+
square: (r, c, emit) => emit({ rect: inset(r, 0.2), radius: r.w * 0.12, color: c }),
|
|
194
|
+
grid: (r, c, emit) => {
|
|
195
|
+
const s = r.w * 0.32
|
|
196
|
+
const g = r.w * 0.1
|
|
197
|
+
const x0 = r.x + (r.w - s * 2 - g) / 2
|
|
198
|
+
const y0 = r.y + (r.h - s * 2 - g) / 2
|
|
199
|
+
for (let i = 0; i < 2; i++) for (let j = 0; j < 2; j++) emit({ rect: { x: x0 + i * (s + g), y: y0 + j * (s + g), w: s, h: s }, radius: s * 0.28, color: c })
|
|
200
|
+
},
|
|
201
|
+
menu: (r, c, emit) => {
|
|
202
|
+
for (let i = 0; i < 3; i++) emit({ rect: { x: r.x + r.w * 0.2, y: r.y + r.h * (0.28 + i * 0.22) - r.h * 0.05, w: r.w * 0.6, h: r.h * 0.1 }, radius: 1, color: c })
|
|
203
|
+
},
|
|
204
|
+
search: (r, c, emit) => {
|
|
205
|
+
emit({ type: 'stroke', rect: { x: r.x + r.w * 0.16, y: r.y + r.h * 0.16, w: r.w * 0.5, h: r.h * 0.5 }, radius: 99, color: c, width: r.w * 0.1 })
|
|
206
|
+
emit({ rect: { x: r.x + r.w * 0.56, y: r.y + r.h * 0.62, w: r.w * 0.3, h: r.w * 0.11 }, radius: 1, color: c, rotation: Math.PI / 4 })
|
|
207
|
+
},
|
|
208
|
+
gear: (r, c, emit) => {
|
|
209
|
+
for (let i = 0; i < 4; i++) emit({ rect: bar(r, 0.8, 0.2), radius: r.w * 0.05, color: c, rotation: (i * Math.PI) / 4 })
|
|
210
|
+
emit({ type: 'stroke', rect: inset(r, 0.22), radius: 99, color: c, width: r.w * 0.16 })
|
|
211
|
+
},
|
|
212
|
+
sun: (r, c, emit) => {
|
|
213
|
+
emit({ rect: inset(r, 0.3), radius: 99, color: c })
|
|
214
|
+
for (let i = 0; i < 4; i++) emit({ rect: bar(r, 0.9, 0.08), radius: 1, color: c, rotation: (i * Math.PI) / 4 })
|
|
215
|
+
emit({ rect: inset(r, 0.22), radius: 99, color: [0, 0, 0, 0] })
|
|
216
|
+
},
|
|
217
|
+
moon: (r, c, emit) => emit({ rect: inset(r, 0.18), radius: 99, color: c }),
|
|
218
|
+
play: (r, c, emit) => emit({ rect: { x: r.x + r.w * 0.3, y: r.y + r.h * 0.25, w: r.w * 0.5, h: r.h * 0.5 }, radius: r.w * 0.08, color: c, rotation: Math.PI / 4 }),
|
|
219
|
+
pause: (r, c, emit) => {
|
|
220
|
+
emit({ rect: { x: r.x + r.w * 0.26, y: r.y + r.h * 0.22, w: r.w * 0.16, h: r.h * 0.56 }, radius: 2, color: c })
|
|
221
|
+
emit({ rect: { x: r.x + r.w * 0.58, y: r.y + r.h * 0.22, w: r.w * 0.16, h: r.h * 0.56 }, radius: 2, color: c })
|
|
222
|
+
},
|
|
223
|
+
back: (r, c, emit) => chevron(r, c, emit, -Math.PI / 2),
|
|
224
|
+
forward: (r, c, emit) => chevron(r, c, emit, Math.PI / 2),
|
|
225
|
+
more: (r, c, emit) => {
|
|
226
|
+
for (let i = 0; i < 3; i++) emit({ rect: { x: r.x + r.w * (0.14 + i * 0.3), y: r.y + r.h * 0.44, w: r.w * 0.14, h: r.w * 0.14 }, radius: 99, color: c })
|
|
227
|
+
},
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function bar(r, length, thickness) {
|
|
231
|
+
const w = r.w * length
|
|
232
|
+
const h = r.w * thickness
|
|
233
|
+
return { x: r.x + (r.w - w) / 2, y: r.y + (r.h - h) / 2, w, h }
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function inset(r, amount) {
|
|
237
|
+
return { x: r.x + r.w * amount, y: r.y + r.h * amount, w: r.w * (1 - amount * 2), h: r.h * (1 - amount * 2) }
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function chevron(r, c, emit, rotation) {
|
|
241
|
+
const length = r.w * 0.42
|
|
242
|
+
const thick = r.w * 0.11
|
|
243
|
+
const cx = r.x + r.w / 2
|
|
244
|
+
const cy = r.y + r.h / 2
|
|
245
|
+
const cos = Math.cos(rotation)
|
|
246
|
+
const sin = Math.sin(rotation)
|
|
247
|
+
const arm = (dir) => {
|
|
248
|
+
const ox = dir * length * 0.33
|
|
249
|
+
const oy = -length * 0.18
|
|
250
|
+
const x = cx + ox * cos - oy * sin
|
|
251
|
+
const y = cy + ox * sin + oy * cos
|
|
252
|
+
return { rect: { x: x - length / 2, y: y - thick / 2, w: length, h: thick }, radius: 1, color: c, rotation: rotation + dir * Math.PI / 4 }
|
|
253
|
+
}
|
|
254
|
+
emit(arm(-1))
|
|
255
|
+
emit(arm(1))
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function icon(name, props = {}) {
|
|
259
|
+
const size = props.size ?? 16
|
|
260
|
+
const node = createNode('icon', { ...props, w: size, h: size })
|
|
261
|
+
node.paint = (n, ctx) => {
|
|
262
|
+
const color = props.color ? resolveColor(props.color, ctx) : ctx.theme.fg
|
|
263
|
+
const draw = ICONS[name]
|
|
264
|
+
if (!draw) {
|
|
265
|
+
ctx.emit({ type: 'rect', rect: inset(n.rect, 0.2), radius: 3, color })
|
|
266
|
+
return
|
|
267
|
+
}
|
|
268
|
+
draw(n.rect, color, (op) => ctx.emit({ type: op.type ?? 'rect', roundness: 2, ...op }))
|
|
269
|
+
}
|
|
270
|
+
return driver.add(node)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function scroll(props = {}, fn) {
|
|
274
|
+
const id = ids.make(props.key ?? 'scroll')
|
|
275
|
+
const st = state.get(id, () => ({ offset: 0 }))
|
|
276
|
+
const outer = createNode('scroll', { direction: 'stack', align: 'stretch', ...props, pad: 0, gap: 0, id })
|
|
277
|
+
outer.paint = paintScroll
|
|
278
|
+
driver.open(outer)
|
|
279
|
+
const horizontal = Boolean(props.horizontal)
|
|
280
|
+
const inner = createNode('box', {
|
|
281
|
+
direction: horizontal ? 'row' : 'column',
|
|
282
|
+
align: horizontal ? 'center' : 'stretch',
|
|
283
|
+
gap: props.gap,
|
|
284
|
+
pad: props.pad,
|
|
285
|
+
detached: false,
|
|
286
|
+
x: 0,
|
|
287
|
+
y: 0,
|
|
288
|
+
w: horizontal ? undefined : 'fill',
|
|
289
|
+
h: horizontal ? 'fill' : undefined,
|
|
290
|
+
})
|
|
291
|
+
driver.open(inner)
|
|
292
|
+
if (fn) fn(outer)
|
|
293
|
+
driver.close()
|
|
294
|
+
driver.close()
|
|
295
|
+
outer.inner = inner
|
|
296
|
+
outer.scrollState = st
|
|
297
|
+
return outer
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function paintScroll(node, ctx) {
|
|
301
|
+
const st = node.scrollState
|
|
302
|
+
const frame = driver.frame
|
|
303
|
+
const inner = node.inner
|
|
304
|
+
const horizontal = Boolean(node.props.horizontal)
|
|
305
|
+
const maxOffset = Math.max(0, horizontal ? inner.rect.w - node.rect.w : inner.rect.h - node.rect.h)
|
|
306
|
+
if (horizontal) {
|
|
307
|
+
if (frame.wheelTargetX === node.id) st.offset += frame.wheelX
|
|
308
|
+
} else if (frame.wheelTarget === node.id) st.offset += frame.wheelY
|
|
309
|
+
st.offset = Math.max(0, Math.min(maxOffset, st.offset))
|
|
310
|
+
const offset = clock.ease(st, 'shown', st.offset, 22)
|
|
311
|
+
if (horizontal) translate(inner, -offset, 0)
|
|
312
|
+
else translate(inner, 0, -offset)
|
|
313
|
+
ctx.scroll(node)
|
|
314
|
+
const previous = ctx.pushClip(node.props.clipRect ?? node.rect)
|
|
315
|
+
const previousFade = ctx.fade
|
|
316
|
+
const edge = node.props.edge
|
|
317
|
+
if (edge && !horizontal) {
|
|
318
|
+
const size = edge.size ?? 32
|
|
319
|
+
const top = edge.top !== false && offset > 0.5
|
|
320
|
+
const bottom = edge.bottom !== false && offset < maxOffset - 0.5
|
|
321
|
+
ctx.fade = {
|
|
322
|
+
topStart: node.rect.y,
|
|
323
|
+
topEnd: top ? node.rect.y + size : node.rect.y,
|
|
324
|
+
bottomStart: bottom ? node.rect.y + node.rect.h - size : node.rect.y + node.rect.h,
|
|
325
|
+
bottomEnd: node.rect.y + node.rect.h,
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
ctx.paintChildren(node, ctx)
|
|
329
|
+
ctx.fade = previousFade
|
|
330
|
+
ctx.popClip(previous)
|
|
331
|
+
if (!horizontal && maxOffset > 0) {
|
|
332
|
+
const trackH = node.rect.h - 8
|
|
333
|
+
const thumbH = Math.max(24, (node.rect.h / inner.rect.h) * trackH)
|
|
334
|
+
const thumbY = node.rect.y + 4 + (offset / maxOffset) * (trackH - thumbH)
|
|
335
|
+
const alpha = clock.ease(st, 'barAlpha', Math.abs(offset - (st.lastOffset ?? offset)) > 0.01 || frame.hot === node.id ? 1 : 0, 4)
|
|
336
|
+
st.lastOffset = offset
|
|
337
|
+
ctx.emit({ type: 'rect', rect: { x: node.rect.x + node.rect.w - 8, y: thumbY, w: 4, h: thumbH }, radius: 2, color: [ctx.theme.fg3[0], ctx.theme.fg3[1], ctx.theme.fg3[2], ctx.theme.fg3[3] * alpha] })
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function translate(node, dx, dy) {
|
|
342
|
+
node.rect = { ...node.rect, x: node.rect.x + dx, y: node.rect.y + dy }
|
|
343
|
+
for (const child of node.children) translate(child, dx, dy)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function popup(props, fn) {
|
|
347
|
+
const node = createNode('popup', { direction: 'column', align: 'stretch', ...props, detached: true })
|
|
348
|
+
driver.openPopup(node)
|
|
349
|
+
if (fn) fn(node)
|
|
350
|
+
driver.close()
|
|
351
|
+
return node
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return { box, row, column, stack, transform, spacer, separator, text, label, heading, rect, image, icon, polyline, curve, scroll, popup, font, resolveColor }
|
|
355
|
+
}
|