@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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Jonathan Pyers
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,526 @@
1
+ <p align="center">
2
+ <img src=".github/trend.png" alt="trend" width="400" />
3
+ </p>
4
+
5
+ <p align="center">4-16x faster than ink and neo-blessed - <a href="bench/README.md">benchmarks</a></p>
6
+
7
+ Direct-mode TUI renderer with JSX, signals, and per-cell diffing.
8
+
9
+ ESM only. Requires esbuild (or similar) for JSX transformation.
10
+
11
+ ```json
12
+ { "jsx": "automatic", "jsxImportSource": "trend" }
13
+ ```
14
+
15
+ https://github.com/user-attachments/assets/d307ba1e-2b21-4f7d-8b1b-56252820db6c
16
+
17
+ https://github.com/user-attachments/assets/a5984747-f365-4fe3-a161-93101682ca42
18
+
19
+ https://github.com/user-attachments/assets/9c357c93-0299-4480-a969-61a54b49ec33
20
+
21
+ ## Usage
22
+
23
+ ```jsx
24
+ import { mount, createSignal, useInput } from 'trend'
25
+
26
+ function App() {
27
+ const [count, setCount] = createSignal(0)
28
+
29
+ useInput(({ key }) => {
30
+ if (key === 'up') setCount(c => c + 1)
31
+ if (key === 'down') setCount(c => c - 1)
32
+ })
33
+
34
+ return (
35
+ <box style={{ flexDirection: 'column', padding: 1 }}>
36
+ <text style={{ color: 'cyan', bold: true }}>Count: {count()}</text>
37
+ <text style={{ color: 'gray' }}>up/down to change</text>
38
+ </box>
39
+ )
40
+ }
41
+
42
+ mount(App)
43
+ ```
44
+
45
+ `mount(Component, { stream?, stdin?, theme? })` enters alt screen, starts a 60fps render loop, and returns `{ unmount }`. Components are plain functions that return JSX. State is managed with signals - call the getter to read, the setter to write. The framework re-renders automatically when signals change.
46
+
47
+ ### Theming
48
+
49
+ All built-in components use `accent` as the focus/highlight color (default: `'cyan'`). Override it globally via mount:
50
+
51
+ ```jsx
52
+ mount(App, { theme: { accent: 'green' } })
53
+ ```
54
+
55
+ Components read the accent with `useTheme()`. Use this in your own components to stay consistent:
56
+
57
+ ```jsx
58
+ import { useTheme } from 'trend'
59
+
60
+ const { accent } = useTheme()
61
+ ```
62
+
63
+ Individual components still accept explicit color props (e.g. `<Spinner color="magenta" />`) which override the theme.
64
+
65
+ ## Signals
66
+
67
+ ```js
68
+ import { createSignal, createEffect, createMemo, batch, untrack, onCleanup } from 'trend'
69
+
70
+ const [value, setValue] = createSignal(0)
71
+ value() // read (tracks dependency)
72
+ setValue(1) // write
73
+ setValue(v => v + 1) // updater
74
+
75
+ createEffect(() => {
76
+ console.log(value()) // re-runs when value changes
77
+ return () => {} // optional cleanup
78
+ })
79
+
80
+ const doubled = createMemo(() => value() * 2) // cached derived value
81
+
82
+ batch(() => { // coalesce multiple updates into one render
83
+ setValue(1)
84
+ setValue(2)
85
+ })
86
+
87
+ untrack(() => value()) // read without tracking
88
+ onCleanup(() => {}) // runs when component unmounts or effect re-runs
89
+ ```
90
+
91
+ ## Layout
92
+
93
+ Two element types: `box` (container) and `text` (leaf).
94
+
95
+ ```jsx
96
+ <box style={{
97
+ flexDirection: 'column', // 'column' (default) | 'row'
98
+ flexGrow: 1, // fill remaining space
99
+ gap: 1, // space between children
100
+ justifyContent: 'flex-start', // 'flex-start' | 'center' | 'flex-end'
101
+ alignItems: 'stretch', // 'stretch' | 'flex-start' | 'center' | 'flex-end'
102
+ width: 20, // fixed or '50%'
103
+ height: 10, // fixed or '25%'
104
+ minWidth: 5, maxWidth: 30,
105
+ minHeight: 2, maxHeight: 15,
106
+ padding: 1, // all sides
107
+ paddingX: 1, paddingY: 1, // axis
108
+ paddingTop: 1, paddingBottom: 1, paddingLeft: 1, paddingRight: 1,
109
+ margin: 1, // same variants as padding
110
+ border: 'round', // 'single' | 'double' | 'round' | 'bold'
111
+ borderColor: 'cyan',
112
+ bg: 'blue', // background color
113
+ }}>
114
+
115
+ <text style={{
116
+ color: 'cyan', // named, hex (#ff0000), or 256-color index
117
+ bg: 'black',
118
+ bold: true,
119
+ dim: true,
120
+ italic: true,
121
+ underline: true,
122
+ inverse: true,
123
+ strikethrough: true,
124
+ overflow: 'wrap', // 'wrap' (default) | 'truncate' | 'nowrap'
125
+ }}>
126
+ ```
127
+
128
+ `Box`, `Text`, and `Spacer` are convenience wrappers:
129
+
130
+ ```jsx
131
+ import { Box, Text, Spacer } from 'trend'
132
+ <Box style={{ flexDirection: 'row' }}><Text>hello</Text><Spacer /><Text>right</Text></Box>
133
+ ```
134
+
135
+ ## Hooks
136
+
137
+ ### useInput
138
+
139
+ Used in [counter](examples/counter.jsx), [dashboard](examples/dashboard.jsx), [explorer](examples/explorer.jsx), [chat](examples/chat.jsx), [modal-form](examples/modal-form.jsx), [components](examples/components.jsx), [focus-demo](examples/focus-demo.jsx)
140
+
141
+ ```jsx
142
+ useInput((event) => {
143
+ // event.key: 'a', 'return', 'escape', 'up', 'down', 'left', 'right',
144
+ // 'tab', 'shift-tab', 'space', 'backspace', 'delete',
145
+ // 'home', 'end', 'pageup', 'pagedown', 'f1'-'f12'
146
+ // event.ctrl: boolean
147
+ // event.meta: boolean (alt/option key)
148
+ // event.raw: raw character string
149
+ // event.stopPropagation(): prevent other handlers from receiving this event
150
+ })
151
+ ```
152
+
153
+ Handlers fire in reverse registration order (innermost component first). Call `stopPropagation()` to consume the event.
154
+
155
+ ### useHotkey
156
+
157
+ Declarative key binding. Parses `'ctrl+s'`, `'alt+enter'`, etc.
158
+
159
+ ```jsx
160
+ import { useHotkey } from 'trend'
161
+
162
+ useHotkey('ctrl+s', () => save())
163
+ useHotkey('alt+enter', () => submit(), { when: () => isFocused })
164
+ ```
165
+
166
+ ### useLayout
167
+
168
+ Returns the component's computed layout rectangle.
169
+
170
+ ```jsx
171
+ const { x, y, width, height } = useLayout()
172
+ ```
173
+
174
+ ### useResize
175
+
176
+ ```jsx
177
+ useResize(({ width, height }) => { /* terminal resized */ })
178
+ ```
179
+
180
+ ### useInterval
181
+
182
+ Used in [dashboard](examples/dashboard.jsx)
183
+
184
+ ```jsx
185
+ useInterval(() => tick(), 1000) // auto-cleaned on unmount
186
+ ```
187
+
188
+ ### useStdout
189
+
190
+ ```jsx
191
+ const stream = useStdout() // the output stream (process.stdout or custom)
192
+ ```
193
+
194
+ ### useTheme
195
+
196
+ Returns the current theme object. See [Theming](#theming).
197
+
198
+ ```jsx
199
+ const { accent } = useTheme()
200
+ ```
201
+
202
+ ### useFocus
203
+
204
+ Used in [explorer](examples/explorer.jsx), [chat](examples/chat.jsx), [modal-form](examples/modal-form.jsx), [components](examples/components.jsx), [focus-demo](examples/focus-demo.jsx), [layout](examples/layout.jsx)
205
+
206
+ Manages focus across multiple interactive regions. You register named items in the order you want tab to cycle through them. The focus manager tracks which name is currently active - it doesn't know anything about your components or layout.
207
+
208
+ ```jsx
209
+ import { useFocus } from 'trend'
210
+
211
+ const fm = useFocus({ initial: 'input' })
212
+
213
+ // declaration order = tab order
214
+ fm.item('input') // tab stop 0
215
+ fm.item('list') // tab stop 1
216
+ fm.item('sidebar') // tab stop 2
217
+ ```
218
+
219
+ Then wire each component's `focused` prop to `fm.is()`, which returns true when that name is the active one:
220
+
221
+ ```jsx
222
+ <TextInput focused={fm.is('input')} />
223
+ <List focused={fm.is('list')} />
224
+ <Select focused={fm.is('sidebar')} />
225
+ ```
226
+
227
+ Tab and shift-tab cycle through the registered names. The focus manager handles the index - you just query it.
228
+
229
+ ```jsx
230
+ fm.is('input') // boolean - is 'input' the active name?
231
+ fm.focus('list') // jump to 'list' programmatically
232
+ fm.current() // the currently active name
233
+ ```
234
+
235
+ Groups let you nest multiple items under one tab stop, navigable with j/k or up/down within the group:
236
+
237
+ ```jsx
238
+ fm.group('settings', { items: ['theme', 'autosave', 'format'], navigate: 'updown', wrap: true })
239
+ // fm.is('theme'), fm.is('autosave'), etc. work within the group
240
+ ```
241
+
242
+ Stack-based focus for modals and drills - push saves the current focus and switches, pop restores it:
243
+
244
+ ```jsx
245
+ fm.push('modal') // save current focus, switch to 'modal'
246
+ fm.pop() // restore previous focus
247
+ ```
248
+
249
+ ### useToast
250
+
251
+ Used in [chat](examples/chat.jsx), [modal-form](examples/modal-form.jsx), [components](examples/components.jsx)
252
+
253
+ ```jsx
254
+ import { useToast } from 'trend'
255
+
256
+ const toast = useToast({
257
+ duration: 2000, // ms, default 2000
258
+ position: 'bottom-right', // see positions below
259
+ margin: 1, // padding from screen edge, default 1
260
+ })
261
+
262
+ toast('saved')
263
+
264
+ // positions: 'top-left', 'top-center', 'top-right',
265
+ // 'center-left', 'center', 'center-right',
266
+ // 'bottom-left', 'bottom-center', 'bottom-right'
267
+ ```
268
+
269
+ ## Components
270
+
271
+ All interactive components accept a `focused` prop that controls whether they respond to keyboard input. When multiple components are on screen, only the focused one should capture keys - otherwise a keypress meant for a text input would also scroll a list. In practice you wire this to a focus manager:
272
+
273
+ ```jsx
274
+ const fm = useFocus({ initial: 'search' })
275
+ fm.item('search')
276
+ fm.item('results')
277
+
278
+ <TextInput focused={fm.is('search')} />
279
+ <List focused={fm.is('results')} />
280
+ ```
281
+
282
+ ### TextInput
283
+
284
+ Used in [explorer](examples/explorer.jsx), [modal-form](examples/modal-form.jsx), [focus-demo](examples/focus-demo.jsx)
285
+
286
+ Single-line text input with horizontal scrolling.
287
+
288
+ ```jsx
289
+ <TextInput
290
+ focused={fm.is('search')}
291
+ placeholder="search..."
292
+ onChange={v => {}} // every keystroke
293
+ onSubmit={v => {}} // Enter
294
+ onCancel={() => {}} // Escape (only stopPropagates if provided)
295
+ />
296
+ ```
297
+
298
+ Keys: left/right, home/end, ctrl-a/e, ctrl-u/k/w, backspace, delete.
299
+
300
+ ### TextArea
301
+
302
+ Used in [chat](examples/chat.jsx)
303
+
304
+ Multi-line text input. Auto-grows up to `maxHeight`, then scrolls.
305
+
306
+ ```jsx
307
+ <TextArea
308
+ focused={fm.is('input')}
309
+ placeholder="write something..."
310
+ maxHeight={10} // default 10
311
+ onChange={v => {}} // every edit
312
+ onSubmit={v => {}} // Alt+Enter
313
+ onCancel={() => {}} // Escape
314
+ />
315
+ ```
316
+
317
+ Keys: Enter inserts newline. Up/down with sticky goal column. Home/end operate on display rows. Ctrl-u/k/w operate on logical lines.
318
+
319
+ ### List
320
+
321
+ Used in [explorer](examples/explorer.jsx), [chat](examples/chat.jsx), [modal-form](examples/modal-form.jsx), [components](examples/components.jsx), [focus-demo](examples/focus-demo.jsx), [layout](examples/layout.jsx)
322
+
323
+ Scrollable list with keyboard navigation.
324
+
325
+ ```jsx
326
+ <List
327
+ items={data}
328
+ selected={selectedIndex} // controlled, or omit for internal state
329
+ onSelect={setIndex}
330
+ focused={fm.is('list')}
331
+ height={10} // defaults to layout height
332
+ header={<text>title</text>}
333
+ renderItem={(item, { selected, index, focused }) => (
334
+ <text style={{ bg: selected ? (focused ? accent : 'gray') : null }}>{item.name}</text>
335
+ )}
336
+ />
337
+ ```
338
+
339
+ Keys: j/k or up/down, g/G for top/bottom, ctrl-d/u half page, ctrl-f/b full page, pageup/pagedown.
340
+
341
+ ### Table
342
+
343
+ Used in [components](examples/components.jsx)
344
+
345
+ Column-based data table. Uses List internally.
346
+
347
+ ```jsx
348
+ <Table
349
+ columns={[
350
+ { header: 'Name', key: 'name', flexGrow: 1 },
351
+ { header: 'Size', key: 'size', width: 10, color: 'gray', paddingX: 1 },
352
+ { header: 'Type', render: (row, sel) => row.type.toUpperCase(), width: 8 },
353
+ ]}
354
+ data={rows}
355
+ selected={selectedRow}
356
+ onSelect={setRow}
357
+ focused={fm.is('table')}
358
+ />
359
+ ```
360
+
361
+ ### Tabs
362
+
363
+ Used in [chat](examples/chat.jsx)
364
+
365
+ ```jsx
366
+ <Tabs
367
+ items={['general', 'settings', 'logs']}
368
+ selected={activeTab}
369
+ onSelect={setTab}
370
+ focused={fm.is('tabs')}
371
+ />
372
+ ```
373
+
374
+ Keys: left/right, tab/shift-tab. Wraps around.
375
+
376
+ ### Select
377
+
378
+ Used in [modal-form](examples/modal-form.jsx), [components](examples/components.jsx), [focus-demo](examples/focus-demo.jsx)
379
+
380
+ Dropdown selector. Can render inline or as overlay.
381
+
382
+ ```jsx
383
+ <Select
384
+ items={['red', 'green', 'blue']}
385
+ selected={color}
386
+ onSelect={setColor}
387
+ focused={fm.is('color')}
388
+ overlay={false} // true renders as floating overlay
389
+ placeholder="pick one..."
390
+ renderItem={(item, { selected, index }) => <text>{item}</text>}
391
+ style={{
392
+ border: 'single', borderColor: 'green', bg: 'black',
393
+ cursorBg: 'green', cursorTextColor: 'black',
394
+ color: null, focusedColor: 'green',
395
+ }}
396
+ />
397
+ ```
398
+
399
+ Keys: j/k or up/down to navigate, enter/space to select, escape to close.
400
+
401
+ ### Checkbox
402
+
403
+ Used in [modal-form](examples/modal-form.jsx), [components](examples/components.jsx), [focus-demo](examples/focus-demo.jsx)
404
+
405
+ ```jsx
406
+ <Checkbox
407
+ checked={isChecked}
408
+ label="Enable feature"
409
+ onChange={setChecked} // (newState: boolean) => void
410
+ focused={fm.is('feature')}
411
+ />
412
+ ```
413
+
414
+ Keys: space or enter to toggle. Renders `[x]` / `[ ]`.
415
+
416
+ ### Radio
417
+
418
+ Used in [modal-form](examples/modal-form.jsx), [components](examples/components.jsx), [focus-demo](examples/focus-demo.jsx)
419
+
420
+ ```jsx
421
+ <Radio
422
+ options={['small', 'medium', 'large']}
423
+ selected={size}
424
+ onSelect={setSize}
425
+ focused={fm.is('size')}
426
+ />
427
+ ```
428
+
429
+ Keys: j/k or up/down, enter/space to select. Renders circle symbols.
430
+
431
+ ### ProgressBar
432
+
433
+ Used in [dashboard](examples/dashboard.jsx), [components](examples/components.jsx)
434
+
435
+ ```jsx
436
+ <ProgressBar
437
+ value={0.65} // 0 to 1
438
+ width={20} // characters, default 20
439
+ color="red" // overrides theme accent
440
+ label="65%" // optional text after bar
441
+ />
442
+ ```
443
+
444
+ ### Spinner
445
+
446
+ Used in [components](examples/components.jsx)
447
+
448
+ ```jsx
449
+ <Spinner
450
+ label="loading..."
451
+ color="magenta" // overrides theme accent
452
+ interval={80} // ms, default 80
453
+ />
454
+ ```
455
+
456
+ ### Button
457
+
458
+ Used in [modal-form](examples/modal-form.jsx)
459
+
460
+ Focusable button. Enter or space to activate.
461
+
462
+ ```jsx
463
+ <Button
464
+ label="save"
465
+ onPress={() => save()}
466
+ focused={fm.is('save')}
467
+ variant="dim" // optional, grays out when unfocused
468
+ />
469
+ ```
470
+
471
+ ### Modal
472
+
473
+ Used in [modal-form](examples/modal-form.jsx), [components](examples/components.jsx), [focus-demo](examples/focus-demo.jsx)
474
+
475
+ Centered overlay with dimmed backdrop. Height is driven by content.
476
+
477
+ ```jsx
478
+ <Modal
479
+ open={isOpen}
480
+ onClose={() => setOpen(false)}
481
+ title="Confirm"
482
+ width={40} // default 40
483
+ >
484
+ <text>Are you sure?</text>
485
+ <Button label="ok" onPress={() => setOpen(false)} focused={fm.is('ok')} />
486
+ </Modal>
487
+ ```
488
+
489
+ Keys: escape to close.
490
+
491
+ ### ScrollableText
492
+
493
+ Used in [explorer](examples/explorer.jsx), [reader](examples/reader.jsx), [highlight](examples/highlight.jsx)
494
+
495
+ Scrollable text viewer with optional scrollbar. Content can include ANSI escape sequences (SGR) - colors, bold, dim, etc. are parsed and rendered correctly. This means you can pipe output from any syntax highlighter (shiki, cli-highlight, etc.) directly into `content`.
496
+
497
+ ```jsx
498
+ <ScrollableText
499
+ content={longText}
500
+ focused={fm.is('preview')}
501
+ scrollOffset={offset} // controlled, or omit for internal state
502
+ onScroll={setOffset}
503
+ scrollbar={true} // default false
504
+ wrap={false} // default true, set false for horizontal scroll
505
+ />
506
+ ```
507
+
508
+ Keys: same as List (j/k, g/G, ctrl-d/u, ctrl-f/b, pageup/pagedown).
509
+
510
+ ## Build
511
+
512
+ Uses esbuild. JSX configured with `jsxImportSource: 'trend'`.
513
+
514
+ ```
515
+ node esbuild.config.js
516
+ ```
517
+
518
+ Examples run via npm scripts:
519
+
520
+ ```
521
+ npm run counter
522
+ npm run chat
523
+ npm run dashboard
524
+ npm run explorer
525
+ npm run highlight
526
+ ```
package/index.js ADDED
@@ -0,0 +1,22 @@
1
+ export { mount } from './src/renderer.js'
2
+ export { createSignal } from './src/signal.js'
3
+ export { createEffect, createMemo, batch, untrack, onCleanup } from './src/signal.js'
4
+ export { useInput, useResize, useInterval, useLayout, useStdout, useTitle, useTheme } from './src/hooks.js'
5
+ export { Box, Text, Spacer } from './src/components.js'
6
+ export { TextInput } from './src/text-input.js'
7
+ export { TextArea } from './src/text-area.js'
8
+ export { List } from './src/list.js'
9
+ export { Table } from './src/table.js'
10
+ export { Tabs } from './src/tabs.js'
11
+ export { Select } from './src/select.js'
12
+ export { Checkbox } from './src/checkbox.js'
13
+ export { ProgressBar } from './src/progress.js'
14
+ export { Spinner } from './src/spinner.js'
15
+ export { Modal } from './src/modal.js'
16
+ export { Radio } from './src/radio.js'
17
+ export { Button } from './src/button.js'
18
+ export { ScrollableText } from './src/scrollable-text.js'
19
+ export { useFocus } from './src/focus.js'
20
+ export { useHotkey } from './src/hotkey.js'
21
+ export { useToast } from './src/toast.js'
22
+ export { Fragment } from './src/element.js'
package/jsx-runtime.js ADDED
@@ -0,0 +1,9 @@
1
+ import { Fragment } from './src/element.js'
2
+
3
+ export { Fragment }
4
+
5
+ export function jsx(type, props, key) {
6
+ return { type, props, key: key ?? props?.key ?? null }
7
+ }
8
+
9
+ export const jsxs = jsx
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@trendr/core",
3
+ "version": "0.1.0",
4
+ "description": "direct-mode TUI renderer with JSX, signals, and per-cell diffing",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./index.js",
8
+ "files": [
9
+ "src",
10
+ "index.js",
11
+ "jsx-runtime.js"
12
+ ],
13
+ "exports": {
14
+ ".": "./index.js",
15
+ "./jsx-runtime": "./jsx-runtime.js",
16
+ "./jsx-dev-runtime": "./jsx-runtime.js"
17
+ },
18
+ "scripts": {
19
+ "test": "node test/test.js && node test/test-e2e.js && node test/test-mount.js && node test/test-render.js",
20
+ "build": "node esbuild.config.js",
21
+ "counter": "node esbuild.config.js && node dist/counter.js",
22
+ "dashboard": "node esbuild.config.js && node dist/dashboard.js",
23
+ "chat": "node esbuild.config.js && node dist/chat.js",
24
+ "explorer": "node esbuild.config.js && node dist/explorer.js",
25
+ "layout": "node esbuild.config.js && node dist/layout.js",
26
+ "components": "node esbuild.config.js && node dist/components.js",
27
+ "reader": "node esbuild.config.js && node dist/reader.js",
28
+ "focus-demo": "node esbuild.config.js && node dist/focus-demo.js",
29
+ "modal-form": "node esbuild.config.js && node dist/modal-form.js",
30
+ "highlight": "node esbuild.config.js && node dist/highlight.js src"
31
+ },
32
+ "devDependencies": {
33
+ "esbuild": "^0.25.0"
34
+ }
35
+ }