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,545 @@
1
+ import { createNode } from '../core/frame.js'
2
+ import { springs, springForDuration } from '../core/spring.js'
3
+ import { clamp } from '../core/geometry.js'
4
+ import { ACCENT, withAlpha } from '../core/theme.js'
5
+
6
+ const BUTTON_HEIGHT = 34
7
+ const COMPACT_HEIGHT = 28
8
+
9
+ export function createControls(core, primitives, glassWidget) {
10
+ const { driver, ids, state, clock, interaction, insideGlass, settings } = core
11
+ const { box, row, text, icon } = primitives
12
+
13
+ function pressPoint() {
14
+ const f = driver.frame
15
+ return { x: f.dragOrigin.x, y: f.dragOrigin.y }
16
+ }
17
+
18
+ function focusRing(ctx, rect, radius, it) {
19
+ if (!it.focused || !it.keyboard) return
20
+ ctx.emit({ type: 'stroke', rect: { x: rect.x - 2, y: rect.y - 2, w: rect.w + 4, h: rect.h + 4 }, radius: radius + 2, color: ctx.theme.focusRing, width: 2 })
21
+ }
22
+
23
+ function buttonContent(labelText, props) {
24
+ if (props.content) return props.content
25
+ return () => {
26
+ if (props.icon) icon(props.icon, { size: props.iconSize ?? 16, color: props.primary ? 'onAccent' : props.color })
27
+ if (labelText) text(labelText, { weight: 600, size: props.size ?? 13, color: props.primary ? 'onAccent' : props.color, vibrancy: props.primary ? 0 : undefined })
28
+ }
29
+ }
30
+
31
+ function overlayButtonPaint(it, props) {
32
+ return (node, ctx) => {
33
+ const st = state.get(node.id)
34
+ const r = node.rect
35
+ const radius = props.radius ?? r.h / 2
36
+ ctx.hit(node)
37
+ const hover = clock.animate(st, 'hover', it.hot && !it.active ? 1 : 0, springs.gentle)
38
+ const pressed = it.active && it.hot
39
+ const press = clock.animate(st, 'press', pressed ? 1 : 0, pressed ? springs.snappy : springs.fade)
40
+ const scale = 1 - press * 0.04
41
+ const drawn = { x: r.x + (r.w - r.w * scale) / 2, y: r.y + (r.h - r.h * scale) / 2, w: r.w * scale, h: r.h * scale }
42
+ const theme = ctx.theme
43
+ if (props.primary) {
44
+ const fill = props.tint ?? theme.accent
45
+ if (settings.increaseContrast || (ctx.theme.recede ?? 0) > 0.5) {
46
+ ctx.emit({ type: 'rect', rect: drawn, radius, color: [fill[0], fill[1], fill[2], fill[3] * (1 - press * 0.12)] })
47
+ } else {
48
+ ctx.emit({ type: 'tintedFill', rect: drawn, radius, color: [fill[0], fill[1], fill[2], 0.85 * (1 - press * 0.1)], tone: 0.5 })
49
+ }
50
+ } else if (props.selected) {
51
+ ctx.emit({ type: 'rect', rect: drawn, radius, color: theme.selected })
52
+ } else if (props.bordered) {
53
+ ctx.emit({ type: 'rect', rect: drawn, radius, color: withAlpha(theme.hover, 0.8) })
54
+ }
55
+ if (hover > 0.001) ctx.emit({ type: 'rect', rect: drawn, radius, color: withAlpha(theme.hover, hover) })
56
+ if (press > 0.001) ctx.emit({ type: 'rect', rect: drawn, radius, color: withAlpha(theme.press, press) })
57
+ if (settings.increaseContrast && props.bordered) ctx.emit({ type: 'stroke', rect: drawn, radius, color: theme.fg, width: 1 })
58
+ focusRing(ctx, drawn, radius, it)
59
+ ctx.paintChildren(node, ctx)
60
+ }
61
+ }
62
+
63
+ function button(labelText, props = {}) {
64
+ const id = props.id ?? ids.make(props.key ?? labelText ?? props.icon ?? 'button')
65
+ const it = interaction(id, props)
66
+ const height = props.compact ? COMPACT_HEIGHT : props.h ?? BUTTON_HEIGHT
67
+ const pad = props.pad ?? [0, labelText ? 16 : (height - 16) / 2]
68
+ const content = buttonContent(labelText, props)
69
+ const layoutProps = { direction: 'row', gap: 6, align: 'center', justify: 'center', h: height, pad, w: props.w, fr: props.fr, minW: props.minW ?? height }
70
+ const inside = insideGlass()
71
+ const ownGlass = props.glass ?? (!inside || (core.toolbarDepth > 0 && core.capsuleDepth === 0) || (props.primary && core.capsuleDepth === 0))
72
+ if (!ownGlass) {
73
+ const node = box({ ...layoutProps, id, key: props.key ?? labelText }, content)
74
+ node.paint = overlayButtonPaint(it, props)
75
+ } else {
76
+ glassWidget.glass(
77
+ {
78
+ ...layoutProps,
79
+ id,
80
+ key: props.key ?? labelText,
81
+ role: props.role ?? (props.primary ? 'accent' : inside ? 'toolbar' : 'control'),
82
+ shape: props.shape,
83
+ radius: props.radius,
84
+ inset: inside,
85
+ hit: true,
86
+ pressed: it.active && it.hot,
87
+ pressPoint: it.pressed ? pressPoint() : undefined,
88
+ tint: props.primary ? (props.tint ?? 'accent') : undefined,
89
+ appear: props.appear,
90
+ focused: props.focused,
91
+ },
92
+ () => {
93
+ content()
94
+ },
95
+ )
96
+ }
97
+ return it.clicked
98
+ }
99
+
100
+ function capsule(props = {}, fn) {
101
+ core.capsuleDepth += 1
102
+ const inside = insideGlass()
103
+ const node = glassWidget.glass(
104
+ { role: props.role ?? (inside ? 'toolbar' : 'control'), shape: 'pill', inset: inside, direction: 'row', gap: 2, pad: 2, align: 'center', appear: props.appear, ...props },
105
+ fn,
106
+ )
107
+ core.capsuleDepth -= 1
108
+ return node
109
+ }
110
+
111
+ function reachDistance(x, y, rect, reachX, reachY) {
112
+ const dx = Math.max(rect.x - x, 0, x - (rect.x + rect.w)) / Math.max(1, reachX)
113
+ const dy = Math.max(rect.y - y, 0, y - (rect.y + rect.h)) / Math.max(1, reachY)
114
+ return Math.hypot(dx, dy)
115
+ }
116
+
117
+ function group(props = {}, fn) {
118
+ const id = ids.make(props.key ?? 'group')
119
+ const st = state.get(id)
120
+ const frame = driver.frame
121
+ const reachX = props.reachX ?? props.reach ?? 140
122
+ const reachY = props.reachY ?? props.reach ?? 140
123
+ const hysteresis = props.hysteresis ?? 24
124
+ if (st.rect && frame.x >= 0 && frame.windowFocused) {
125
+ const distance = reachDistance(frame.x, frame.y, st.rect, reachX, reachY)
126
+ const leave = reachDistance(frame.x, frame.y, st.rect, reachX + hysteresis, reachY + hysteresis)
127
+ if (!st.open && distance < 1) st.open = true
128
+ else if (st.open && leave > 1) st.open = false
129
+ } else st.open = false
130
+ if (props.near != null) st.open = Boolean(props.near)
131
+ const spring = props.spring ?? (props.duration ? springForDuration(props.duration) : springs.liquid)
132
+ const near = clamp(clock.animate(st, 'near', st.open ? 1 : 0, spring), 0, 1)
133
+ const phase = (from, to) => {
134
+ const t = clamp((near - from) / (to - from), 0, 1)
135
+ return t * t * (3 - 2 * t)
136
+ }
137
+ const farGap = props.farGap ?? 0
138
+ const mergeRate = props.mergeRate ?? 12
139
+ const nearGap = props.nearGap ?? mergeRate * 2 + 4
140
+ const gap = farGap + (nearGap - farGap) * phase(0.3, 1)
141
+ const grow = phase(0.1, 0.7)
142
+ const bridge = 1 - phase(0.15, 0.45)
143
+ const inside = insideGlass()
144
+ core.capsuleDepth += 1
145
+ const node = glassWidget.glass(
146
+ {
147
+ id,
148
+ key: props.key ?? 'group',
149
+ role: props.role ?? (inside ? 'toolbar' : 'control'),
150
+ shape: 'pill',
151
+ merge: true,
152
+ mergeRate,
153
+ bridge,
154
+ inset: inside,
155
+ direction: 'row',
156
+ gap,
157
+ pad: 0,
158
+ align: 'center',
159
+ appear: props.appear,
160
+ clipContent: false,
161
+ },
162
+ () => fn(near),
163
+ )
164
+ core.capsuleDepth -= 1
165
+ const reveal = props.reveal ?? Infinity
166
+ node.children.forEach((child, index) => {
167
+ if (index >= reveal) {
168
+ child.props.reveal = grow
169
+ child.props.opacity = phase(0.35, 0.75)
170
+ }
171
+ })
172
+ const paint = node.paint
173
+ node.paint = (n, ctx) => {
174
+ st.rect = n.rect
175
+ paint(n, ctx)
176
+ }
177
+ return near
178
+ }
179
+
180
+ function toolbar(props = {}, fn) {
181
+ core.toolbarDepth += 1
182
+ const node = row({ h: 44, pad: [0, 14], gap: 10, w: 'fill', ...props }, fn)
183
+ core.toolbarDepth -= 1
184
+ return node
185
+ }
186
+
187
+ const LIGHTS = [
188
+ { key: 'close', color: [1, 0.37, 0.34, 1], icon: 'close' },
189
+ { key: 'minimize', color: [1, 0.74, 0.18, 1], icon: 'minus' },
190
+ { key: 'zoom', color: [0.16, 0.78, 0.25, 1], icon: 'plus' },
191
+ ]
192
+
193
+ function trafficLights(props = {}) {
194
+ const result = { close: false, minimize: false, zoom: false }
195
+ const items = LIGHTS.map((light) => ({ light, id: ids.make(light.key) }))
196
+ const interactions = items.map(({ id }) => interaction(id, { focusable: false }))
197
+ const anyHot = interactions.some((it) => it.hot || it.active)
198
+ row({ gap: 8, h: 12, pad: props.pad }, () => {
199
+ items.forEach(({ light, id }, index) => {
200
+ const it = interactions[index]
201
+ if (it.clicked) result[light.key] = true
202
+ const node = createNode('light', { id, key: light.key, w: 12, h: 12 })
203
+ node.paint = (n, ctx) => {
204
+ ctx.hit(n, { x: n.rect.x - 4, y: n.rect.y - 4, w: n.rect.w + 8, h: n.rect.h + 8 })
205
+ const focused = props.focused !== false
206
+ const color = focused ? light.color : [0.82, 0.82, 0.84, 1]
207
+ const press = it.active && it.hot ? 0.8 : 1
208
+ ctx.emit({ type: 'rect', rect: n.rect, radius: 6, color: [color[0] * press, color[1] * press, color[2] * press, 1] })
209
+ ctx.emit({ type: 'stroke', rect: n.rect, radius: 6, color: [0, 0, 0, 0.12], width: 0.5 })
210
+ if (anyHot && focused) {
211
+ const glyph = { x: n.rect.x + 2, y: n.rect.y + 2, w: 8, h: 8 }
212
+ const dark = [0.3, 0.12, 0.08, 0.9]
213
+ if (light.icon === 'close') {
214
+ ctx.emit({ type: 'rect', rect: { x: glyph.x + 1, y: glyph.y + 3.4, w: 6, h: 1.2 }, radius: 0.6, color: dark, rotation: Math.PI / 4 })
215
+ ctx.emit({ type: 'rect', rect: { x: glyph.x + 1, y: glyph.y + 3.4, w: 6, h: 1.2 }, radius: 0.6, color: dark, rotation: -Math.PI / 4 })
216
+ } else if (light.icon === 'minus') {
217
+ ctx.emit({ type: 'rect', rect: { x: glyph.x + 1, y: glyph.y + 3.4, w: 6, h: 1.2 }, radius: 0.6, color: dark })
218
+ } else {
219
+ ctx.emit({ type: 'rect', rect: { x: glyph.x + 1, y: glyph.y + 3.4, w: 6, h: 1.2 }, radius: 0.6, color: dark })
220
+ ctx.emit({ type: 'rect', rect: { x: glyph.x + 1, y: glyph.y + 3.4, w: 6, h: 1.2 }, radius: 0.6, color: dark, rotation: Math.PI / 2 })
221
+ }
222
+ }
223
+ }
224
+ driver.add(node)
225
+ })
226
+ })
227
+ return result
228
+ }
229
+
230
+ function iconButton(name, props = {}) {
231
+ return button(null, { icon: name, key: props.key ?? name, ...props })
232
+ }
233
+
234
+ function toggle(value, props = {}) {
235
+ const id = ids.make(props.key ?? props.label ?? 'toggle')
236
+ const it = interaction(id, props)
237
+ const frame = driver.frame
238
+ const w = props.w ?? 58
239
+ const h = props.h ?? 28
240
+ const st = state.get(id)
241
+ const knobH = h - 4
242
+ const knobBase = Math.round(w * 0.52)
243
+ let next = value
244
+ if (it.pressed) st.dragged = false
245
+ if (it.active && Math.abs(frame.x - frame.dragOrigin.x) > 4) st.dragged = true
246
+ if (it.active && st.rect) {
247
+ const k = st.scale ?? 1
248
+ const travel = st.rect.w - 4 * k - knobBase * k
249
+ st.dragT = clamp((frame.x - st.rect.x - 2 * k - (knobBase * k) / 2) / Math.max(1, travel), 0, 1)
250
+ }
251
+ if (it.released) {
252
+ if (st.dragged && st.dragT != null) {
253
+ next = st.dragT > 0.5
254
+ if (st.on) {
255
+ st.on.value = st.dragT
256
+ st.on.velocity = 0
257
+ }
258
+ if (st.dragShown != null) st.dragShown = st.dragT
259
+ } else if (it.clicked) next = !value
260
+ st.dragT = null
261
+ } else if (it.clicked) next = !value
262
+ const node = createNode('toggle', { ...props, id, w, h })
263
+ const lifted = clock.animate(st, 'lift', it.active ? 1 : 0, springs.lift)
264
+ const liftedW = clock.animate(st, 'liftW', it.active ? 1 : 0, springs.bouncy)
265
+ const stretch = clock.animate(st, 'stretch', it.active ? clamp(Math.abs(frame.dx) * 0.7, 0, 5) : 0, springs.gentle)
266
+ let thumbNode = null
267
+ driver.open(node)
268
+ if (lifted > 0.001) {
269
+ thumbNode = glassWidget.glass({
270
+ key: 'thumb',
271
+ role: 'thumb',
272
+ shape: 'pill',
273
+ lift: true,
274
+ surface: 'small',
275
+ dimming: 0,
276
+ morph: false,
277
+ x: 0,
278
+ y: 0,
279
+ w: knobBase + (w * 1.06 - knobBase) * liftedW + stretch,
280
+ h: knobH + (h * 1.55 - knobH) * lifted,
281
+ clipContent: false,
282
+ appear: true,
283
+ })
284
+ }
285
+ driver.close()
286
+ node.paint = (n, ctx) => {
287
+ const r = n.rect
288
+ const k = n.drawScale ?? 1
289
+ const knobBase = Math.round(w * 0.52) * k
290
+ const knobH = (h - 4) * k
291
+ st.rect = r
292
+ st.scale = k
293
+ ctx.hit(n)
294
+ const on = clock.animate(st, 'on', next ? 1 : 0, springs.snappy)
295
+ const position = it.active && st.dragged && st.dragT != null ? clock.ease(st, 'dragShown', st.dragT, 30) : clock.ease(st, 'dragShown', on, 30)
296
+ const theme = ctx.theme
297
+ const accent = props.tint ?? theme.switchOn
298
+ const blend = Math.max(on, it.active && st.dragged ? position : on)
299
+ const track = [
300
+ theme.track[0] + (accent[0] - theme.track[0]) * blend,
301
+ theme.track[1] + (accent[1] - theme.track[1]) * blend,
302
+ theme.track[2] + (accent[2] - theme.track[2]) * blend,
303
+ theme.track[3] + (accent[3] - theme.track[3]) * blend,
304
+ ]
305
+ ctx.emit({ type: 'rect', rect: r, radius: r.h / 2, color: track })
306
+ const knobW = knobBase + lifted * 6 * k
307
+ const x = r.x + 2 * k + (r.w - 4 * k - knobW) * position
308
+ const knobAlpha = 1 - lifted
309
+ if (knobAlpha > 0.001) ctx.emit({ type: 'rect', rect: { x, y: r.y + 2 * k, w: knobW, h: knobH }, radius: knobH / 2, color: withAlpha(theme.knob, knobAlpha) })
310
+ if (settings.increaseContrast) ctx.emit({ type: 'stroke', rect: r, radius: r.h / 2, color: theme.fg, width: 1 })
311
+ focusRing(ctx, r, r.h / 2, it)
312
+ if (thumbNode) {
313
+ const tw = thumbNode.props.w * k
314
+ const th = thumbNode.props.h * k
315
+ thumbNode.rect = { x: x + knobW / 2 - tw / 2, y: r.y + r.h / 2 - th / 2, w: tw, h: th }
316
+ ctx.paintChildren(n, ctx)
317
+ }
318
+ }
319
+ if (it.focused && it.keyboard && (driver.frame.keysPressed.has('Left') || driver.frame.keysPressed.has('Right'))) {
320
+ next = driver.frame.keysPressed.has('Right')
321
+ }
322
+ return next
323
+ }
324
+
325
+ function slider(value, props = {}) {
326
+ const id = ids.make(props.key ?? props.label ?? 'slider')
327
+ const it = interaction(id, props)
328
+ const min = props.min ?? 0
329
+ const max = props.max ?? 1
330
+ const step = props.step ?? 0
331
+ const frame = driver.frame
332
+ const st = state.get(id)
333
+ let next = value
334
+ const quantize = (v) => {
335
+ let q = clamp(v, min, max)
336
+ if (step > 0) q = Math.round((q - min) / step) * step + min
337
+ return clamp(q, min, max)
338
+ }
339
+ if (it.active && st.trackRect) {
340
+ const t = clamp((frame.x - st.trackRect.x) / Math.max(1, st.trackRect.w), 0, 1)
341
+ next = quantize(min + (max - min) * t)
342
+ }
343
+ if (it.focused && it.keyboard) {
344
+ const inc = step > 0 ? step : (max - min) / 100
345
+ if (frame.keysPressed.has('Left')) next = quantize(next - inc)
346
+ if (frame.keysPressed.has('Right')) next = quantize(next + inc)
347
+ if (frame.keysPressed.has('Home')) next = min
348
+ if (frame.keysPressed.has('End')) next = max
349
+ }
350
+ const height = 28
351
+ const node = createNode('slider', { ...props, id, h: height, w: props.w ?? 'fill', minW: props.minW ?? 80 })
352
+ const rest = 20
353
+ const lifted = clock.animate(st, 'lift', it.active ? 1 : 0, springs.lift)
354
+ const liftedW = clock.animate(st, 'liftW', it.active ? 1 : 0, springs.bouncy)
355
+ const stretch = clock.animate(st, 'stretch', it.active ? clamp(Math.abs(frame.dx) * 0.7, 0, 6) : 0, springs.gentle)
356
+ let thumbNode = null
357
+ driver.open(node)
358
+ if (lifted > 0.001) {
359
+ thumbNode = glassWidget.glass({
360
+ key: 'thumb',
361
+ role: 'thumb',
362
+ shape: 'pill',
363
+ lift: true,
364
+ surface: 'small',
365
+ dimming: 0,
366
+ morph: false,
367
+ x: 0,
368
+ y: 0,
369
+ w: rest + (36 - rest) * liftedW + stretch,
370
+ h: rest + (30 - rest) * lifted,
371
+ clipContent: false,
372
+ appear: true,
373
+ })
374
+ }
375
+ driver.close()
376
+ node.paint = (n, ctx) => {
377
+ const r = n.rect
378
+ const k = n.drawScale ?? 1
379
+ const rest = 20 * k
380
+ const trackH = 4 * k
381
+ const trackRect = { x: r.x + rest / 2, y: r.y + (r.h - trackH) / 2, w: r.w - rest, h: trackH }
382
+ st.trackRect = trackRect
383
+ ctx.hit(n)
384
+ const theme = ctx.theme
385
+ const t = (max - min) > 0 ? (next - min) / (max - min) : 0
386
+ const shown = clock.ease(st, 'shown', t, it.active ? 60 : 18)
387
+ const cx = trackRect.x + trackRect.w * shown
388
+ ctx.emit({ type: 'rect', rect: trackRect, radius: trackH / 2, color: theme.track })
389
+ ctx.emit({ type: 'rect', rect: { x: trackRect.x, y: trackRect.y, w: cx - trackRect.x, h: trackH }, radius: trackH / 2, color: props.tint ?? theme.accent })
390
+ const size = rest
391
+ const knob = { x: cx - size / 2, y: r.y + (r.h - size) / 2, w: size, h: size }
392
+ const knobAlpha = 1 - lifted
393
+ if (knobAlpha > 0.001) {
394
+ ctx.emit({ type: 'rect', rect: knob, radius: size / 2, color: withAlpha(theme.knob, knobAlpha) })
395
+ ctx.emit({ type: 'stroke', rect: knob, radius: size / 2, color: [0, 0, 0, 0.12 * knobAlpha], width: 1 })
396
+ }
397
+ focusRing(ctx, knob, size / 2, it)
398
+ if (thumbNode) {
399
+ const tw = thumbNode.props.w * k
400
+ const th = thumbNode.props.h * k
401
+ thumbNode.rect = { x: cx - tw / 2, y: r.y + (r.h - th) / 2, w: tw, h: th }
402
+ ctx.paintChildren(n, ctx)
403
+ }
404
+ }
405
+ return next
406
+ }
407
+
408
+ function segmented(options, selected, props = {}) {
409
+ const id = ids.make(props.key ?? 'segmented')
410
+ const st = state.get(id)
411
+ let next = selected
412
+ const height = props.compact ? COMPACT_HEIGHT : 32
413
+ const inside = insideGlass()
414
+ const padX = props.padX ?? 12
415
+ const labelFont = primitives.font({ weight: 600 })
416
+ const widest = props.equal
417
+ ? Math.max(...options.map((option) => {
418
+ const labelText = typeof option === 'string' ? option : option.label
419
+ const iconWidth = option.icon ? 15 + (labelText ? 6 : 0) : 0
420
+ return (labelText ? core.renderer.atlas.measure(labelText, labelFont).w : 0) + iconWidth + padX * 2
421
+ }))
422
+ : null
423
+ const build = (container) => {
424
+ options.forEach((option, index) => {
425
+ const labelText = typeof option === 'string' ? option : option.label
426
+ const itemId = ids.make(labelText ?? String(index))
427
+ const it = interaction(itemId, props)
428
+ if (it.clicked) next = index
429
+ const fill = props.w === 'fill'
430
+ const item = box({ id: itemId, key: labelText, direction: 'row', gap: 6, align: 'center', justify: 'center', h: height - 4, pad: [0, padX], w: fill ? undefined : widest ?? undefined, fr: fill ? 1 : undefined, minW: fill ? widest ?? height : height }, () => {
431
+ if (option.icon) icon(option.icon, { size: 15 })
432
+ if (labelText) text(labelText, { weight: 600 })
433
+ })
434
+ item.paint = (n, ctx) => {
435
+ ctx.hit(n)
436
+ const hover = clock.animate(state.get(itemId), 'hover', it.hot && index !== next ? 1 : 0, springs.gentle)
437
+ if (hover > 0.001) ctx.emit({ type: 'rect', rect: n.rect, radius: n.rect.h / 2, color: withAlpha(ctx.theme.hover, hover) })
438
+ focusRing(ctx, n.rect, n.rect.h / 2, it)
439
+ ctx.paintChildren(n, ctx)
440
+ }
441
+ })
442
+ }
443
+ const paintSelection = (container, ctx) => {
444
+ const items = container.children.filter((child) => child.kind === 'box' && child.id)
445
+ const target = next >= 0 && next < items.length ? items[next].rect : null
446
+ if (target) {
447
+ const origin = container.rect
448
+ const x = origin.x + clock.animate(st, 'sx', target.x - origin.x, springs.morph)
449
+ const w = clock.animate(st, 'sw', target.w, springs.morph)
450
+ const rect = { x, y: target.y, w, h: target.h }
451
+ const dark = ctx.theme.dark > 0.5
452
+ const lift = 1 - (ctx.theme.recede ?? 0)
453
+ if (lift > 0.01) ctx.emit({ type: 'rect', rect: { ...rect, y: rect.y + 1 }, radius: rect.h / 2, color: [0, 0, 0, (dark ? 0.25 : 0.1) * lift] })
454
+ ctx.emit({ type: 'rect', rect, radius: rect.h / 2, color: dark ? [1, 1, 1, 0.28] : ctx.theme.knob })
455
+ if (settings.increaseContrast) ctx.emit({ type: 'stroke', rect, radius: rect.h / 2, color: ctx.theme.fg, width: 1 })
456
+ }
457
+ }
458
+ const parentStretches = driver.current().props.align === 'stretch'
459
+ const layoutProps = { direction: 'row', gap: 2, pad: 2, align: 'center', h: height, w: props.w, alignSelf: props.alignSelf ?? (parentStretches && !props.w ? 'start' : undefined) }
460
+ let container
461
+ if (inside) {
462
+ container = box({ ...layoutProps, id, key: props.key ?? 'segmented' }, build)
463
+ const previous = container.paint
464
+ container.paint = (n, ctx) => {
465
+ ctx.emit({ type: 'rect', rect: n.rect, radius: n.rect.h / 2, color: ctx.theme.track })
466
+ paintSelection(n, ctx)
467
+ ctx.paintChildren(n, ctx)
468
+ if (previous) previous(n, ctx)
469
+ }
470
+ } else {
471
+ container = glassWidget.glass({ ...layoutProps, id, key: props.key ?? 'segmented', role: 'control', shape: 'pill', appear: props.appear }, () => {
472
+ const marker = createNode('selection', { x: 0, y: 0, w: 0, h: 0 })
473
+ marker.paint = (n, ctx) => paintSelection(n.parent, ctx)
474
+ driver.add(marker)
475
+ build()
476
+ })
477
+ }
478
+ return next
479
+ }
480
+
481
+ function progress(value, props = {}) {
482
+ const node = createNode('progress', { ...props, h: 4, w: props.w ?? 'fill', minW: 40 })
483
+ node.paint = (n, ctx) => {
484
+ const r = n.rect
485
+ ctx.emit({ type: 'rect', rect: r, radius: 2, color: ctx.theme.track })
486
+ ctx.emit({ type: 'rect', rect: { ...r, w: r.w * clamp(value, 0, 1) }, radius: 2, color: props.tint ?? ctx.theme.accent })
487
+ }
488
+ return driver.add(node)
489
+ }
490
+
491
+ function item(labelText, props = {}) {
492
+ const id = ids.make(props.key ?? labelText)
493
+ const it = interaction(id, props)
494
+ const node = row({ id, key: labelText, gap: 10, h: props.h ?? 30, pad: props.pad ?? [0, 12], w: 'fill', align: 'center' }, () => {
495
+ if (props.content) {
496
+ props.content()
497
+ return
498
+ }
499
+ if (props.icon) icon(props.icon, { size: 16, color: props.selected ? 'accent' : 'fg2' })
500
+ if (props.swatch) primitives.rect({ w: 12, h: 12, radius: 4, fill: props.swatch })
501
+ text(labelText, { weight: 600, color: props.selected ? 'accent' : undefined, fr: 1, w: 'fill' })
502
+ if (props.detail) text(props.detail, { color: 'fg2' })
503
+ if (props.check) icon('check', { size: 14 })
504
+ if (props.chevron) icon('chevronRight', { size: 12, color: 'fg3' })
505
+ })
506
+ node.paint = (n, ctx) => {
507
+ ctx.hit(n)
508
+ const st = state.get(id)
509
+ const hover = clock.animate(st, 'hover', it.hot && !it.active ? 1 : 0, springs.gentle)
510
+ const press = it.active && it.hot ? 1 : 0
511
+ const radius = props.radius ?? n.rect.h / 2
512
+ if (props.selected) ctx.emit({ type: 'rect', rect: n.rect, radius, color: props.tint ? withAlpha(props.tint, 0.9) : ctx.theme.selected })
513
+ if (hover > 0.001) ctx.emit({ type: 'rect', rect: n.rect, radius, color: withAlpha(ctx.theme.hover, hover) })
514
+ if (press) ctx.emit({ type: 'rect', rect: n.rect, radius, color: ctx.theme.press })
515
+ focusRing(ctx, n.rect, radius, it)
516
+ ctx.paintChildren(n, ctx)
517
+ }
518
+ return it.clicked
519
+ }
520
+
521
+ function dragHandle(props = {}) {
522
+ const id = ids.make(props.key ?? 'drag')
523
+ const it = interaction(id, { ...props, focusable: false })
524
+ const frame = driver.frame
525
+ const node = createNode('drag', { ...props, id, w: props.w ?? 'fill', h: props.h ?? 'fill', minH: props.minH ?? 28 })
526
+ node.paint = (n, ctx) => {
527
+ ctx.hit(n, props.hitRect)
528
+ ctx.paintChildren(n, ctx)
529
+ }
530
+ driver.add(node)
531
+ const dragging = it.active && !it.pressed
532
+ return { dragging, dx: dragging ? frame.dx : 0, dy: dragging ? frame.dy : 0, pressed: it.pressed, hot: it.hot }
533
+ }
534
+
535
+ function hitArea(props = {}) {
536
+ const id = ids.make(props.key ?? 'hit')
537
+ const it = interaction(id, { ...props, focusable: false })
538
+ const node = createNode('hit', { ...props, id })
539
+ node.paint = (n, ctx) => ctx.hit(n)
540
+ driver.add(node)
541
+ return it
542
+ }
543
+
544
+ return { button, iconButton, toggle, slider, segmented, progress, item, dragHandle, hitArea, capsule, toolbar, trafficLights, group }
545
+ }
@@ -0,0 +1,144 @@
1
+ const DEFAULT_MIN = { w: 240, h: 160 }
2
+
3
+ export function createDesktop(ui, options = {}) {
4
+ const cascade = { x: 80, y: 60, step: 32, ...(options.cascade ?? {}) }
5
+ const entries = new Map()
6
+ const pending = []
7
+ let topZ = 0
8
+ let focused = null
9
+ let placed = 0
10
+
11
+ function entry(key, spec) {
12
+ let item = entries.get(key)
13
+ if (!item) {
14
+ const vp = ui.viewport
15
+ const w = spec.w ?? 480
16
+ const h = spec.h ?? 360
17
+ const x = spec.x ?? Math.min(cascade.x + (placed % 8) * cascade.step, Math.max(8, vp.w - w - 8))
18
+ const y = spec.y ?? Math.min(cascade.y + (placed % 8) * cascade.step, Math.max(8, vp.h - h - 8))
19
+ placed += 1
20
+ topZ += 1
21
+ item = { key, x, y, w, h, z: topZ, open: spec.open !== false, zoomed: null }
22
+ entries.set(key, item)
23
+ if (item.open && (spec.focus ?? true)) focused = key
24
+ }
25
+ return item
26
+ }
27
+
28
+ function focus(key) {
29
+ const item = entries.get(key)
30
+ if (!item) return
31
+ topZ += 1
32
+ item.z = topZ
33
+ focused = key
34
+ ui.invalidate()
35
+ }
36
+
37
+ function open(key) {
38
+ const item = entries.get(key)
39
+ if (!item) return
40
+ item.open = true
41
+ focus(key)
42
+ }
43
+
44
+ function close(key) {
45
+ const item = entries.get(key)
46
+ if (!item) return
47
+ item.open = false
48
+ if (focused === key) focused = null
49
+ ui.invalidate()
50
+ }
51
+
52
+ function toggle(key) {
53
+ const item = entries.get(key)
54
+ if (!item) return
55
+ if (item.open && focused === key) close(key)
56
+ else open(key)
57
+ }
58
+
59
+ function isOpen(key) {
60
+ return entries.get(key)?.open ?? false
61
+ }
62
+
63
+ function window(key, spec, fn) {
64
+ const item = entry(key, spec)
65
+ pending.push({ item, spec, fn })
66
+ return item
67
+ }
68
+
69
+ function apply(item, spec, result) {
70
+ const min = { ...DEFAULT_MIN, ...(spec.minSize ?? {}) }
71
+ if (result.moved) {
72
+ item.x += result.dx
73
+ item.y += result.dy
74
+ item.zoomed = null
75
+ }
76
+ if (result.resize) {
77
+ const nextW = Math.max(min.w, item.w + result.resize.dw)
78
+ const nextH = Math.max(min.h, item.h + result.resize.dh)
79
+ if (result.resize.dx) item.x += item.w - nextW
80
+ if (result.resize.dy) item.y += item.h - nextH
81
+ item.w = nextW
82
+ item.h = nextH
83
+ item.zoomed = null
84
+ }
85
+ if (result.focusRequested) focus(item.key)
86
+ if (result.closed) close(item.key)
87
+ if (result.minimized) close(item.key)
88
+ if (result.zoomed) {
89
+ const vp = ui.viewport
90
+ if (item.zoomed) {
91
+ Object.assign(item, item.zoomed)
92
+ item.zoomed = null
93
+ } else {
94
+ item.zoomed = { x: item.x, y: item.y, w: item.w, h: item.h }
95
+ Object.assign(item, { x: 24, y: 24, w: vp.w - 48, h: vp.h - 48 - (options.bottomInset ?? 0) })
96
+ }
97
+ }
98
+ }
99
+
100
+ function draw() {
101
+ const frame = ui.input
102
+ if (frame.pressedAnywhere && !frame.hotEntry) focused = null
103
+ if (focused && !entries.get(focused)?.open) focused = null
104
+ const visible = pending.filter(({ item }) => item.open).sort((a, b) => a.item.z - b.item.z)
105
+ for (const { item, spec, fn } of visible) {
106
+ const result = ui.window(
107
+ {
108
+ key: item.key,
109
+ x: item.x,
110
+ y: item.y,
111
+ w: item.w,
112
+ h: item.h,
113
+ focused: focused === item.key,
114
+ sidebar: spec.sidebar,
115
+ sidebarWidth: spec.sidebarWidth,
116
+ toolbar: spec.toolbar ?? (spec.title ? () => ui.text(spec.title, { weight: 700, size: 15 }) : undefined),
117
+ resizable: spec.resizable,
118
+ radius: spec.radius,
119
+ role: spec.role,
120
+ accent: spec.accent,
121
+ },
122
+ fn,
123
+ )
124
+ apply(item, spec, result)
125
+ }
126
+ pending.length = 0
127
+ }
128
+
129
+ return {
130
+ window,
131
+ draw,
132
+ focus,
133
+ open,
134
+ close,
135
+ toggle,
136
+ isOpen,
137
+ get focused() {
138
+ return focused
139
+ },
140
+ get windows() {
141
+ return [...entries.values()]
142
+ },
143
+ }
144
+ }