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.
@@ -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
+ }