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/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "lgimgui",
3
+ "version": "0.1.0",
4
+ "description": "liquid glass immediate mode gui for the browser",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js",
9
+ "./src/*": "./src/*",
10
+ "./materials.json": "./demo/public/materials.json"
11
+ },
12
+ "files": [
13
+ "src",
14
+ "demo/public/materials.json"
15
+ ],
16
+ "scripts": {
17
+ "dev": "vite demo --host",
18
+ "build": "vite build demo",
19
+ "preview": "vite preview demo",
20
+ "test": "vitest run",
21
+ "test:watch": "vitest"
22
+ },
23
+ "engines": {
24
+ "node": ">=24"
25
+ },
26
+ "devDependencies": {
27
+ "vite": "^6.0.0",
28
+ "vitest": "^3.0.0"
29
+ },
30
+ "license": "MIT",
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/nvms/lgimgui.git"
34
+ },
35
+ "bugs": {
36
+ "url": "https://github.com/nvms/lgimgui/issues"
37
+ },
38
+ "homepage": "https://github.com/nvms/lgimgui#readme"
39
+ }
@@ -0,0 +1,71 @@
1
+ function cubicAt(p0, p1, p2, p3, t) {
2
+ const u = 1 - t
3
+ const a = u * u * u
4
+ const b = 3 * u * u * t
5
+ const c = 3 * u * t * t
6
+ const d = t * t * t
7
+ return { x: a * p0.x + b * p1.x + c * p2.x + d * p3.x, y: a * p0.y + b * p1.y + c * p2.y + d * p3.y }
8
+ }
9
+
10
+ export function cubicPoints({ from, c1, c2, to }, options = {}) {
11
+ const chord = Math.hypot(to.x - from.x, to.y - from.y) + Math.hypot(c1.x - from.x, c1.y - from.y) + Math.hypot(c2.x - to.x, c2.y - to.y)
12
+ const segments = options.segments ?? Math.max(8, Math.min(96, Math.ceil(chord / (options.step ?? 10))))
13
+ const points = []
14
+ for (let i = 0; i <= segments; i++) points.push(cubicAt(from, c1, c2, to, i / segments))
15
+ return points
16
+ }
17
+
18
+ export function horizontalCubic(from, to, options = {}) {
19
+ const distance = Math.abs(to.x - from.x)
20
+ const reach = Math.max(options.minReach ?? 40, distance * (options.tension ?? 0.5))
21
+ return { from, c1: { x: from.x + reach, y: from.y }, c2: { x: to.x - reach, y: to.y }, to }
22
+ }
23
+
24
+ function segmentQuad(a, b, width) {
25
+ const dx = b.x - a.x
26
+ const dy = b.y - a.y
27
+ const length = Math.hypot(dx, dy)
28
+ const cx = (a.x + b.x) / 2
29
+ const cy = (a.y + b.y) / 2
30
+ return {
31
+ rect: { x: cx - (length + width) / 2, y: cy - width / 2, w: length + width, h: width },
32
+ rotation: Math.atan2(dy, dx),
33
+ radius: width / 2,
34
+ }
35
+ }
36
+
37
+ function walkDashes(points, dash, gap) {
38
+ const quads = []
39
+ const period = dash + gap
40
+ let travelled = 0
41
+ for (let i = 1; i < points.length; i++) {
42
+ const a = points[i - 1]
43
+ const b = points[i]
44
+ const length = Math.hypot(b.x - a.x, b.y - a.y)
45
+ if (length === 0) continue
46
+ let local = 0
47
+ while (local < length) {
48
+ const phase = (travelled + local) % period
49
+ const onDash = phase < dash
50
+ const remaining = onDash ? dash - phase : period - phase
51
+ const end = Math.min(length, local + remaining)
52
+ if (onDash) {
53
+ const t0 = local / length
54
+ const t1 = end / length
55
+ quads.push({ a: { x: a.x + (b.x - a.x) * t0, y: a.y + (b.y - a.y) * t0 }, b: { x: a.x + (b.x - a.x) * t1, y: a.y + (b.y - a.y) * t1 } })
56
+ }
57
+ local = end
58
+ }
59
+ travelled += length
60
+ }
61
+ return quads
62
+ }
63
+
64
+ export function strokeQuads(points, options = {}) {
65
+ const width = options.width ?? 2
66
+ if (points.length < 2) return []
67
+ if (options.dash > 0) return walkDashes(points, options.dash, options.gap ?? options.dash).map(({ a, b }) => segmentQuad(a, b, width))
68
+ const quads = []
69
+ for (let i = 1; i < points.length; i++) quads.push(segmentQuad(points[i - 1], points[i], width))
70
+ return quads
71
+ }
@@ -0,0 +1,272 @@
1
+ import { layout, measure, position, scaleNode, translateNode } from './layout.js'
2
+ import { CONTENT_THEME } from './theme.js'
3
+ import { rectIntersect } from './geometry.js'
4
+
5
+ export { scaleNode, translateNode }
6
+
7
+ function rectsOverlap(a, b) {
8
+ return a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h
9
+ }
10
+
11
+ export function createNode(kind, props, extra) {
12
+ return {
13
+ kind,
14
+ id: props.id ?? null,
15
+ props,
16
+ children: [],
17
+ parent: null,
18
+ natural: { w: 0, h: 0 },
19
+ content: { w: 0, h: 0 },
20
+ rect: { x: 0, y: 0, w: 0, h: 0 },
21
+ intrinsic: null,
22
+ paint: null,
23
+ layer: 0,
24
+ ...extra,
25
+ }
26
+ }
27
+
28
+ export function createFrameDriver({ renderer, input, state, ids, clock, probe }) {
29
+ let root = null
30
+ let stack = []
31
+ let popups = []
32
+ let viewport = { w: 0, h: 0 }
33
+ let frame = null
34
+ let focusOrder = []
35
+ let ghosts = new Map()
36
+ let lastStrata = []
37
+
38
+ function begin(width, height, seconds) {
39
+ viewport = { w: width, h: height }
40
+ ids.reset()
41
+ clock.tick(seconds)
42
+ frame = input.beginFrame()
43
+ root = createNode('box', { direction: 'stack', align: 'stretch', pad: 0 })
44
+ stack = [root]
45
+ popups = []
46
+ focusOrder = []
47
+ return frame
48
+ }
49
+
50
+ function current() {
51
+ return stack[stack.length - 1]
52
+ }
53
+
54
+ function add(node) {
55
+ const parent = current()
56
+ node.parent = parent
57
+ node.layer = node.props.detached ? node.layer : parent.layer
58
+ parent.children.push(node)
59
+ return node
60
+ }
61
+
62
+ function open(node) {
63
+ add(node)
64
+ stack.push(node)
65
+ if (node.id) ids.push(node.props.key ?? node.props.label ?? node.kind)
66
+ return node
67
+ }
68
+
69
+ function close() {
70
+ const node = stack.pop()
71
+ if (node.id) ids.pop()
72
+ return node
73
+ }
74
+
75
+ function openPopup(node) {
76
+ node.props.detached = true
77
+ node.layer = current().layer + 1
78
+ node.parent = current()
79
+ popups.push(node)
80
+ stack.push(node)
81
+ if (node.id) ids.push(node.props.key ?? node.kind)
82
+ return node
83
+ }
84
+
85
+ function registerFocusable(id) {
86
+ focusOrder.push(id)
87
+ }
88
+
89
+ function placePopup(node) {
90
+ measure(node, {})
91
+ const anchor = node.props.anchorNode?.rect ?? node.props.anchor ?? { x: 0, y: 0, w: 0, h: 0 }
92
+ const gap = node.props.gap ?? 6
93
+ let w = typeof node.props.w === 'number' ? node.props.w : Math.max(node.natural.w, node.props.minW ?? 0)
94
+ if (node.props.matchAnchor) w = Math.max(w, anchor.w)
95
+ const h = typeof node.props.h === 'number' ? node.props.h : node.natural.h
96
+ const placement = node.props.placement ?? 'below'
97
+ let x = anchor.x
98
+ let y = anchor.y + anchor.h + gap
99
+ if (placement === 'above') y = anchor.y - h - gap
100
+ if (placement === 'right') {
101
+ x = anchor.x + anchor.w + gap
102
+ y = anchor.y
103
+ }
104
+ if (placement === 'center') {
105
+ x = anchor.x + (anchor.w - w) / 2
106
+ y = anchor.y + (anchor.h - h) / 2
107
+ }
108
+ if (node.props.alignEnd) x = anchor.x + anchor.w - w
109
+ if (x + w > viewport.w - 8) x = viewport.w - 8 - w
110
+ if (y + h > viewport.h - 8) y = placement === 'below' ? anchor.y - h - gap : viewport.h - 8 - h
111
+ x = Math.max(8, x)
112
+ y = Math.max(8, y)
113
+ position(node, { x, y, w, h })
114
+ }
115
+
116
+ function createPaintContext() {
117
+ const strata = []
118
+ const hitList = []
119
+ const scrolls = []
120
+ const ctx = {
121
+ strata,
122
+ hitList,
123
+ scrolls,
124
+ stratum: 0,
125
+ clip: null,
126
+ glass: null,
127
+ theme: CONTENT_THEME,
128
+ order: 0,
129
+ frame,
130
+ probeFor: (index) => probe.current(index),
131
+ glassOps: [],
132
+ stratumFor(bounds, base) {
133
+ let stratum = base
134
+ for (const other of ctx.glassOps) {
135
+ if (other.stratum >= stratum && rectsOverlap(other.bounds, bounds)) stratum = other.stratum + 1
136
+ }
137
+ return stratum
138
+ },
139
+ ensure(index) {
140
+ while (strata.length <= index) strata.push({ ops: [], glass: [] })
141
+ return strata[index]
142
+ },
143
+ fade: null,
144
+ opacity: 1,
145
+ emit(op) {
146
+ op.clip = ctx.clip
147
+ op.fade = ctx.fade
148
+ if (ctx.opacity < 1 && op.color) op.color = [op.color[0], op.color[1], op.color[2], op.color[3] * ctx.opacity]
149
+ ctx.ensure(ctx.stratum).ops.push(op)
150
+ return op
151
+ },
152
+ emitGlass(op) {
153
+ op.clip = ctx.clip
154
+ op.stratum = ctx.stratum
155
+ ctx.ensure(ctx.stratum).glass.push(op)
156
+ ctx.glassOps.push(op)
157
+ return op
158
+ },
159
+ hit(node, rect) {
160
+ hitList.push({ id: node.id, rect: rect ?? node.rect, clip: ctx.clip, layer: node.layer, order: ctx.order++ })
161
+ },
162
+ scroll(node) {
163
+ scrolls.push({ id: node.id, rect: node.rect, clip: ctx.clip, horizontal: Boolean(node.props.horizontal) })
164
+ },
165
+ pushClip(rect) {
166
+ const previous = ctx.clip
167
+ ctx.clip = previous ? rectIntersect(previous, rect) : { ...rect }
168
+ return previous
169
+ },
170
+ popClip(previous) {
171
+ ctx.clip = previous
172
+ },
173
+ }
174
+ return ctx
175
+ }
176
+
177
+ function paintNode(node, ctx) {
178
+ const opacity = node.props.opacity
179
+ const previous = ctx.opacity
180
+ if (opacity != null) ctx.opacity = previous * Math.max(0, Math.min(1, opacity))
181
+ if (opacity != null && ctx.opacity <= 0.001) {
182
+ ctx.opacity = previous
183
+ return
184
+ }
185
+ if (node.paint) node.paint(node, ctx)
186
+ else paintChildren(node, ctx)
187
+ ctx.opacity = previous
188
+ }
189
+
190
+ function paintChildren(node, ctx) {
191
+ for (const child of node.children) {
192
+ if (child.props.detached) continue
193
+ paintNode(child, ctx)
194
+ }
195
+ }
196
+
197
+ function end(options) {
198
+ layout(root, { x: 0, y: 0, w: viewport.w, h: viewport.h }, {})
199
+ for (const popup of popups) placePopup(popup)
200
+
201
+ const ctx = createPaintContext()
202
+ ctx.paintChildren = paintChildren
203
+ ctx.paintNode = paintNode
204
+ paintNode(root, ctx)
205
+
206
+ const byLayer = [...popups].sort((a, b) => a.layer - b.layer)
207
+ for (const popup of byLayer) {
208
+ ctx.stratum = 0
209
+ ctx.clip = null
210
+ ctx.glass = null
211
+ ctx.theme = CONTENT_THEME
212
+ paintNode(popup, ctx)
213
+ }
214
+
215
+ for (const [id, ghost] of ghosts) {
216
+ if (ghost.seen) {
217
+ ghost.seen = false
218
+ continue
219
+ }
220
+ ctx.stratum = 0
221
+ ctx.clip = null
222
+ ctx.glass = null
223
+ ctx.theme = CONTENT_THEME
224
+ if (ghost.paint(ctx)) ghosts.delete(id)
225
+ }
226
+
227
+ if (ctx.strata.length === 0) ctx.ensure(0)
228
+ lastStrata = ctx.strata.map((stratum) => ({ ops: stratum.ops.length, glass: stratum.glass.map((g) => g.id) }))
229
+ renderer.render({ strata: ctx.strata, clearColor: options?.clearColor })
230
+ input.endFrame(ctx.hitList, ctx.scrolls)
231
+ state.nextFrame()
232
+ state.collect()
233
+ return { animating: clock.animating }
234
+ }
235
+
236
+ function ghost(id, paint) {
237
+ ghosts.set(id, { paint, seen: true })
238
+ }
239
+
240
+ function markGhostSeen(id) {
241
+ const entry = ghosts.get(id)
242
+ if (entry) entry.seen = true
243
+ }
244
+
245
+ return {
246
+ begin,
247
+ end,
248
+ open,
249
+ close,
250
+ add,
251
+ openPopup,
252
+ current,
253
+ registerFocusable,
254
+ ghost,
255
+ markGhostSeen,
256
+ get focusOrder() {
257
+ return focusOrder
258
+ },
259
+ get frame() {
260
+ return frame
261
+ },
262
+ get viewport() {
263
+ return viewport
264
+ },
265
+ get root() {
266
+ return root
267
+ },
268
+ get lastStrata() {
269
+ return lastStrata
270
+ },
271
+ }
272
+ }
@@ -0,0 +1,61 @@
1
+ export function rect(x, y, w, h) {
2
+ return { x, y, w, h }
3
+ }
4
+
5
+ export function rectContains(r, px, py) {
6
+ return px >= r.x && py >= r.y && px < r.x + r.w && py < r.y + r.h
7
+ }
8
+
9
+ export function rectIntersect(a, b) {
10
+ const x = Math.max(a.x, b.x)
11
+ const y = Math.max(a.y, b.y)
12
+ const r = Math.min(a.x + a.w, b.x + b.w)
13
+ const btm = Math.min(a.y + a.h, b.y + b.h)
14
+ return { x, y, w: Math.max(0, r - x), h: Math.max(0, btm - y) }
15
+ }
16
+
17
+ export function rectUnion(a, b) {
18
+ if (!a) return { ...b }
19
+ if (!b) return { ...a }
20
+ const x = Math.min(a.x, b.x)
21
+ const y = Math.min(a.y, b.y)
22
+ const r = Math.max(a.x + a.w, b.x + b.w)
23
+ const btm = Math.max(a.y + a.h, b.y + b.h)
24
+ return { x, y, w: r - x, h: btm - y }
25
+ }
26
+
27
+ export function rectInset(r, amount) {
28
+ return { x: r.x + amount, y: r.y + amount, w: r.w - amount * 2, h: r.h - amount * 2 }
29
+ }
30
+
31
+ export function rectCenter(r) {
32
+ return { x: r.x + r.w / 2, y: r.y + r.h / 2 }
33
+ }
34
+
35
+ export function rectLerp(a, b, t) {
36
+ return {
37
+ x: a.x + (b.x - a.x) * t,
38
+ y: a.y + (b.y - a.y) * t,
39
+ w: a.w + (b.w - a.w) * t,
40
+ h: a.h + (b.h - a.h) * t,
41
+ }
42
+ }
43
+
44
+ export function rectEquals(a, b) {
45
+ return a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h
46
+ }
47
+
48
+ export function clamp(v, lo, hi) {
49
+ return v < lo ? lo : v > hi ? hi : v
50
+ }
51
+
52
+ export function lerp(a, b, t) {
53
+ return a + (b - a) * t
54
+ }
55
+
56
+ export function padding(value) {
57
+ if (value == null) return [0, 0, 0, 0]
58
+ if (typeof value === 'number') return [value, value, value, value]
59
+ if (value.length === 2) return [value[0], value[1], value[0], value[1]]
60
+ return value
61
+ }
@@ -0,0 +1,31 @@
1
+ export function createIdScope() {
2
+ const stack = ['']
3
+ const counters = new Map()
4
+
5
+ function push(key) {
6
+ stack.push(stack[stack.length - 1] + '/' + key)
7
+ }
8
+
9
+ function pop() {
10
+ if (stack.length === 1) throw new Error('id scope underflow')
11
+ stack.pop()
12
+ }
13
+
14
+ function current() {
15
+ return stack[stack.length - 1]
16
+ }
17
+
18
+ function make(key) {
19
+ const id = current() + '/' + key
20
+ const count = counters.get(id) ?? 0
21
+ counters.set(id, count + 1)
22
+ return count === 0 ? id : id + '#' + count
23
+ }
24
+
25
+ function reset() {
26
+ stack.length = 1
27
+ counters.clear()
28
+ }
29
+
30
+ return { push, pop, current, make, reset }
31
+ }