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,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
|
+
}
|