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,355 @@
1
+ import { createNode } from '../core/frame.js'
2
+ import { springs } from '../core/spring.js'
3
+ import { withAlpha } from '../core/theme.js'
4
+ import { graphemes } from '../render/glyphs.js'
5
+
6
+ export function createComposite(core, primitives, controls, glassWidget) {
7
+ const { driver, ids, state, interaction } = core
8
+ const { row, text, icon, spacer, popup, font, separator } = primitives
9
+ const { button, item } = controls
10
+
11
+ function menu(anchorNode, props, fn) {
12
+ return popup({ anchorNode, placement: props.placement ?? 'below', gap: 6, alignEnd: props.alignEnd, minW: props.minW ?? 180, matchAnchor: props.matchAnchor, align: 'stretch' }, () => {
13
+ glassWidget.glass(
14
+ {
15
+ key: props.key ?? 'menu',
16
+ role: props.role ?? 'menu',
17
+ surface: 'large',
18
+ radius: props.radius ?? core.materials.menu?.cornerRadius ?? 16,
19
+ pad: 6,
20
+ gap: 2,
21
+ direction: 'column',
22
+ align: 'stretch',
23
+ from: () => anchorNode.rect,
24
+ morph: true,
25
+ morphSpring: springs.reveal,
26
+ presenceSpring: springs.reveal,
27
+ },
28
+ fn,
29
+ )
30
+ })
31
+ }
32
+
33
+ function insideIds(frame, id) {
34
+ const target = frame.hotEntry?.id
35
+ return target != null && (target === id || target.startsWith(id + '/'))
36
+ }
37
+
38
+ function menuButton(labelText, items, props = {}) {
39
+ const key = props.key ?? labelText ?? 'menu'
40
+ const id = ids.make(key)
41
+ const st = state.get(id, () => ({ open: false, retained: true }))
42
+ const frame = driver.frame
43
+ let selected = null
44
+ const clicked = button(labelText, { ...props, id, key, selected: st.open || props.selected })
45
+ const parent = driver.current()
46
+ const anchor = parent.children[parent.children.length - 1]
47
+ if (clicked) st.open = !st.open
48
+ if (st.open) {
49
+ ids.push(key)
50
+ const labelFont = font({ weight: 600 })
51
+ const widest = Math.max(0, ...items.map((entry) => (entry === '-' ? 0 : core.renderer.atlas.measure(typeof entry === 'string' ? entry : entry.label, labelFont).w + (entry.icon ? 26 : 0) + (entry.detail ? 40 : 0) + (entry.check ? 24 : 0))))
52
+ menu(anchor, { key: 'menu', alignEnd: props.alignEnd, minW: Math.max(props.menuMinW ?? 0, widest + 36 + 12), matchAnchor: true, placement: props.placement }, () => {
53
+ items.forEach((entry, index) => {
54
+ if (entry === '-') {
55
+ separator({ h: 1 })
56
+ return
57
+ }
58
+ const labelOf = typeof entry === 'string' ? entry : entry.label
59
+ if (item(labelOf, { key: labelOf + index, icon: entry.icon, check: entry.checked, detail: entry.detail, swatch: entry.swatch, h: 28 })) {
60
+ selected = entry
61
+ st.open = false
62
+ }
63
+ })
64
+ })
65
+ ids.pop()
66
+ if (frame.pressedAnywhere && !insideIds(frame, id)) st.open = false
67
+ if (frame.keysPressed.has('Escape')) st.open = false
68
+ }
69
+ return selected
70
+ }
71
+
72
+ function textInput(value, props = {}) {
73
+ const id = ids.make(props.key ?? props.placeholder ?? 'input')
74
+ const it = interaction(id, props)
75
+ const st = state.get(id, () => ({ caret: props.selectAll ? graphemes(value ?? '').length : 0, anchor: 0 }))
76
+ const frame = driver.frame
77
+ const f = font({ size: props.size ?? 13, weight: 500 })
78
+ let next = value ?? ''
79
+ const parts = () => graphemes(next)
80
+
81
+ if (it.focused) {
82
+ const keys = frame.keysPressed
83
+ const shift = frame.keysDown.has('Shift')
84
+ const meta = frame.keysDown.has('Meta')
85
+ st.caret = Math.min(st.caret, parts().length)
86
+ st.anchor = Math.min(st.anchor, parts().length)
87
+ const hasSelection = st.caret !== st.anchor
88
+ const selectionStart = Math.min(st.caret, st.anchor)
89
+ const selectionEnd = Math.max(st.caret, st.anchor)
90
+ const replaceSelection = (insert) => {
91
+ const p = parts()
92
+ next = p.slice(0, selectionStart).join('') + insert + p.slice(selectionEnd).join('')
93
+ st.caret = selectionStart + graphemes(insert).length
94
+ st.anchor = st.caret
95
+ }
96
+ if (meta && keys.has('a')) {
97
+ st.anchor = 0
98
+ st.caret = parts().length
99
+ } else if (frame.text && !meta) {
100
+ replaceSelection(frame.text)
101
+ }
102
+ if (keys.has('Backspace')) {
103
+ if (hasSelection) replaceSelection('')
104
+ else if (st.caret > 0) {
105
+ const p = parts()
106
+ next = p.slice(0, st.caret - 1).join('') + p.slice(st.caret).join('')
107
+ st.caret -= 1
108
+ st.anchor = st.caret
109
+ }
110
+ }
111
+ if (keys.has('Delete')) {
112
+ if (hasSelection) replaceSelection('')
113
+ else {
114
+ const p = parts()
115
+ next = p.slice(0, st.caret).join('') + p.slice(st.caret + 1).join('')
116
+ }
117
+ }
118
+ if (keys.has('Left')) {
119
+ st.caret = Math.max(0, hasSelection && !shift ? selectionStart : st.caret - 1)
120
+ if (!shift) st.anchor = st.caret
121
+ }
122
+ if (keys.has('Right')) {
123
+ st.caret = Math.min(parts().length, hasSelection && !shift ? selectionEnd : st.caret + 1)
124
+ if (!shift) st.anchor = st.caret
125
+ }
126
+ if (keys.has('Home')) {
127
+ st.caret = 0
128
+ if (!shift) st.anchor = 0
129
+ }
130
+ if (keys.has('End')) {
131
+ st.caret = parts().length
132
+ if (!shift) st.anchor = st.caret
133
+ }
134
+ if (keys.has('Enter') && props.onEnter) props.onEnter(next)
135
+ }
136
+
137
+ const height = props.h ?? 30
138
+ const useGlass = props.glass ?? (core.toolbarDepth > 0 && core.capsuleDepth === 0)
139
+ let glassNode = null
140
+ if (useGlass) {
141
+ glassNode = glassWidget.glass({
142
+ key: (props.key ?? props.placeholder ?? 'input') + '-back',
143
+ role: 'toolbar',
144
+ shape: 'pill',
145
+ inset: core.insideGlass(),
146
+ w: props.w,
147
+ h: height,
148
+ minW: props.minW ?? 120,
149
+ direction: 'row',
150
+ align: 'stretch',
151
+ clipContent: false,
152
+ })
153
+ }
154
+ const node = createNode('input', { ...props, id, h: useGlass ? 'fill' : height, w: useGlass ? 'fill' : props.w ?? 'fill', minW: useGlass ? 0 : props.minW ?? 120 })
155
+ node.paint = (n, ctx) => {
156
+ const r = n.rect
157
+ const k = n.drawScale ?? 1
158
+ const fs = { ...f, size: f.size * k }
159
+ const radius = (props.radius ?? height / 2) * k
160
+ ctx.hit(n)
161
+ const theme = ctx.theme
162
+ const atlas = core.renderer.atlas
163
+ if (!useGlass) ctx.emit({ type: 'rect', rect: r, radius, color: theme.field })
164
+ if (it.focused) ctx.emit({ type: 'stroke', rect: r, radius, color: theme.focusRing, width: 1.5 })
165
+ if (props.icon) {
166
+ const iconY = r.y + (r.h - 15 * k) / 2
167
+ ctx.emit({ type: 'stroke', rect: { x: r.x + 12 * k, y: iconY + 2 * k, w: 8 * k, h: 8 * k }, radius: 99, color: theme.fg3, width: 1.6 * k })
168
+ ctx.emit({ type: 'rect', rect: { x: r.x + 21 * k, y: iconY + 9.5 * k, w: 5 * k, h: 1.7 * k }, radius: 1, color: theme.fg3, rotation: Math.PI / 4 })
169
+ }
170
+ const iconSize = 15 * k
171
+ const padX = 12 * k
172
+ const iconPad = props.icon ? iconSize + 7 * k : 0
173
+ const textX = r.x + padX + iconPad
174
+ const lineH = fs.size * fs.lineHeight
175
+ const textY = r.y + (r.h - lineH) / 2
176
+ const p = graphemes(next)
177
+ const widthUpTo = (index) => atlas.measure(p.slice(0, index).join(''), fs).w
178
+ if (it.pressed || (it.active && frame.moved)) {
179
+ let best = 0
180
+ let bestDistance = Infinity
181
+ for (let i = 0; i <= p.length; i++) {
182
+ const d = Math.abs(textX + widthUpTo(i) - frame.x)
183
+ if (d < bestDistance) {
184
+ bestDistance = d
185
+ best = i
186
+ }
187
+ }
188
+ st.caret = best
189
+ if (it.pressed) st.anchor = best
190
+ }
191
+ const previousClip = ctx.pushClip({ x: r.x + 4 * k, y: r.y, w: r.w - 8 * k, h: r.h })
192
+ if (it.focused && st.caret !== st.anchor) {
193
+ const a = widthUpTo(Math.min(st.caret, st.anchor))
194
+ const b = widthUpTo(Math.max(st.caret, st.anchor))
195
+ ctx.emit({ type: 'rect', rect: { x: textX + a, y: textY, w: b - a, h: lineH }, radius: 2, color: withAlpha(theme.accent, 0.3) })
196
+ }
197
+ if (next.length === 0 && props.placeholder) {
198
+ ctx.emit({ type: 'text', x: textX, y: textY, text: props.placeholder, font: fs, color: theme.fg3 })
199
+ } else {
200
+ ctx.emit({ type: 'text', x: textX, y: textY, text: next, font: fs, color: theme.fg })
201
+ }
202
+ if (it.focused) {
203
+ const blink = (performance.now() / 530) % 2 < 1 || frame.keysPressed.size > 0 || frame.text
204
+ core.invalidateSoon(260)
205
+ if (blink) ctx.emit({ type: 'rect', rect: { x: textX + widthUpTo(st.caret), y: textY + 1, w: 1.5, h: lineH - 2 }, radius: 1, color: theme.accent })
206
+ }
207
+ ctx.popClip(previousClip)
208
+ }
209
+ if (glassNode) {
210
+ glassNode.children.push(node)
211
+ node.parent = glassNode
212
+ } else {
213
+ driver.add(node)
214
+ }
215
+ return next
216
+ }
217
+
218
+ function pane(props, fn) {
219
+ return glassWidget.glass(
220
+ {
221
+ role: 'pane',
222
+ surface: 'large',
223
+ inset: true,
224
+ radius: props.radius ?? core.materials.pane?.cornerRadius ?? 32,
225
+ morph: false,
226
+ appear: false,
227
+ direction: 'column',
228
+ align: 'stretch',
229
+ ...props,
230
+ },
231
+ fn,
232
+ )
233
+ }
234
+
235
+ const RESIZE_EDGES = {
236
+ left: { x: 1, w: -1, cursor: 'ew-resize' },
237
+ right: { w: 1, cursor: 'ew-resize' },
238
+ top: { y: 1, h: -1, cursor: 'ns-resize' },
239
+ bottom: { h: 1, cursor: 'ns-resize' },
240
+ topLeft: { x: 1, w: -1, y: 1, h: -1, cursor: 'nwse-resize' },
241
+ bottomRight: { w: 1, h: 1, cursor: 'nwse-resize' },
242
+ topRight: { w: 1, y: 1, h: -1, cursor: 'nesw-resize' },
243
+ bottomLeft: { x: 1, w: -1, h: 1, cursor: 'nesw-resize' },
244
+ }
245
+
246
+ function resizeInteraction(id, frame) {
247
+ const result = { resized: false, dx: 0, dy: 0, dw: 0, dh: 0 }
248
+ for (const [name, edge] of Object.entries(RESIZE_EDGES)) {
249
+ const edgeId = id + '/resize/' + name
250
+ const it = interaction(edgeId, { focusable: false })
251
+ if (it.hot || it.active) core.setCursor(edge.cursor)
252
+ if (it.active && !it.pressed && frame.moved) {
253
+ result.resized = true
254
+ result.dx += (edge.x ?? 0) * frame.dx
255
+ result.dw += (edge.w ?? 0) * frame.dx
256
+ result.dy += (edge.y ?? 0) * frame.dy
257
+ result.dh += (edge.h ?? 0) * frame.dy
258
+ }
259
+ }
260
+ return result
261
+ }
262
+
263
+ function window(props, fn) {
264
+ const key = props.key ?? props.title ?? 'window'
265
+ const id = ids.make(key)
266
+ const result = { closed: false, moved: false, dx: 0, dy: 0, focusRequested: false, minimized: false, zoomed: false, resize: null }
267
+ const focused = props.focused ?? true
268
+ const frame = driver.frame
269
+ const resizable = props.resizable !== false
270
+ if (resizable) {
271
+ const resize = resizeInteraction(id, frame)
272
+ if (resize.resized) result.resize = resize
273
+ }
274
+ const radius = props.radius ?? core.materials.window?.cornerRadius ?? 36
275
+ const inset = props.inset ?? 8
276
+ let drag = null
277
+ let dragId = null
278
+ const lights = (pad) => {
279
+ const pressed = controls.trafficLights({ focused, pad })
280
+ if (pressed.close) result.closed = true
281
+ if (pressed.minimize) result.minimized = true
282
+ if (pressed.zoom) result.zoomed = true
283
+ }
284
+ glassWidget.glass(
285
+ {
286
+ id,
287
+ key,
288
+ role: props.role ?? 'window',
289
+ surface: 'large',
290
+ radius,
291
+ x: props.x,
292
+ y: props.y,
293
+ w: props.w,
294
+ h: props.h,
295
+ direction: 'row',
296
+ align: 'stretch',
297
+ pad: inset,
298
+ gap: inset,
299
+ focused,
300
+ accent: props.accent,
301
+ appear: props.appear,
302
+ hit: true,
303
+ resizable,
304
+ },
305
+ (node) => {
306
+ dragId = ids.make('drag')
307
+ drag = interaction(dragId, { focusable: false })
308
+ const toolbarHeight = 44
309
+ const lightsY = inset + toolbarHeight / 2 - 6
310
+ const lightsAt = (x, y) => {
311
+ const lightsBox = createNode('box', { direction: 'row', x, y, key: 'lights' })
312
+ driver.open(lightsBox)
313
+ lights(0)
314
+ driver.close()
315
+ }
316
+ const dragArea = (rect) => {
317
+ const area = createNode('drag', { ...rect, key: 'drag-area' })
318
+ area.paint = (n, ctx) => ctx.hit({ id: dragId, layer: n.layer }, n.rect)
319
+ driver.add(area)
320
+ }
321
+ if (props.sidebar) {
322
+ const panePad = [toolbarHeight - 4, 8, 8, 8]
323
+ const paneWidth = props.sidebarWidth ?? 200
324
+ pane({ key: 'sidebar', w: paneWidth, radius: props.sidebarRadius ?? radius, pad: panePad, gap: 2, focused }, () => {
325
+ props.sidebar(node)
326
+ dragArea({ x: -panePad[3], y: -panePad[0], w: paneWidth, h: panePad[0] })
327
+ lightsAt(20 - inset - panePad[3], lightsY - inset - panePad[0])
328
+ })
329
+ }
330
+ const body = createNode('box', { direction: 'column', fr: 1, w: 'fill', align: 'stretch', key: 'body' })
331
+ driver.open(body)
332
+ controls.toolbar({ key: 'toolbar', h: toolbarHeight, pad: [0, 6, 0, props.sidebar ? 4 : 74] }, () => {
333
+ const bar = driver.current()
334
+ bar.paint = (n, ctx) => {
335
+ ctx.hit({ id: dragId, layer: n.layer }, n.rect)
336
+ ctx.paintChildren(n, ctx)
337
+ }
338
+ if (props.toolbar) props.toolbar(node)
339
+ })
340
+ if (fn) fn(node)
341
+ driver.close()
342
+ if (!props.sidebar) lightsAt(20 - inset, lightsY - inset)
343
+ },
344
+ )
345
+ if (drag.active && !drag.pressed) {
346
+ result.moved = frame.moved
347
+ result.dx = frame.dx
348
+ result.dy = frame.dy
349
+ }
350
+ if (frame.pressedAnywhere && insideIds(frame, id)) result.focusRequested = true
351
+ return result
352
+ }
353
+
354
+ return { menu, menuButton, textInput, window, pane }
355
+ }