@trendr/core 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/src/ansi.js ADDED
@@ -0,0 +1,149 @@
1
+ const ESC = '\x1b['
2
+
3
+ export const moveTo = (row, col) => `${ESC}${row};${col}H`
4
+ export const moveUp = (n = 1) => `${ESC}${n}A`
5
+ export const moveDown = (n = 1) => `${ESC}${n}B`
6
+ export const moveRight = (n = 1) => `${ESC}${n}C`
7
+ export const moveLeft = (n = 1) => `${ESC}${n}D`
8
+
9
+ export const hideCursor = `${ESC}?25l`
10
+ export const showCursor = `${ESC}?25h`
11
+ export const clearScreen = `${ESC}2J`
12
+ export const clearLine = `${ESC}2K`
13
+ export const altScreen = `${ESC}?1049h`
14
+ export const exitAltScreen = `${ESC}?1049l`
15
+ export const sgrReset = `${ESC}0m`
16
+
17
+ export const setTitle = (title) => `\x1b]2;${title}\x07`
18
+
19
+ export const BOLD = 1
20
+ export const DIM = 2
21
+ export const ITALIC = 4
22
+ export const UNDERLINE = 8
23
+ export const INVERSE = 16
24
+ export const STRIKETHROUGH = 32
25
+
26
+ const ATTR_CODES = [
27
+ [BOLD, '1'],
28
+ [DIM, '2'],
29
+ [ITALIC, '3'],
30
+ [UNDERLINE, '4'],
31
+ [INVERSE, '7'],
32
+ [STRIKETHROUGH, '9'],
33
+ ]
34
+
35
+ const NAMED_COLORS = {
36
+ black: 0, red: 1, green: 2, yellow: 3,
37
+ blue: 4, magenta: 5, cyan: 6, white: 7,
38
+ gray: 8, grey: 8,
39
+ brightRed: 9, brightGreen: 10, brightYellow: 11,
40
+ brightBlue: 12, brightMagenta: 13, brightCyan: 14, brightWhite: 15,
41
+ }
42
+
43
+ function parseColor(color, offset) {
44
+ if (color == null) return null
45
+ if (typeof color === 'number') return `${offset};5;${color}`
46
+ if (color in NAMED_COLORS) return `${offset};5;${NAMED_COLORS[color]}`
47
+ if (color.startsWith('#') && color.length === 7) {
48
+ const r = parseInt(color.slice(1, 3), 16)
49
+ const g = parseInt(color.slice(3, 5), 16)
50
+ const b = parseInt(color.slice(5, 7), 16)
51
+ return `${offset};2;${r};${g};${b}`
52
+ }
53
+ return null
54
+ }
55
+
56
+ // reverse lookup: 256-color index -> named color string
57
+ // only the 16 standard colors get names, everything else stays numeric
58
+ const INDEX_TO_NAME = Object.entries(NAMED_COLORS)
59
+ .filter(([k]) => k !== 'grey')
60
+ .reduce((map, [name, idx]) => { map[idx] = name; return map }, {})
61
+
62
+ // maps basic ansi fg codes (30-37, 90-97) to 256-color index
63
+ function basicFgToIndex(code) {
64
+ if (code >= 30 && code <= 37) return code - 30
65
+ if (code >= 90 && code <= 97) return code - 90 + 8
66
+ return null
67
+ }
68
+
69
+ function basicBgToIndex(code) {
70
+ if (code >= 40 && code <= 47) return code - 40
71
+ if (code >= 100 && code <= 107) return code - 100 + 8
72
+ return null
73
+ }
74
+
75
+ function indexToColor(idx) {
76
+ return INDEX_TO_NAME[idx] ?? idx
77
+ }
78
+
79
+ function rgbToHex(r, g, b) {
80
+ return '#' + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)
81
+ }
82
+
83
+ export function parseSgr(params, state) {
84
+ if (!state) state = { fg: null, bg: null, attrs: 0 }
85
+ const codes = params.split(';').map(Number)
86
+ let i = 0
87
+
88
+ while (i < codes.length) {
89
+ const c = codes[i]
90
+
91
+ if (c === 0) {
92
+ state.fg = null
93
+ state.bg = null
94
+ state.attrs = 0
95
+ } else if (c === 1) state.attrs |= BOLD
96
+ else if (c === 2) state.attrs |= DIM
97
+ else if (c === 3) state.attrs |= ITALIC
98
+ else if (c === 4) state.attrs |= UNDERLINE
99
+ else if (c === 7) state.attrs |= INVERSE
100
+ else if (c === 9) state.attrs |= STRIKETHROUGH
101
+ else if (c === 22) state.attrs &= ~(BOLD | DIM)
102
+ else if (c === 23) state.attrs &= ~ITALIC
103
+ else if (c === 24) state.attrs &= ~UNDERLINE
104
+ else if (c === 27) state.attrs &= ~INVERSE
105
+ else if (c === 29) state.attrs &= ~STRIKETHROUGH
106
+ else if (c === 39) state.fg = null
107
+ else if (c === 49) state.bg = null
108
+ else if (c === 38 && codes[i + 1] === 5) {
109
+ state.fg = indexToColor(codes[i + 2])
110
+ i += 2
111
+ } else if (c === 48 && codes[i + 1] === 5) {
112
+ state.bg = indexToColor(codes[i + 2])
113
+ i += 2
114
+ } else if (c === 38 && codes[i + 1] === 2) {
115
+ state.fg = rgbToHex(codes[i + 2], codes[i + 3], codes[i + 4])
116
+ i += 4
117
+ } else if (c === 48 && codes[i + 1] === 2) {
118
+ state.bg = rgbToHex(codes[i + 2], codes[i + 3], codes[i + 4])
119
+ i += 4
120
+ } else {
121
+ const fgIdx = basicFgToIndex(c)
122
+ if (fgIdx != null) state.fg = indexToColor(fgIdx)
123
+ else {
124
+ const bgIdx = basicBgToIndex(c)
125
+ if (bgIdx != null) state.bg = indexToColor(bgIdx)
126
+ }
127
+ }
128
+
129
+ i++
130
+ }
131
+
132
+ return state
133
+ }
134
+
135
+ export function sgr(fg, bg, attrs) {
136
+ const parts = ['0']
137
+
138
+ for (const [mask, code] of ATTR_CODES) {
139
+ if (attrs & mask) parts.push(code)
140
+ }
141
+
142
+ const fgCode = parseColor(fg, 38)
143
+ if (fgCode) parts.push(fgCode)
144
+
145
+ const bgCode = parseColor(bg, 48)
146
+ if (bgCode) parts.push(bgCode)
147
+
148
+ return `${ESC}${parts.join(';')}m`
149
+ }
package/src/buffer.js ADDED
@@ -0,0 +1,101 @@
1
+ import { parseSgr } from './ansi.js'
2
+
3
+ const EMPTY = { ch: ' ', fg: null, bg: null, attrs: 0 }
4
+
5
+ export function createBuffer(width, height) {
6
+ const size = width * height
7
+ const cells = new Array(size)
8
+ for (let i = 0; i < size; i++) cells[i] = EMPTY
9
+ return { width, height, cells }
10
+ }
11
+
12
+ export function clearBuffer(buf) {
13
+ const len = buf.cells.length
14
+ for (let i = 0; i < len; i++) buf.cells[i] = EMPTY
15
+ }
16
+
17
+ export function resizeBuffer(buf, width, height) {
18
+ buf.width = width
19
+ buf.height = height
20
+ buf.cells = new Array(width * height)
21
+ for (let i = 0; i < buf.cells.length; i++) buf.cells[i] = EMPTY
22
+ }
23
+
24
+ export function setCell(buf, x, y, ch, fg, bg, attrs) {
25
+ if (x < 0 || y < 0 || x >= buf.width || y >= buf.height) return
26
+ buf.cells[y * buf.width + x] = { ch, fg: fg ?? null, bg: bg ?? null, attrs: attrs ?? 0 }
27
+ }
28
+
29
+ export function writeText(buf, x, y, text, fg, bg, attrs, maxWidth) {
30
+ if (y < 0 || y >= buf.height) return
31
+ const max = maxWidth ?? (buf.width - x)
32
+
33
+ if (text.indexOf('\x1b') === -1) {
34
+ const n = Math.min(max, text.length, buf.width - x)
35
+ for (let i = 0; i < n; i++) {
36
+ const cx = x + i
37
+ if (cx < 0 || cx >= buf.width) continue
38
+ const prev = buf.cells[y * buf.width + cx]
39
+ buf.cells[y * buf.width + cx] = {
40
+ ch: text[i],
41
+ fg: fg ?? prev.fg,
42
+ bg: bg ?? prev.bg,
43
+ attrs: attrs || prev.attrs,
44
+ }
45
+ }
46
+ return
47
+ }
48
+
49
+ let col = 0
50
+ const ansi = { fg: null, bg: null, attrs: 0 }
51
+ let i = 0
52
+
53
+ while (i < text.length && col < max) {
54
+ if (text[i] === '\x1b' && text[i + 1] === '[') {
55
+ const end = text.indexOf('m', i + 2)
56
+ if (end !== -1) {
57
+ parseSgr(text.slice(i + 2, end), ansi)
58
+ i = end + 1
59
+ continue
60
+ }
61
+ }
62
+
63
+ const cx = x + col
64
+ if (cx >= 0 && cx < buf.width) {
65
+ const prev = buf.cells[y * buf.width + cx]
66
+ buf.cells[y * buf.width + cx] = {
67
+ ch: text[i],
68
+ fg: ansi.fg ?? fg ?? prev.fg,
69
+ bg: ansi.bg ?? bg ?? prev.bg,
70
+ attrs: ansi.attrs || attrs || prev.attrs,
71
+ }
72
+ }
73
+ col++
74
+ i++
75
+ }
76
+ }
77
+
78
+ export function fillRect(buf, x, y, w, h, ch, fg, bg, attrs) {
79
+ const x2 = Math.min(x + w, buf.width)
80
+ const y2 = Math.min(y + h, buf.height)
81
+ const x1 = Math.max(x, 0)
82
+ const y1 = Math.max(y, 0)
83
+ for (let row = y1; row < y2; row++) {
84
+ for (let col = x1; col < x2; col++) {
85
+ buf.cells[row * buf.width + col] = { ch: ch ?? ' ', fg: fg ?? null, bg: bg ?? null, attrs: attrs ?? 0 }
86
+ }
87
+ }
88
+ }
89
+
90
+ export function dimBuffer(buf) {
91
+ for (let i = 0; i < buf.cells.length; i++) {
92
+ const cell = buf.cells[i]
93
+ if (cell.attrs & 2) continue
94
+ buf.cells[i] = { ch: cell.ch, fg: cell.fg, bg: cell.bg, attrs: cell.attrs | 2 }
95
+ }
96
+ }
97
+
98
+ export function copyBuffer(src, dst) {
99
+ const len = Math.min(src.cells.length, dst.cells.length)
100
+ for (let i = 0; i < len; i++) dst.cells[i] = src.cells[i]
101
+ }
package/src/button.js ADDED
@@ -0,0 +1,26 @@
1
+ import { jsx } from '../jsx-runtime.js'
2
+ import { useInput, useTheme } from './hooks.js'
3
+
4
+ export function Button({ label, onPress, focused = false, variant }) {
5
+ const { accent = 'cyan' } = useTheme()
6
+
7
+ useInput((event) => {
8
+ if (!focused) return
9
+ if (event.key === 'return' || event.key === 'space') {
10
+ onPress?.()
11
+ event.stopPropagation()
12
+ }
13
+ })
14
+
15
+ const dim = variant === 'dim'
16
+
17
+ return jsx('text', {
18
+ style: {
19
+ bg: focused ? accent : null,
20
+ color: focused ? 'black' : (dim ? 'gray' : null),
21
+ bold: focused,
22
+ dim: !focused && dim,
23
+ },
24
+ children: focused ? ` ${label} ` : `[${label}]`,
25
+ })
26
+ }
@@ -0,0 +1,28 @@
1
+ import { jsx, jsxs } from '../jsx-runtime.js'
2
+ import { useInput, useTheme } from './hooks.js'
3
+
4
+ export function Checkbox({ checked = false, label, onChange, focused = false }) {
5
+ const { accent = 'cyan' } = useTheme()
6
+
7
+ useInput((event) => {
8
+ if (!focused) return
9
+ if (event.key === 'space' || event.key === 'return') {
10
+ onChange?.(!checked)
11
+ event.stopPropagation()
12
+ }
13
+ })
14
+
15
+ const icon = checked ? '[x]' : '[ ]'
16
+ const bg = focused ? accent : null
17
+ const color = focused ? 'black' : null
18
+
19
+ const children = [
20
+ jsx('text', { style: { color, bold: focused }, children: icon }),
21
+ ]
22
+
23
+ if (label != null) {
24
+ children.push(jsx('text', { style: { color }, children: ` ${label}` }))
25
+ }
26
+
27
+ return jsxs('box', { style: { flexDirection: 'row', bg }, children })
28
+ }
@@ -0,0 +1,13 @@
1
+ import { jsx } from '../jsx-runtime.js'
2
+
3
+ export function Box(props) {
4
+ return jsx('box', props)
5
+ }
6
+
7
+ export function Text(props) {
8
+ return jsx('text', props)
9
+ }
10
+
11
+ export function Spacer() {
12
+ return jsx('box', { style: { flexGrow: 1 } })
13
+ }
package/src/diff.js ADDED
@@ -0,0 +1,51 @@
1
+ import { moveTo, sgr, sgrReset } from './ansi.js'
2
+
3
+ function cellEq(a, b) {
4
+ return a === b || (a.ch === b.ch && a.fg === b.fg && a.bg === b.bg && a.attrs === b.attrs)
5
+ }
6
+
7
+ export function diff(prev, curr) {
8
+ const w = curr.width
9
+ const h = curr.height
10
+ const parts = []
11
+
12
+ let lastFg = undefined
13
+ let lastBg = undefined
14
+ let lastAttrs = undefined
15
+
16
+ for (let y = 0; y < h; y++) {
17
+ let x = 0
18
+ while (x < w) {
19
+ const idx = y * w + x
20
+ if (cellEq(prev.cells[idx], curr.cells[idx])) {
21
+ x++
22
+ continue
23
+ }
24
+
25
+ parts.push(moveTo(y + 1, x + 1))
26
+
27
+ while (x < w) {
28
+ const i = y * w + x
29
+ if (cellEq(prev.cells[i], curr.cells[i])) break
30
+
31
+ const c = curr.cells[i]
32
+
33
+ if (c.fg !== lastFg || c.bg !== lastBg || c.attrs !== lastAttrs) {
34
+ parts.push(sgr(c.fg, c.bg, c.attrs))
35
+ lastFg = c.fg
36
+ lastBg = c.bg
37
+ lastAttrs = c.attrs
38
+ }
39
+
40
+ parts.push(c.ch)
41
+ x++
42
+ }
43
+ }
44
+ }
45
+
46
+ if (parts.length > 0) {
47
+ parts.push(sgrReset)
48
+ }
49
+
50
+ return parts.join('')
51
+ }
package/src/element.js ADDED
@@ -0,0 +1,81 @@
1
+ import { createScope, disposeScope, runInScope } from './signal.js'
2
+
3
+ export const Fragment = Symbol('Fragment')
4
+
5
+ export function flattenChildren(children) {
6
+ if (children == null || children === true || children === false) return []
7
+ if (!Array.isArray(children)) return [children]
8
+ const result = []
9
+ for (const child of children) {
10
+ if (child == null || child === true || child === false) continue
11
+ if (Array.isArray(child)) result.push(...flattenChildren(child))
12
+ else result.push(child)
13
+ }
14
+ return result
15
+ }
16
+
17
+ export function resolveTree(element, parent) {
18
+ if (element == null || typeof element === 'boolean') return null
19
+
20
+ if (typeof element === 'string' || typeof element === 'number') {
21
+ return {
22
+ type: 'text',
23
+ props: { children: String(element) },
24
+ key: null,
25
+ _parent: parent,
26
+ _layout: null,
27
+ _resolved: null,
28
+ _resolvedChildren: null,
29
+ _scope: null,
30
+ }
31
+ }
32
+
33
+ const node = {
34
+ type: element.type,
35
+ props: element.props ?? {},
36
+ key: element.key,
37
+ _parent: parent,
38
+ _layout: null,
39
+ _resolved: null,
40
+ _resolvedChildren: null,
41
+ _scope: null,
42
+ }
43
+
44
+ if (typeof element.type === 'function') {
45
+ let result
46
+ const scope = createScope(() => {
47
+ result = element.type(element.props ?? {})
48
+ })
49
+ node._scope = scope
50
+ node._resolved = resolveTree(result, node)
51
+ return node
52
+ }
53
+
54
+ if (element.type === Fragment) {
55
+ const children = flattenChildren(element.props?.children)
56
+ node._resolvedChildren = children.map(c => resolveTree(c, node)).filter(Boolean)
57
+ return node
58
+ }
59
+
60
+ const children = flattenChildren(element.props?.children)
61
+ if (children.length > 0) {
62
+ node._resolvedChildren = children.map(c => resolveTree(c, node)).filter(Boolean)
63
+ }
64
+
65
+ return node
66
+ }
67
+
68
+ export function getLeafNode(node) {
69
+ if (!node) return null
70
+ if (node._resolved) return getLeafNode(node._resolved)
71
+ return node
72
+ }
73
+
74
+ export function walkTree(node, fn) {
75
+ if (!node) return
76
+ fn(node)
77
+ if (node._resolved) walkTree(node._resolved, fn)
78
+ if (node._resolvedChildren) {
79
+ for (const child of node._resolvedChildren) walkTree(child, fn)
80
+ }
81
+ }
package/src/focus.js ADDED
@@ -0,0 +1,135 @@
1
+ import { createSignalRaw } from './signal.js'
2
+ import { registerHook } from './renderer.js'
3
+ import { useInput } from './hooks.js'
4
+
5
+ export function useFocus({ initial, cycle = 'tab' } = {}) {
6
+ const state = registerHook(() => {
7
+ const [current, setCurrent] = createSignalRaw(initial ?? null)
8
+ return {
9
+ items: [],
10
+ groups: new Map(),
11
+ nameToParent: new Map(),
12
+ current,
13
+ setCurrent,
14
+ stack: [],
15
+ initialized: false,
16
+ }
17
+ })
18
+
19
+ function resolveLeaf(name) {
20
+ const g = state.groups.get(name)
21
+ if (g) return g.items[g.subIdx]
22
+ return name
23
+ }
24
+
25
+ function item(name) {
26
+ if (!state.items.find(i => i.name === name)) {
27
+ state.items.push({ name, type: 'item' })
28
+ }
29
+ if (!state.initialized && !state.current()) {
30
+ state.setCurrent(name)
31
+ state.initialized = true
32
+ }
33
+ }
34
+
35
+ function group(name, { items: subItems, navigate = 'both', wrap = false } = {}) {
36
+ if (!state.items.find(i => i.name === name)) {
37
+ state.items.push({ name, type: 'group' })
38
+ }
39
+ if (!state.groups.has(name)) {
40
+ state.groups.set(name, { items: subItems, navigate, wrap, subIdx: 0 })
41
+ for (const sub of subItems) {
42
+ state.nameToParent.set(sub, name)
43
+ }
44
+ }
45
+ if (!state.initialized && !state.current()) {
46
+ state.setCurrent(subItems[0])
47
+ state.initialized = true
48
+ }
49
+ }
50
+
51
+ function is(name) {
52
+ const cur = state.current()
53
+ if (cur === name) return true
54
+ const g = state.groups.get(name)
55
+ if (g && g.items.includes(cur)) return true
56
+ return false
57
+ }
58
+
59
+ function focus(name) {
60
+ const parentGroup = state.nameToParent.get(name)
61
+ if (parentGroup) {
62
+ const g = state.groups.get(parentGroup)
63
+ const idx = g.items.indexOf(name)
64
+ if (idx >= 0) g.subIdx = idx
65
+ }
66
+ state.setCurrent(name)
67
+ }
68
+
69
+ function push(name) {
70
+ state.stack.push(state.current())
71
+ focus(name)
72
+ }
73
+
74
+ function pop() {
75
+ if (state.stack.length === 0) return
76
+ focus(state.stack.pop())
77
+ }
78
+
79
+ function current() {
80
+ return state.current()
81
+ }
82
+
83
+ function findTopLevel(cur) {
84
+ const parent = state.nameToParent.get(cur)
85
+ if (parent) return state.items.findIndex(i => i.name === parent)
86
+ return state.items.findIndex(i => i.name === cur)
87
+ }
88
+
89
+ useInput((event) => {
90
+ if (state.items.length === 0) return
91
+
92
+ const { key } = event
93
+ const cur = state.current()
94
+
95
+ if (state.stack.length > 0) return
96
+
97
+ if (cycle === 'tab' && (key === 'tab' || key === 'shift-tab')) {
98
+ const idx = findTopLevel(cur)
99
+ if (idx === -1) return
100
+ const dir = key === 'tab' ? 1 : -1
101
+ const next = (idx + dir + state.items.length) % state.items.length
102
+ const nextItem = state.items[next]
103
+ state.setCurrent(resolveLeaf(nextItem.name))
104
+ return
105
+ }
106
+
107
+ const parentName = state.nameToParent.get(cur)
108
+ if (!parentName) return
109
+
110
+ const g = state.groups.get(parentName)
111
+ const isNav =
112
+ (g.navigate === 'jk' && (key === 'j' || key === 'k')) ||
113
+ (g.navigate === 'updown' && (key === 'up' || key === 'down')) ||
114
+ (g.navigate === 'both' && (key === 'j' || key === 'k' || key === 'up' || key === 'down'))
115
+
116
+ if (!isNav) return
117
+
118
+ const dir = (key === 'j' || key === 'down') ? 1 : -1
119
+ const idx = g.subIdx
120
+ const len = g.items.length
121
+
122
+ if (g.wrap) {
123
+ const next = (idx + dir + len) % len
124
+ g.subIdx = next
125
+ state.setCurrent(g.items[next])
126
+ } else {
127
+ const next = idx + dir
128
+ if (next < 0 || next >= len) return
129
+ g.subIdx = next
130
+ state.setCurrent(g.items[next])
131
+ }
132
+ })
133
+
134
+ return { item, group, is, focus, push, pop, current }
135
+ }
package/src/hooks.js ADDED
@@ -0,0 +1,63 @@
1
+ import { onCleanup, createSignal as rawCreateSignal } from './signal.js'
2
+ import { getContext, getTheme, registerHook, getInstanceLayout } from './renderer.js'
3
+ import { setTitle } from './ansi.js'
4
+
5
+ export function useState(initial) {
6
+ return registerHook(() => rawCreateSignal(initial))
7
+ }
8
+
9
+ export function useInput(handler) {
10
+ const ref = registerHook(() => {
11
+ const ctx = getContext()
12
+ if (!ctx) throw new Error('useInput must be called within a mounted component')
13
+ const state = { current: handler }
14
+ const unsub = ctx.input.onKey((event) => state.current(event))
15
+ onCleanup(unsub)
16
+ return state
17
+ })
18
+ ref.current = handler
19
+ }
20
+
21
+ export function useResize(handler) {
22
+ const ref = registerHook(() => {
23
+ const ctx = getContext()
24
+ if (!ctx) throw new Error('useResize must be called within a mounted component')
25
+ const stream = ctx.stream
26
+ const state = { current: handler }
27
+ const onResize = () => state.current({ width: stream.columns, height: stream.rows })
28
+ stream.on('resize', onResize)
29
+ onCleanup(() => stream.off('resize', onResize))
30
+ return state
31
+ })
32
+ ref.current = handler
33
+ }
34
+
35
+ export function useInterval(fn, ms) {
36
+ const ref = registerHook(() => {
37
+ const state = { current: fn }
38
+ const id = setInterval(() => state.current(), ms)
39
+ onCleanup(() => clearInterval(id))
40
+ return state
41
+ })
42
+ ref.current = fn
43
+ }
44
+
45
+ export function useLayout() {
46
+ return getInstanceLayout()
47
+ }
48
+
49
+ export function useTheme() {
50
+ return getTheme()
51
+ }
52
+
53
+ export function useStdout() {
54
+ const ctx = getContext()
55
+ if (!ctx) throw new Error('useStdout must be called within a mounted component')
56
+ return ctx.stream
57
+ }
58
+
59
+ export function useTitle(title) {
60
+ const ctx = getContext()
61
+ if (!ctx) throw new Error('useTitle must be called within a mounted component')
62
+ ctx.stream.write(setTitle(title))
63
+ }
package/src/hotkey.js ADDED
@@ -0,0 +1,35 @@
1
+ import { useInput } from './hooks.js'
2
+ import { registerHook } from './renderer.js'
3
+
4
+ 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
11
+ }
12
+ return { key, ...mods }
13
+ }
14
+
15
+ const RAW_KEYS = { return: '\r', enter: '\r' }
16
+
17
+ export function useHotkey(descriptor, handler, { when } = {}) {
18
+ const parsed = registerHook(() => parseDescriptor(descriptor))
19
+ const ref = registerHook(() => ({ handler, when }))
20
+ ref.handler = handler
21
+ ref.when = when
22
+
23
+ useInput((event) => {
24
+ 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
+
32
+ ref.handler()
33
+ event.stopPropagation()
34
+ })
35
+ }