@trendr/core 0.2.10 → 0.4.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/src/hotkey.js CHANGED
@@ -1,34 +1,69 @@
1
1
  import { useInput } from './hooks.js'
2
2
  import { registerHook } from './renderer.js'
3
3
 
4
+ const KEY_ALIASES = { enter: 'return', esc: 'escape' }
5
+ const MOD_NAMES = new Set(['ctrl', 'alt', 'meta', 'shift'])
6
+
4
7
  function parseDescriptor(desc) {
5
- const parts = desc.toLowerCase().split('+')
6
- const key = parts.pop()
7
- const mods = { ctrl: false, meta: false }
8
- for (const p of parts) {
9
- if (p === 'ctrl') mods.ctrl = true
10
- if (p === 'alt' || p === 'meta') mods.meta = true
8
+ const mods = { ctrl: false, meta: false, shift: false }
9
+ let key = desc
10
+
11
+ while (true) {
12
+ const idx = key.indexOf('+')
13
+ if (idx <= 0 || idx === key.length - 1) break
14
+ const mod = key.slice(0, idx).toLowerCase()
15
+ if (!MOD_NAMES.has(mod)) break
16
+ if (mod === 'ctrl') mods.ctrl = true
17
+ else if (mod === 'shift') mods.shift = true
18
+ else mods.meta = true
19
+ key = key.slice(idx + 1)
20
+ }
21
+
22
+ if (key.length > 1) {
23
+ key = key.toLowerCase()
24
+ if (KEY_ALIASES[key]) key = KEY_ALIASES[key]
11
25
  }
26
+
27
+ // an uppercase letter descriptor implies shift
28
+ if (key.length === 1 && key !== key.toLowerCase()) {
29
+ mods.shift = true
30
+ }
31
+
12
32
  return { key, ...mods }
13
33
  }
14
34
 
15
- const RAW_KEYS = { return: '\r', enter: '\r' }
35
+ function matches(parsed, event) {
36
+ const key = event.key
37
+ if (typeof key !== 'string') return false
38
+
39
+ // uppercase single-char keys imply shift even when the terminal
40
+ // doesn't report the modifier
41
+ const eventShift = !!event.shift || (key.length === 1 && key !== key.toLowerCase())
42
+
43
+ const keyMatch = parsed.key.length === 1 && key.length === 1
44
+ ? parsed.key.toLowerCase() === key.toLowerCase()
45
+ : parsed.key === key || (parsed.shift && parsed.key === 'tab' && key === 'shift-tab')
46
+
47
+ if (!keyMatch) return false
48
+ if (parsed.ctrl !== !!event.ctrl) return false
49
+ if (parsed.meta !== !!event.meta) return false
50
+ if (parsed.shift !== eventShift && !(parsed.shift && key === 'shift-tab')) return false
51
+ return true
52
+ }
16
53
 
17
54
  export function useHotkey(descriptor, handler, { when } = {}) {
18
- const parsed = registerHook(() => parseDescriptor(descriptor))
19
- const ref = registerHook(() => ({ handler, when }))
55
+ const ref = registerHook(() => ({ descriptor: undefined, parsed: null, handler, when }))
56
+ if (ref.descriptor !== descriptor) {
57
+ ref.descriptor = descriptor
58
+ ref.parsed = parseDescriptor(descriptor)
59
+ }
20
60
  ref.handler = handler
21
61
  ref.when = when
22
62
 
23
63
  useInput((event) => {
24
64
  if (ref.when && !ref.when()) return
25
-
26
- const rawKey = RAW_KEYS[parsed.key] || parsed.key
27
- const keyMatch = event.key === rawKey || event.key === parsed.key
28
- if (!keyMatch) return
29
- if (parsed.ctrl !== event.ctrl) return
30
- if (parsed.meta !== event.meta) return
31
-
65
+ if (event.key === 'paste') return
66
+ if (!matches(ref.parsed, event)) return
32
67
  ref.handler()
33
68
  event.stopPropagation()
34
69
  })
package/src/input.js CHANGED
@@ -5,8 +5,10 @@ const SPECIAL_KEYS = {
5
5
  '\x1b[D': 'left',
6
6
  '\x1b[H': 'home',
7
7
  '\x1b[F': 'end',
8
+ '\x1b[1~': 'home',
8
9
  '\x1b[2~': 'insert',
9
10
  '\x1b[3~': 'delete',
11
+ '\x1b[4~': 'end',
10
12
  '\x1b[5~': 'pageup',
11
13
  '\x1b[6~': 'pagedown',
12
14
  '\x1bOP': 'f1',
@@ -40,20 +42,34 @@ export function parseMouse(raw) {
40
42
  const y = parseInt(m[3], 10) - 1
41
43
  const release = m[4] === 'm'
42
44
  const button = cb & 3
43
- const scroll = (cb & 64) !== 0
45
+ const extended = (cb & 128) !== 0
46
+ const scroll = !extended && (cb & 64) !== 0
44
47
  const motion = (cb & 32) !== 0
45
48
 
46
49
  if (scroll) {
47
- return { type: 'mouse', action: 'scroll', direction: button === 0 ? 'up' : 'down', x, y }
50
+ // wheel codes: 0 up, 1 down, 2 left, 3 right (trackpads emit left/right
51
+ // during a vertical scroll - don't fold those into 'down')
52
+ const direction = button === 0 ? 'up' : button === 1 ? 'down' : button === 2 ? 'left' : 'right'
53
+ return { type: 'mouse', action: 'scroll', direction, x, y }
48
54
  }
49
55
 
50
- const buttonName = button === 0 ? 'left' : button === 1 ? 'middle' : 'right'
56
+ const buttonName = extended
57
+ ? (button === 0 ? 'back' : button === 1 ? 'forward' : button === 2 ? 'button10' : 'button11')
58
+ : (button === 0 ? 'left' : button === 1 ? 'middle' : 'right')
51
59
  const action = release ? 'release' : motion ? 'drag' : 'press'
52
60
  return { type: 'mouse', action, button: buttonName, x, y }
53
61
  }
54
62
 
55
63
  const MODIFY_OTHER_RE = /^\x1b\[27;(\d+);(\d+)~$/
56
64
  const CSI_U_RE = /^\x1b\[(\d+)(?:;(\d+))?u$/
65
+ const CSI_MOD_LETTER_RE = /^\x1b\[1;(\d+)([ABCDHF])$/
66
+ const CSI_MOD_TILDE_RE = /^\x1b\[(\d+);(\d+)~$/
67
+
68
+ const CSI_LETTER_KEYS = { A: 'up', B: 'down', C: 'right', D: 'left', H: 'home', F: 'end' }
69
+ const CSI_TILDE_KEYS = {
70
+ 1: 'home', 2: 'insert', 3: 'delete', 4: 'end', 5: 'pageup', 6: 'pagedown',
71
+ 15: 'f5', 17: 'f6', 18: 'f7', 19: 'f8', 20: 'f9', 21: 'f10', 23: 'f11', 24: 'f12',
72
+ }
57
73
 
58
74
  function codeToKey(code) {
59
75
  if (code === 13 || code === 10) return 'return'
@@ -66,10 +82,10 @@ function codeToKey(code) {
66
82
 
67
83
  // modifier param is 1-based with a bitmask in the low bits: shift=1, alt=2,
68
84
  // ctrl=4 (so plain=1, shift=2, ctrl=5, etc)
69
- function modifiedKey(code, mod, raw) {
85
+ function withMods(key, mod, raw) {
70
86
  const bits = Math.max(0, mod - 1)
71
87
  return {
72
- key: codeToKey(code),
88
+ key,
73
89
  ctrl: (bits & 4) !== 0,
74
90
  meta: (bits & 2) !== 0,
75
91
  shift: (bits & 1) !== 0,
@@ -77,6 +93,11 @@ function modifiedKey(code, mod, raw) {
77
93
  }
78
94
  }
79
95
 
96
+ function isSingleCodePoint(s) {
97
+ if (s.length === 1) return true
98
+ return s.length === 2 && s.codePointAt(0) > 0xffff
99
+ }
100
+
80
101
  export function parseKey(data) {
81
102
  const raw = typeof data === 'string' ? data : data.toString()
82
103
 
@@ -84,11 +105,26 @@ export function parseKey(data) {
84
105
  return { key: SPECIAL_KEYS[raw], ctrl: false, meta: false, shift: false, raw }
85
106
  }
86
107
 
108
+ if (raw === '\x1b\x1b') {
109
+ return { key: 'escape', ctrl: false, meta: false, shift: false, raw }
110
+ }
111
+
112
+ if (raw.length > 2 && raw.startsWith('\x1b\x1b')) {
113
+ const inner = parseKey(raw.slice(1))
114
+ return { key: inner.key, ctrl: inner.ctrl, meta: true, shift: inner.shift, raw }
115
+ }
116
+
87
117
  let m = MODIFY_OTHER_RE.exec(raw)
88
- if (m) return modifiedKey(parseInt(m[2], 10), parseInt(m[1], 10), raw)
118
+ if (m) return withMods(codeToKey(parseInt(m[2], 10)), parseInt(m[1], 10), raw)
89
119
 
90
120
  m = CSI_U_RE.exec(raw)
91
- if (m) return modifiedKey(parseInt(m[1], 10), m[2] ? parseInt(m[2], 10) : 1, raw)
121
+ if (m) return withMods(codeToKey(parseInt(m[1], 10)), m[2] ? parseInt(m[2], 10) : 1, raw)
122
+
123
+ m = CSI_MOD_LETTER_RE.exec(raw)
124
+ if (m) return withMods(CSI_LETTER_KEYS[m[2]], parseInt(m[1], 10), raw)
125
+
126
+ m = CSI_MOD_TILDE_RE.exec(raw)
127
+ if (m && CSI_TILDE_KEYS[m[1]]) return withMods(CSI_TILDE_KEYS[m[1]], parseInt(m[2], 10), raw)
92
128
 
93
129
  if (raw.length === 1) {
94
130
  const code = raw.charCodeAt(0)
@@ -106,94 +142,193 @@ export function parseKey(data) {
106
142
  return { key: raw, ctrl: false, meta: false, shift: false, raw }
107
143
  }
108
144
 
109
- if (raw.startsWith('\x1b') && raw.length === 2) {
110
- return { key: raw[1], ctrl: false, meta: true, shift: false, raw }
145
+ if (isSingleCodePoint(raw)) {
146
+ return { key: raw, ctrl: false, meta: false, shift: false, raw }
147
+ }
148
+
149
+ if (raw[0] === '\x1b' && isSingleCodePoint(raw.slice(1))) {
150
+ const inner = parseKey(raw.slice(1))
151
+ return { key: inner.key, ctrl: inner.ctrl, meta: true, shift: inner.shift, raw }
111
152
  }
112
153
 
113
154
  return { key: raw, ctrl: false, meta: false, shift: false, raw }
114
155
  }
115
156
 
157
+ // reads one token off the head of s. returns null when the head is an
158
+ // incomplete escape sequence (or a split surrogate pair) that needs more bytes
159
+ function nextToken(s) {
160
+ if (s[0] !== '\x1b') {
161
+ const c0 = s.charCodeAt(0)
162
+ if (c0 >= 0xd800 && c0 <= 0xdbff && s.length === 1) return null
163
+ const cp = s.codePointAt(0)
164
+ return s.slice(0, cp > 0xffff ? 2 : 1)
165
+ }
166
+
167
+ if (s.length === 1) return null
168
+
169
+ let i = 1
170
+ if (s[1] === '\x1b') {
171
+ // could be double-esc (two escape events) or an alt-prefixed sequence
172
+ // like \x1b\x1b[A - wait for a third byte to disambiguate
173
+ if (s.length === 2) return null
174
+ if (s[2] === '[' || s[2] === 'O') i = 2
175
+ else return '\x1b'
176
+ }
177
+
178
+ const c = s[i]
179
+
180
+ if (c === '[') {
181
+ let j = i + 1
182
+ if (j < s.length && (s[j] === '<' || s[j] === '?')) j++
183
+ while (j < s.length && ((s[j] >= '0' && s[j] <= '9') || s[j] === ';' || s[j] === ':')) j++
184
+ if (j >= s.length) return null
185
+ return s.slice(0, j + 1)
186
+ }
187
+
188
+ if (c === 'O') {
189
+ if (i + 1 >= s.length) return null
190
+ return s.slice(0, i + 2)
191
+ }
192
+
193
+ const cc = s.charCodeAt(i)
194
+ if (cc >= 0xd800 && cc <= 0xdbff && i + 1 >= s.length) return null
195
+ const cp = s.codePointAt(i)
196
+ return s.slice(0, i + (cp > 0xffff ? 2 : 1))
197
+ }
198
+
116
199
  export function splitKeys(data) {
117
200
  const raw = typeof data === 'string' ? data : data.toString()
118
201
  const keys = []
119
- let i = 0
120
-
121
- while (i < raw.length) {
122
- if (raw[i] === '\x1b') {
123
- if (i + 1 < raw.length && raw[i + 1] === '[') {
124
- // generic csi: optional private marker, any number of ;-separated
125
- // numeric params, then a final byte. covers arrows, mouse (\x1b[<..M),
126
- // csi-u (\x1b[13;2u) and modifyOtherKeys (\x1b[27;2;13~)
127
- let j = i + 2
128
- if (j < raw.length && (raw[j] === '<' || raw[j] === '?')) j++
129
- while (j < raw.length && ((raw[j] >= '0' && raw[j] <= '9') || raw[j] === ';' || raw[j] === ':')) j++
130
- if (j < raw.length) j++
131
- keys.push(raw.slice(i, j))
132
- i = j
133
- } else if (i + 1 < raw.length && raw[i + 1] === 'O') {
134
- const end = Math.min(i + 3, raw.length)
135
- keys.push(raw.slice(i, end))
136
- i = end
137
- } else if (i + 1 < raw.length) {
138
- keys.push(raw.slice(i, i + 2))
139
- i += 2
140
- } else {
141
- keys.push(raw[i])
142
- i++
143
- }
144
- } else {
145
- keys.push(raw[i])
146
- i++
202
+ let s = raw
203
+
204
+ while (s.length > 0) {
205
+ const token = nextToken(s)
206
+ if (token !== null) {
207
+ keys.push(token)
208
+ s = s.slice(token.length)
209
+ continue
210
+ }
211
+ // incomplete tail - split leading escapes apart, keep the rest whole
212
+ if (s === '\x1b') {
213
+ keys.push(s)
214
+ break
215
+ }
216
+ if (s.startsWith('\x1b\x1b')) {
217
+ keys.push('\x1b')
218
+ s = s.slice(1)
219
+ continue
147
220
  }
221
+ keys.push(s)
222
+ break
148
223
  }
149
224
 
150
225
  return keys
151
226
  }
152
227
 
153
- export function createInputHandler(stream) {
154
- const keyListeners = new Set()
155
- const mouseListeners = new Set()
228
+ const PASTE_START = '\x1b[200~'
229
+ const PASTE_END = '\x1b[201~'
230
+
231
+ // longest suffix of s that is a proper prefix of marker
232
+ function partialSuffixLen(s, marker) {
233
+ const max = Math.min(marker.length - 1, s.length)
234
+ for (let len = max; len > 0; len--) {
235
+ if (s.endsWith(marker.slice(0, len))) return len
236
+ }
237
+ return 0
238
+ }
239
+
240
+ export function createInputHandler(stream, options = {}) {
241
+ const {
242
+ escDelay = 25,
243
+ setTimer = (fn, ms) => setTimeout(fn, ms),
244
+ clearTimer = (id) => clearTimeout(id),
245
+ isEligible = () => true,
246
+ } = options
247
+
248
+ const keyListeners = new Map()
249
+ const mouseListeners = new Map()
250
+
251
+ let pending = ''
252
+ let inPaste = false
253
+ let pasteData = ''
254
+ let escTimer = null
255
+
256
+ function fire(event, listeners) {
257
+ event.stopPropagation = () => { event._stopped = true }
258
+ const snapshot = [...listeners].reverse()
259
+ for (const [fn, owner] of snapshot) {
260
+ if (!isEligible(owner)) continue
261
+ fn(event)
262
+ if (event._stopped) break
263
+ }
264
+ }
156
265
 
157
266
  function dispatch(keyStr) {
158
267
  const mouse = parseMouse(keyStr)
159
268
  if (mouse) {
160
- mouse.stopPropagation = () => { mouse._stopped = true }
161
- const snapshot = [...mouseListeners].reverse()
162
- for (const fn of snapshot) {
163
- fn(mouse)
164
- if (mouse._stopped) break
165
- }
269
+ fire(mouse, mouseListeners)
166
270
  return
167
271
  }
272
+ fire(parseKey(keyStr), keyListeners)
273
+ }
274
+
275
+ function dispatchPaste(text) {
276
+ const normalized = text.replace(/\r\n?/g, '\n')
277
+ fire({ key: 'paste', text: normalized, ctrl: false, meta: false, shift: false, raw: normalized }, keyListeners)
278
+ }
279
+
280
+ function processPending() {
281
+ while (pending.length > 0) {
282
+ if (inPaste) {
283
+ const idx = pending.indexOf(PASTE_END)
284
+ if (idx === -1) {
285
+ const keep = partialSuffixLen(pending, PASTE_END)
286
+ pasteData += pending.slice(0, pending.length - keep)
287
+ pending = pending.slice(pending.length - keep)
288
+ return
289
+ }
290
+ pasteData += pending.slice(0, idx)
291
+ pending = pending.slice(idx + PASTE_END.length)
292
+ inPaste = false
293
+ const text = pasteData
294
+ pasteData = ''
295
+ dispatchPaste(text)
296
+ continue
297
+ }
298
+
299
+ const token = nextToken(pending)
300
+ if (token === null) return
301
+ pending = pending.slice(token.length)
302
+ if (token === PASTE_START) {
303
+ inPaste = true
304
+ continue
305
+ }
306
+ if (token === PASTE_END) continue
307
+ dispatch(token)
308
+ }
309
+ }
168
310
 
169
- const event = parseKey(keyStr)
170
- event.stopPropagation = () => { event._stopped = true }
171
- const snapshot = [...keyListeners].reverse()
172
- for (const fn of snapshot) {
173
- fn(event)
174
- if (event._stopped) break
311
+ function clearEscTimer() {
312
+ if (escTimer !== null) {
313
+ clearTimer(escTimer)
314
+ escTimer = null
175
315
  }
176
316
  }
177
317
 
178
- function isPaste(data) {
179
- const s = typeof data === 'string' ? data : data.toString()
180
- return s.length > 1 && !s.startsWith('\x1b') && (s.includes('\n') || s.includes('\r'))
318
+ function flushPending() {
319
+ escTimer = null
320
+ if (pending.length === 0 || inPaste) return
321
+ const flush = pending
322
+ pending = ''
323
+ for (const key of splitKeys(flush)) dispatch(key)
181
324
  }
182
325
 
183
326
  function onData(data) {
184
- if (isPaste(data)) {
185
- const text = (typeof data === 'string' ? data : data.toString()).replace(/\r\n?/g, '\n')
186
- const event = { key: 'paste', text, ctrl: false, meta: false, shift: false, raw: text }
187
- event.stopPropagation = () => { event._stopped = true }
188
- const snapshot = [...keyListeners].reverse()
189
- for (const fn of snapshot) {
190
- fn(event)
191
- if (event._stopped) break
192
- }
193
- return
194
- }
195
- for (const key of splitKeys(data)) {
196
- dispatch(key)
327
+ clearEscTimer()
328
+ pending += typeof data === 'string' ? data : data.toString('utf8')
329
+ processPending()
330
+ if (pending.length > 0 && !inPaste) {
331
+ escTimer = setTimer(flushPending, escDelay)
197
332
  }
198
333
  }
199
334
 
@@ -209,10 +344,14 @@ export function createInputHandler(stream) {
209
344
  if (!attached) return
210
345
  attached = false
211
346
  stream.off('data', onData)
347
+ clearEscTimer()
348
+ pending = ''
349
+ inPaste = false
350
+ pasteData = ''
212
351
  }
213
352
 
214
- function onKey(fn) {
215
- keyListeners.add(fn)
353
+ function onKey(fn, owner = null) {
354
+ keyListeners.set(fn, owner)
216
355
  if (keyListeners.size + mouseListeners.size === 1) attach()
217
356
  return () => {
218
357
  keyListeners.delete(fn)
@@ -220,8 +359,8 @@ export function createInputHandler(stream) {
220
359
  }
221
360
  }
222
361
 
223
- function onMouse(fn) {
224
- mouseListeners.add(fn)
362
+ function onMouse(fn, owner = null) {
363
+ mouseListeners.set(fn, owner)
225
364
  if (keyListeners.size + mouseListeners.size === 1) attach()
226
365
  return () => {
227
366
  mouseListeners.delete(fn)
package/src/layout.js CHANGED
@@ -111,8 +111,14 @@ export function computeLayout(node, rect) {
111
111
  node._childHeights = heights
112
112
  }
113
113
 
114
+ // absolute children anchor to the padding box (inside borders, ignoring
115
+ // padding), matching CSS containing-block behavior
116
+ const padBoxX = box.x + be.left
117
+ const padBoxY = box.y + be.top
118
+ const padBoxW = Math.max(0, box.width - be.left - be.right)
119
+ const padBoxH = Math.max(0, box.height - be.top - be.bottom)
114
120
  for (const child of absChildren) {
115
- layoutAbsolute(child, innerX, innerY, innerW, innerH)
121
+ layoutAbsolute(child, padBoxX, padBoxY, padBoxW, padBoxH)
116
122
  }
117
123
  }
118
124
 
@@ -166,7 +172,9 @@ function layoutFlex(children, ctx) {
166
172
  totalFlex += grow
167
173
  childInfo.push({ child, cs, grow, minMain, marginMain, marginCross, margin, measured: null })
168
174
  } else {
169
- const measured = measureChild(child, cs, isRow, width, height)
175
+ const measureW = isRow ? width : Math.max(0, width - marginCross)
176
+ const measureH = isRow ? Math.max(0, height - marginCross) : height
177
+ const measured = measureChild(child, cs, isRow, measureW, measureH)
170
178
  const childMain = isRow ? measured.width : measured.height
171
179
  usedMain += childMain + marginMain
172
180
  childInfo.push({ child, cs, grow: 0, minMain: childMain, marginMain, marginCross, margin, measured })
@@ -197,19 +205,27 @@ function layoutFlex(children, ctx) {
197
205
  }
198
206
 
199
207
  let pos = mainOffset
208
+ let cumFlex = 0
209
+ let flexAllocated = 0
200
210
 
201
211
  for (const info of childInfo) {
202
212
  const { child, cs, grow, minMain, marginMain, marginCross, margin, measured } = info
203
213
 
204
214
  let childMain
205
215
  if (grow > 0) {
206
- const extra = totalFlex > 0 ? Math.floor(remaining * (grow / totalFlex)) : 0
207
- childMain = minMain + extra
216
+ cumFlex += grow
217
+ const target = Math.floor(remaining * (cumFlex / totalFlex))
218
+ childMain = minMain + (target - flexAllocated)
219
+ flexAllocated = target
208
220
  } else {
209
221
  childMain = minMain
210
222
  }
211
223
 
212
- const mainRemaining = mainSize - pos - marginMain
224
+ // pos accumulates fractional space-between/around spacing, so rects
225
+ // always get integer coordinates
226
+ const mainPos = Math.round(pos)
227
+
228
+ const mainRemaining = mainSize - mainPos - marginMain
213
229
  if (childMain > mainRemaining) childMain = Math.max(0, mainRemaining)
214
230
 
215
231
  const explicitCross = isRow
@@ -237,8 +253,8 @@ function layoutFlex(children, ctx) {
237
253
  }
238
254
 
239
255
  const childRect = isRow
240
- ? { x: x + pos + marginBefore, y: y + crossOffset, width: childMain, height: childCross }
241
- : { x: x + crossOffset, y: y + pos + marginBefore, width: childCross, height: childMain }
256
+ ? { x: x + mainPos + marginBefore, y: y + crossOffset, width: childMain, height: childCross }
257
+ : { x: x + crossOffset, y: y + mainPos + marginBefore, width: childCross, height: childMain }
242
258
 
243
259
  computeLayout(child, childRect)
244
260
 
@@ -277,7 +293,7 @@ function measureChild(child, cs, isRow, availW, availH) {
277
293
  w = explicitW
278
294
  h = explicitH
279
295
  } else {
280
- const intrinsic = measureIntrinsic(leaf, availW, availH)
296
+ const intrinsic = measureIntrinsic(leaf, explicitW ?? availW, explicitH ?? availH)
281
297
  w = explicitW ?? intrinsic.width
282
298
  h = explicitH ?? intrinsic.height
283
299
  }
@@ -330,19 +346,24 @@ function measureIntrinsic(node, availW, availH) {
330
346
  totalFlex += grow
331
347
  infos.push({ child, cs, grow, marginMain, marginCross, measured: null })
332
348
  } else {
333
- const measured = measureChild(child, cs, true, innerW, innerH)
349
+ const measured = measureChild(child, cs, true, innerW, Math.max(0, innerH - marginCross))
334
350
  usedByFixed += measured.width + marginMain
335
351
  infos.push({ child, cs, grow: 0, marginMain, marginCross, measured })
336
352
  }
337
353
  }
338
354
 
339
355
  const flexSpace = Math.max(0, innerW - usedByFixed)
356
+ let cumFlex = 0
357
+ let flexAllocated = 0
340
358
 
341
359
  for (const info of infos) {
342
360
  let measured = info.measured
343
361
  if (info.grow > 0) {
344
- const childW = Math.floor(flexSpace * (info.grow / totalFlex))
345
- measured = measureChild(info.child, info.cs, true, childW, innerH)
362
+ cumFlex += info.grow
363
+ const target = Math.floor(flexSpace * (cumFlex / totalFlex))
364
+ const childW = target - flexAllocated
365
+ flexAllocated = target
366
+ measured = measureChild(info.child, info.cs, true, childW, Math.max(0, innerH - info.marginCross))
346
367
  }
347
368
  mainTotal += (measured.width + info.marginMain)
348
369
  const childCross = measured.height + info.marginCross
@@ -353,10 +374,10 @@ function measureIntrinsic(node, availW, availH) {
353
374
  } else {
354
375
  for (const child of children) {
355
376
  const cs = childStyle(child)
356
- const measured = measureChild(child, cs, false, innerW, innerH)
357
377
  const margin = resolveMargin(cs)
358
378
  const marginMain = margin.top + margin.bottom
359
379
  const marginCross = margin.left + margin.right
380
+ const measured = measureChild(child, cs, false, Math.max(0, innerW - marginCross), innerH)
360
381
 
361
382
  mainTotal += measured.height + marginMain
362
383
  const childCross = measured.width + marginCross
@@ -392,8 +413,8 @@ function resolveSize(value, available) {
392
413
  }
393
414
 
394
415
  function clampSize(value, min, max) {
395
- if (min != null && value < min) value = min
396
416
  if (max != null && value > max) value = max
417
+ if (min != null && value < min) value = min
397
418
  return Math.max(0, Math.floor(value))
398
419
  }
399
420