@trendr/core 0.2.11 → 0.4.1

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,238 @@
1
+ import { jsx, jsxs } from '../jsx-runtime.js'
2
+ import { createSignal } from './signal.js'
3
+ import { useInput, useMouse, useLayout, useScrollDrag } from './hooks.js'
4
+ import { sliceVisibleRange } from './wrap.js'
5
+ import { computeDiff } from './diff-engine.js'
6
+
7
+ const PALETTE = {
8
+ addBg: '#10301a', addGutterBg: '#0a2412', addIntraBg: '#1f6e34', addMarker: '#7ee787',
9
+ delBg: '#3a1417', delGutterBg: '#2c0e11', delIntraBg: '#86262c', delMarker: '#f47067',
10
+ lineNo: '#5c6370',
11
+ hunk: '#7aa2f7',
12
+ meta: '#5c6370',
13
+ fold: '#5c6370', foldBg: '#14161c',
14
+ }
15
+
16
+ const cpLength = (s) => {
17
+ let n = 0
18
+ for (const _ of s) n++
19
+ return n
20
+ }
21
+
22
+ function digits(rows, field) {
23
+ let max = 0
24
+ for (const r of rows) if (r[field] != null && r[field] > max) max = r[field]
25
+ return Math.max(1, String(max).length)
26
+ }
27
+
28
+ // splits a highlighted line into normal/hot segments around the changed ranges
29
+ function intraSegments(ansiLine, plainLen, ranges) {
30
+ const segs = []
31
+ let pos = 0
32
+ for (const [start, end] of ranges) {
33
+ if (start > pos) segs.push({ text: sliceVisibleRange(ansiLine, pos, start), hot: false })
34
+ segs.push({ text: sliceVisibleRange(ansiLine, start, end), hot: true })
35
+ pos = end
36
+ }
37
+ if (pos < plainLen) segs.push({ text: sliceVisibleRange(ansiLine, pos, plainLen), hot: false })
38
+ return segs
39
+ }
40
+
41
+ export function Diff({
42
+ before,
43
+ after,
44
+ patch,
45
+ hunks,
46
+ language = 'text',
47
+ filename,
48
+ highlight,
49
+ wordDiff = true,
50
+ context = Infinity,
51
+ lineNumbers = true,
52
+ focused = true,
53
+ scrollOffset: offsetProp,
54
+ onScroll,
55
+ scrollbar = true,
56
+ colors,
57
+ }) {
58
+ const palette = colors ? { ...PALETTE, ...colors } : PALETTE
59
+ const [offsetInternal, setOffsetInternal] = createSignal(0)
60
+ const layout = useLayout()
61
+
62
+ const offset = offsetProp ?? offsetInternal()
63
+ const setOffset = onScroll ?? setOffsetInternal
64
+
65
+ const { rows, stats } = computeDiff({ before, after, patch, hunks, wordDiff, context })
66
+
67
+ const wholeMode = hunks == null && patch == null
68
+ const hiCache = new Map()
69
+ const hiLines = (text) => {
70
+ if (hiCache.has(text)) return hiCache.get(text)
71
+ const lines = (highlight ? highlight(text, language) : text).split('\n')
72
+ hiCache.set(text, lines)
73
+ return lines
74
+ }
75
+ const beforeHi = wholeMode && highlight ? hiLines(before ?? '') : null
76
+ const afterHi = wholeMode && highlight ? hiLines(after ?? '') : null
77
+
78
+ const lineAnsi = (row) => {
79
+ if (!highlight) return row.text
80
+ if (wholeMode) {
81
+ if (row.type === 'del') return beforeHi[row.oldNo - 1] ?? row.text
82
+ if (row.newNo != null) return afterHi[row.newNo - 1] ?? row.text
83
+ return row.text
84
+ }
85
+ return hiLines(row.text)[0] ?? row.text
86
+ }
87
+
88
+ const oldW = digits(rows, 'oldNo')
89
+ const newW = digits(rows, 'newNo')
90
+
91
+ const headerH = filename ? 1 : 0
92
+ const h = Math.max(0, layout.height - headerH)
93
+ const maxOffset = Math.max(0, rows.length - h)
94
+ const clamp = (v) => Math.max(0, Math.min(maxOffset, v))
95
+ const clamped = clamp(offset)
96
+
97
+ useInput(({ key, ctrl }) => {
98
+ if (!focused || rows.length === 0) return
99
+ const pageH = h || 10
100
+ const half = Math.max(1, Math.floor(pageH / 2))
101
+ if (key === 'up' || key === 'k') setOffset(clamp(clamped - 1))
102
+ else if (key === 'down' || key === 'j') setOffset(clamp(clamped + 1))
103
+ else if (key === 'pageup' || (ctrl && key === 'b')) setOffset(clamp(clamped - pageH))
104
+ else if (key === 'pagedown' || (ctrl && key === 'f')) setOffset(clamp(clamped + pageH))
105
+ else if (ctrl && key === 'u') setOffset(clamp(clamped - half))
106
+ else if (ctrl && key === 'd') setOffset(clamp(clamped + half))
107
+ else if (key === 'home' || key === 'g') setOffset(0)
108
+ else if (key === 'end' || key === 'G') setOffset(maxOffset)
109
+ })
110
+
111
+ useMouse((event) => {
112
+ if (event.action !== 'scroll' || rows.length <= h) return
113
+ const { x, y } = event
114
+ if (x < layout.x || x >= layout.x + layout.width || y < layout.y || y >= layout.y + layout.height) return
115
+ if (event.direction !== 'up' && event.direction !== 'down') return
116
+ setOffset(clamp(clamped + (event.direction === 'up' ? -3 : 3)))
117
+ event.stopPropagation()
118
+ })
119
+
120
+ const hasBar = scrollbar && h > 0 && rows.length > h
121
+ const barW = hasBar ? 1 : 0
122
+ const thumbH = hasBar ? Math.max(1, Math.round((h / rows.length) * h)) : 0
123
+ const thumbStart = hasBar && maxOffset > 0 ? Math.round((clamped / maxOffset) * (h - thumbH)) : 0
124
+
125
+ useScrollDrag({
126
+ barX: hasBar ? layout.x + layout.width - 1 : null,
127
+ barY: layout.y + headerH + thumbStart,
128
+ thumbHeight: thumbH,
129
+ trackHeight: h,
130
+ maxOffset,
131
+ scrollOffset: clamped,
132
+ onScroll: (v) => setOffset(clamp(v)),
133
+ })
134
+
135
+ const renderGutter = (row) => {
136
+ const gutterBg = row.type === 'add' ? palette.addGutterBg : row.type === 'del' ? palette.delGutterBg : null
137
+ const markerColor = row.type === 'add' ? palette.addMarker : row.type === 'del' ? palette.delMarker : palette.lineNo
138
+ const marker = row.type === 'add' ? '+' : row.type === 'del' ? '-' : ' '
139
+ const children = []
140
+
141
+ if (lineNumbers) {
142
+ const oldStr = row.oldNo != null ? String(row.oldNo) : ''
143
+ const newStr = row.newNo != null ? String(row.newNo) : ''
144
+ children.push(jsx('text', {
145
+ style: { color: palette.lineNo, bg: gutterBg, overflow: 'nowrap' },
146
+ children: ` ${oldStr.padStart(oldW)} ${newStr.padStart(newW)} `,
147
+ }))
148
+ }
149
+ children.push(jsx('text', {
150
+ style: { color: markerColor, bg: gutterBg, bold: true, overflow: 'nowrap' },
151
+ children: `${marker} `,
152
+ }))
153
+ return children
154
+ }
155
+
156
+ const renderContent = (row) => {
157
+ const ansi = lineAnsi(row)
158
+ if (!row.intra || row.intra.length === 0) {
159
+ return [jsx('text', { style: { overflow: 'nowrap' }, children: ansi || ' ' })]
160
+ }
161
+ const intraBg = row.type === 'add' ? palette.addIntraBg : palette.delIntraBg
162
+ const plainLen = cpLength(row.text)
163
+ return intraSegments(ansi, plainLen, row.intra).map((seg, i) =>
164
+ jsx('text', {
165
+ key: i,
166
+ style: { overflow: 'nowrap', bg: seg.hot ? intraBg : null },
167
+ children: seg.text,
168
+ })
169
+ )
170
+ }
171
+
172
+ const renderRow = (row, key) => {
173
+ if (row.type === 'hunk' || row.type === 'meta' || row.type === 'fold') {
174
+ const color = row.type === 'hunk' ? palette.hunk : palette.meta
175
+ const text = row.type === 'fold' ? `··· ${row.text} ···` : row.text
176
+ return jsx('box', {
177
+ key,
178
+ style: { height: 1, paddingX: 1, bg: row.type === 'fold' ? palette.foldBg : null },
179
+ children: jsx('text', { style: { color, dim: row.type !== 'hunk', overflow: 'nowrap' }, children: text }),
180
+ })
181
+ }
182
+
183
+ const rowBg = row.type === 'add' ? palette.addBg : row.type === 'del' ? palette.delBg : null
184
+ return jsxs('box', {
185
+ key,
186
+ style: { flexDirection: 'row', height: 1, bg: rowBg },
187
+ children: [...renderGutter(row), ...renderContent(row)],
188
+ })
189
+ }
190
+
191
+ const visible = h > 0 ? rows.slice(clamped, clamped + h) : []
192
+ const bodyRows = visible.map((row, i) => renderRow(row, clamped + i))
193
+
194
+ let body
195
+ if (rows.length === 0) {
196
+ body = jsx('box', {
197
+ style: { flexGrow: 1, justifyContent: 'center', alignItems: 'center' },
198
+ children: jsx('text', { style: { color: palette.meta, dim: true }, children: 'No changes' }),
199
+ })
200
+ } else if (hasBar) {
201
+ const bar = []
202
+ for (let i = 0; i < h; i++) {
203
+ const isThumb = i >= thumbStart && i < thumbStart + thumbH
204
+ bar.push(jsx('text', {
205
+ key: i,
206
+ style: { color: isThumb ? palette.lineNo : palette.lineNo, dim: !isThumb },
207
+ children: isThumb ? '█' : '│',
208
+ }))
209
+ }
210
+ body = jsxs('box', {
211
+ style: { flexDirection: 'row', flexGrow: 1 },
212
+ children: [
213
+ jsx('box', { style: { flexDirection: 'column', flexGrow: 1 }, children: bodyRows }),
214
+ jsx('box', { style: { flexDirection: 'column', width: barW }, children: bar }),
215
+ ],
216
+ })
217
+ } else {
218
+ body = jsx('box', { style: { flexDirection: 'column', flexGrow: 1 }, children: bodyRows })
219
+ }
220
+
221
+ if (!filename) return body
222
+
223
+ return jsxs('box', {
224
+ style: { flexDirection: 'column', flexGrow: 1 },
225
+ children: [
226
+ jsxs('box', {
227
+ style: { flexDirection: 'row', paddingX: 1, height: 1 },
228
+ children: [
229
+ jsx('text', { style: { bold: true, overflow: 'nowrap' }, children: filename }),
230
+ jsx('box', { style: { flexGrow: 1 } }),
231
+ jsx('text', { style: { color: palette.addMarker, overflow: 'nowrap' }, children: `+${stats.additions}` }),
232
+ jsx('text', { style: { color: palette.delMarker, overflow: 'nowrap' }, children: ` -${stats.deletions}` }),
233
+ ],
234
+ }),
235
+ body,
236
+ ],
237
+ })
238
+ }
package/src/diff.js CHANGED
@@ -102,9 +102,9 @@ function writeReset() {
102
102
  }
103
103
 
104
104
  function writeChar(ch) {
105
- ensure(4)
105
+ ensure(ch.length * 4)
106
106
  const code = ch.charCodeAt(0)
107
- if (code < 0x80) {
107
+ if (code < 0x80 && ch.length === 1) {
108
108
  buf[pos++] = code
109
109
  } else {
110
110
  pos += buf.write(ch, pos)
@@ -0,0 +1,147 @@
1
+ import { jsx } from '../jsx-runtime.js'
2
+ import { useMouse, useLayout, useTheme } from './hooks.js'
3
+ import { registerOverlay, registerHook } from './renderer.js'
4
+
5
+ export function followCursor(cursor, scroll, visibleCount, len) {
6
+ let next = scroll
7
+ if (cursor < next) next = cursor
8
+ else if (cursor >= next + visibleCount) next = cursor - visibleCount + 1
9
+ return Math.max(0, Math.min(next, len - visibleCount))
10
+ }
11
+
12
+ export function placeDropdown({ anchorTop, itemCount, maxVisible, termH }) {
13
+ const anchorY = anchorTop + 1
14
+ const spaceBelow = termH - anchorY - 2
15
+ const spaceAbove = anchorTop - 2
16
+ let direction = 'down'
17
+ let maxRows = Math.min(itemCount, maxVisible)
18
+ if (spaceBelow >= maxRows) {
19
+ maxRows = Math.min(maxRows, spaceBelow)
20
+ } else if (spaceAbove > spaceBelow) {
21
+ direction = 'up'
22
+ maxRows = Math.min(maxRows, spaceAbove)
23
+ } else {
24
+ maxRows = Math.min(maxRows, spaceBelow)
25
+ }
26
+ return { direction, visibleCount: Math.max(1, maxRows) }
27
+ }
28
+
29
+ export function overlayDropdown({ x, y, termW, termH, dropdown }) {
30
+ registerOverlay(jsx('box', {
31
+ style: { width: termW, height: termH },
32
+ children: jsx('box', {
33
+ style: { position: 'absolute', top: Math.max(0, y), left: x },
34
+ children: dropdown,
35
+ }),
36
+ }), { fullscreen: true })
37
+ }
38
+
39
+ export function Dropdown({ items, cursor, scroll, visibleCount, width, onSubmit, onClose, onCursorChange, renderRow, style: s }) {
40
+ const { muted = 'gray' } = useTheme()
41
+ const layout = useLayout()
42
+ const drag = registerHook(() => ({ active: false, startY: 0, startCursor: 0 }))
43
+ const scrollable = items.length > visibleCount
44
+
45
+ useMouse((event) => {
46
+ if (event.action === 'release') {
47
+ if (drag.active) drag.active = false
48
+ return
49
+ }
50
+
51
+ if (event.action === 'drag' && drag.active) {
52
+ const thumbH = Math.max(1, Math.round((visibleCount / items.length) * visibleCount))
53
+ const dy = event.y - drag.startY
54
+ const travel = Math.max(1, visibleCount - thumbH)
55
+ const ratio = (items.length - 1) / travel
56
+ const idx = Math.max(0, Math.min(items.length - 1, Math.round(drag.startCursor + dy * ratio)))
57
+ onCursorChange(idx)
58
+ event.stopPropagation()
59
+ return
60
+ }
61
+
62
+ // layout not yet computed - consume event but don't act
63
+ if (layout.width === 0 || layout.height === 0) {
64
+ event.stopPropagation()
65
+ return
66
+ }
67
+
68
+ const { x, y } = event
69
+ const boxRight = layout.x + width
70
+ const inside = x >= layout.x && x < boxRight && y >= layout.y && y < layout.y + layout.height
71
+
72
+ if (event.action === 'scroll') {
73
+ if (!inside) return
74
+ if (event.direction !== 'up' && event.direction !== 'down') return
75
+ if (event.direction === 'up') onCursorChange(Math.max(0, cursor - 1))
76
+ else onCursorChange(Math.min(items.length - 1, cursor + 1))
77
+ event.stopPropagation()
78
+ return
79
+ }
80
+
81
+ if (event.action !== 'press' || event.button !== 'left') return
82
+
83
+ if (!inside) {
84
+ onClose()
85
+ event.stopPropagation()
86
+ return
87
+ }
88
+
89
+ if (scrollable && x >= boxRight - 4) {
90
+ const maxSc = items.length - visibleCount
91
+ const thumbH = Math.max(1, Math.round((visibleCount / items.length) * visibleCount))
92
+ const thumbStart = maxSc > 0 ? Math.round((scroll / maxSc) * (visibleCount - thumbH)) : 0
93
+ const barY = layout.y + 1 + thumbStart
94
+ if (y >= barY && y < barY + thumbH) {
95
+ drag.active = true
96
+ drag.startY = y
97
+ drag.startCursor = cursor
98
+ }
99
+ event.stopPropagation()
100
+ return
101
+ }
102
+
103
+ const relY = y - layout.y - 1
104
+ if (relY >= 0 && relY < visibleCount) {
105
+ const clickedIdx = relY + scroll
106
+ if (clickedIdx >= 0 && clickedIdx < items.length) {
107
+ onSubmit(items[clickedIdx], clickedIdx)
108
+ event.stopPropagation()
109
+ }
110
+ }
111
+ })
112
+
113
+ const visible = items.slice(scroll, scroll + visibleCount)
114
+ const thumbH = scrollable ? Math.max(1, Math.round((visibleCount / items.length) * visibleCount)) : 0
115
+ const maxSc = items.length - visibleCount
116
+ const thumbStart = scrollable && maxSc > 0 ? Math.round((scroll / maxSc) * (visibleCount - thumbH)) : 0
117
+
118
+ const children = visible.map((item, vi) => {
119
+ const i = vi + scroll
120
+ const content = renderRow(item, { index: i, isCursor: i === cursor })
121
+ if (!scrollable) return jsx('box', { key: i, style: { flexDirection: 'row' }, children: content })
122
+ const barIsThumb = vi >= thumbStart && vi < thumbStart + thumbH
123
+ return jsx('box', {
124
+ key: i,
125
+ style: { flexDirection: 'row' },
126
+ children: [
127
+ content,
128
+ jsx('text', {
129
+ style: { color: barIsThumb ? s.accent : muted, dim: !barIsThumb },
130
+ children: ' ' + (barIsThumb ? '█' : '│'),
131
+ }),
132
+ ],
133
+ })
134
+ })
135
+
136
+ return jsx('box', {
137
+ style: {
138
+ flexDirection: 'column',
139
+ border: s.border,
140
+ borderColor: s.borderColor,
141
+ height: visibleCount + 2,
142
+ width,
143
+ bg: s.bg,
144
+ },
145
+ children,
146
+ })
147
+ }
package/src/focus.js CHANGED
@@ -27,39 +27,89 @@ export function useFocus({ initial, cycle = 'tab' } = {}) {
27
27
  current,
28
28
  setCurrent,
29
29
  stack: [],
30
- initialized: false,
30
+ gen: 0,
31
31
  }
32
32
  })
33
33
 
34
+ // items and groups re-register every frame like hooks. entries that were
35
+ // not re-registered during the previous frame are swept here, at the start
36
+ // of the next one, and input handling only ever considers current-gen
37
+ // entries so unmounted items drop out of the tab order immediately
38
+ sweep()
39
+
40
+ function sweep() {
41
+ const prevGen = state.gen
42
+ state.gen = prevGen + 1
43
+ if (prevGen === 0) return
44
+
45
+ const stale = state.items.filter(i => i.gen < prevGen)
46
+ if (stale.length > 0) {
47
+ state.items = state.items.filter(i => i.gen >= prevGen)
48
+ for (const it of stale) {
49
+ const g = state.groups.get(it.name)
50
+ if (g) {
51
+ for (const sub of g.items) state.nameToParent.delete(sub)
52
+ state.groups.delete(it.name)
53
+ }
54
+ }
55
+ }
56
+
57
+ if (state.items.length > 0 && !isRegistered(state.current())) {
58
+ state.setCurrent(resolveLeaf(state.items[0].name) ?? null)
59
+ }
60
+ }
61
+
62
+ function isRegistered(name) {
63
+ if (name == null) return false
64
+ if (state.nameToParent.has(name)) return true
65
+ return state.items.some(i => i.name === name)
66
+ }
67
+
34
68
  function resolveLeaf(name) {
35
69
  const g = state.groups.get(name)
36
70
  if (g) return g.items[g.subIdx]
37
71
  return name
38
72
  }
39
73
 
74
+ function liveItems() {
75
+ return state.items.filter(i => i.gen === state.gen)
76
+ }
77
+
40
78
  function item(name) {
41
- if (!state.items.find(i => i.name === name)) {
42
- state.items.push({ name, type: 'item' })
43
- }
44
- if (!state.initialized && !state.current()) {
45
- state.setCurrent(name)
46
- state.initialized = true
47
- }
79
+ const existing = state.items.find(i => i.name === name)
80
+ if (existing) existing.gen = state.gen
81
+ else state.items.push({ name, type: 'item', gen: state.gen })
82
+ if (state.current() == null) state.setCurrent(name)
48
83
  }
49
84
 
50
- function group(name, { items: subItems, navigate = 'both', wrap = false } = {}) {
51
- if (!state.items.find(i => i.name === name)) {
52
- state.items.push({ name, type: 'group' })
85
+ function group(name, { items: subItems = [], navigate = 'both', wrap = false } = {}) {
86
+ const existing = state.items.find(i => i.name === name)
87
+ if (existing) existing.gen = state.gen
88
+ else state.items.push({ name, type: 'group', gen: state.gen })
89
+
90
+ let g = state.groups.get(name)
91
+ if (!g) {
92
+ g = { items: [], navigate, wrap, subIdx: 0 }
93
+ state.groups.set(name, g)
53
94
  }
54
- if (!state.groups.has(name)) {
55
- state.groups.set(name, { items: subItems, navigate, wrap, subIdx: 0 })
56
- for (const sub of subItems) {
57
- state.nameToParent.set(sub, name)
58
- }
95
+
96
+ const prevFocused = g.items[g.subIdx]
97
+ for (const sub of g.items) {
98
+ if (!subItems.includes(sub)) state.nameToParent.delete(sub)
59
99
  }
60
- if (!state.initialized && !state.current()) {
100
+ g.items = subItems.slice()
101
+ g.navigate = navigate
102
+ g.wrap = wrap
103
+ for (const sub of subItems) state.nameToParent.set(sub, name)
104
+
105
+ const keep = subItems.indexOf(prevFocused)
106
+ g.subIdx = keep >= 0 ? keep : Math.min(g.subIdx, Math.max(0, subItems.length - 1))
107
+
108
+ const cur = state.current()
109
+ if (cur == null && subItems.length > 0) {
61
110
  state.setCurrent(subItems[0])
62
- state.initialized = true
111
+ } else if (cur === prevFocused && keep === -1 && subItems.length > 0) {
112
+ state.setCurrent(subItems[g.subIdx])
63
113
  }
64
114
  }
65
115
 
@@ -95,14 +145,15 @@ export function useFocus({ initial, cycle = 'tab' } = {}) {
95
145
  return state.current()
96
146
  }
97
147
 
98
- function findTopLevel(cur) {
148
+ function findTopLevel(cur, items) {
99
149
  const parent = state.nameToParent.get(cur)
100
- if (parent) return state.items.findIndex(i => i.name === parent)
101
- return state.items.findIndex(i => i.name === cur)
150
+ if (parent) return items.findIndex(i => i.name === parent)
151
+ return items.findIndex(i => i.name === cur)
102
152
  }
103
153
 
104
154
  useInput((event) => {
105
- if (state.items.length === 0) return
155
+ const items = liveItems()
156
+ if (items.length === 0) return
106
157
 
107
158
  const { key } = event
108
159
  const cur = state.current()
@@ -110,12 +161,20 @@ export function useFocus({ initial, cycle = 'tab' } = {}) {
110
161
  if (state.stack.length > 0) return
111
162
 
112
163
  if (cycle === 'tab' && (key === 'tab' || key === 'shift-tab')) {
113
- const idx = findTopLevel(cur)
114
- if (idx === -1) return
115
164
  const dir = key === 'tab' ? 1 : -1
116
- const next = (idx + dir + state.items.length) % state.items.length
117
- const nextItem = state.items[next]
118
- state.setCurrent(resolveLeaf(nextItem.name))
165
+ const len = items.length
166
+ const idx = findTopLevel(cur, items)
167
+ const start = idx === -1 ? (dir === 1 ? -1 : 0) : idx
168
+ for (let step = 1; step <= len; step++) {
169
+ const next = ((start + dir * step) % len + len) % len
170
+ const leaf = resolveLeaf(items[next].name)
171
+ if (leaf === undefined) continue
172
+ if (leaf !== cur) {
173
+ state.setCurrent(leaf)
174
+ event.stopPropagation()
175
+ }
176
+ return
177
+ }
119
178
  return
120
179
  }
121
180
 
@@ -136,13 +195,16 @@ export function useFocus({ initial, cycle = 'tab' } = {}) {
136
195
 
137
196
  if (g.wrap) {
138
197
  const next = (idx + dir + len) % len
198
+ if (next === idx) return
139
199
  g.subIdx = next
140
200
  state.setCurrent(g.items[next])
201
+ event.stopPropagation()
141
202
  } else {
142
203
  const next = idx + dir
143
204
  if (next < 0 || next >= len) return
144
205
  g.subIdx = next
145
206
  state.setCurrent(g.items[next])
207
+ event.stopPropagation()
146
208
  }
147
209
  })
148
210
 
package/src/hooks.js CHANGED
@@ -1,17 +1,13 @@
1
- import { onCleanup, createSignal as rawCreateSignal, createSignalRaw } from './signal.js'
2
- import { getContext, getTheme, getCursor, registerHook, getInstanceLayout, getFrameStats } from './renderer.js'
1
+ import { onCleanup, createSignalRaw } from './signal.js'
2
+ import { getContext, getTheme, getCursor, registerHook, getInstanceLayout, getFrameStats, getCurrentHookOwner } from './renderer.js'
3
3
  import { setTitle } from './ansi.js'
4
4
 
5
- export function useState(initial) {
6
- return registerHook(() => rawCreateSignal(initial))
7
- }
8
-
9
5
  export function useInput(handler) {
10
6
  const ref = registerHook(() => {
11
7
  const ctx = getContext()
12
8
  if (!ctx) throw new Error('useInput must be called within a mounted component')
13
9
  const state = { current: handler }
14
- const unsub = ctx.input.onKey((event) => state.current(event))
10
+ const unsub = ctx.input.onKey((event) => state.current(event), getCurrentHookOwner())
15
11
  onCleanup(unsub)
16
12
  return state
17
13
  })
@@ -23,7 +19,7 @@ export function useMouse(handler) {
23
19
  const ctx = getContext()
24
20
  if (!ctx) throw new Error('useMouse must be called within a mounted component')
25
21
  const state = { current: handler }
26
- const unsub = ctx.input.onMouse((event) => state.current(event))
22
+ const unsub = ctx.input.onMouse((event) => state.current(event), getCurrentHookOwner())
27
23
  onCleanup(unsub)
28
24
  return state
29
25
  })
@@ -45,13 +41,19 @@ export function useResize(handler) {
45
41
  }
46
42
 
47
43
  export function useInterval(fn, ms) {
48
- const ref = registerHook(() => {
49
- const state = { current: fn }
50
- const id = setInterval(() => state.current(), ms)
51
- onCleanup(() => clearInterval(id))
52
- return state
44
+ const state = registerHook(() => {
45
+ const s = { current: fn, ms: undefined, id: null }
46
+ onCleanup(() => {
47
+ if (s.id !== null) clearInterval(s.id)
48
+ })
49
+ return s
53
50
  })
54
- ref.current = fn
51
+ state.current = fn
52
+ if (state.ms !== ms) {
53
+ if (state.id !== null) clearInterval(state.id)
54
+ state.ms = ms
55
+ state.id = ms == null ? null : setInterval(() => state.current(), ms)
56
+ }
55
57
  }
56
58
 
57
59
  export function useLayout() {
@@ -81,17 +83,30 @@ export function useFrameStats() {
81
83
  export function useTitle(title) {
82
84
  const ctx = getContext()
83
85
  if (!ctx) throw new Error('useTitle must be called within a mounted component')
84
- ctx.stream.write(setTitle(title))
86
+ const state = registerHook(() => ({ last: undefined }))
87
+ if (state.last !== title) {
88
+ state.last = title
89
+ ctx.stream.write(setTitle(title))
90
+ }
85
91
  }
86
92
 
87
93
  export function useTimeout(fn, ms) {
88
- const ref = registerHook(() => {
89
- const state = { current: fn }
90
- const id = setTimeout(() => state.current(), ms)
91
- onCleanup(() => clearTimeout(id))
92
- return state
94
+ const state = registerHook(() => {
95
+ const s = { current: fn, ms: undefined, id: null }
96
+ onCleanup(() => {
97
+ if (s.id !== null) clearTimeout(s.id)
98
+ })
99
+ return s
93
100
  })
94
- ref.current = fn
101
+ state.current = fn
102
+ if (state.ms !== ms) {
103
+ if (state.id !== null) clearTimeout(state.id)
104
+ state.ms = ms
105
+ state.id = ms == null ? null : setTimeout(() => {
106
+ state.id = null
107
+ state.current()
108
+ }, ms)
109
+ }
95
110
  }
96
111
 
97
112
  export function useScrollDrag({ barX, barY, thumbHeight, trackHeight, maxOffset, scrollOffset, onScroll }) {
@@ -183,21 +198,24 @@ export function useAsync(fn, { immediate = false } = {}) {
183
198
  const [error, setError] = createSignalRaw(null)
184
199
  let generation = 0
185
200
 
186
- function run(...args) {
201
+ const s = { status, data, error, fn }
202
+
203
+ s.run = (...args) => {
187
204
  const gen = ++generation
188
205
  setStatus('loading')
189
206
  setData(null)
190
207
  setError(null)
191
- fn(...args).then(
208
+ s.fn(...args).then(
192
209
  result => { if (gen === generation) { setData(result); setStatus('success') } },
193
210
  err => { if (gen === generation) { setError(err); setStatus('error') } },
194
211
  )
195
212
  }
196
213
 
197
- if (immediate) run()
214
+ if (immediate) s.run()
198
215
 
199
- return { status, data, error, run }
216
+ return s
200
217
  })
201
218
 
219
+ state.fn = fn
202
220
  return state
203
221
  }