@semos-labs/glyph 0.1.74

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/README.md ADDED
@@ -0,0 +1,913 @@
1
+ <p align="center">
2
+ <img src="https://em-content.zobj.net/source/apple/391/crystal-ball_1f52e.png" width="120" height="120" alt="Glyph">
3
+ </p>
4
+
5
+ <h1 align="center">Glyph</h1>
6
+
7
+ <p align="center">
8
+ <strong>React renderer for terminal UIs</strong><br>
9
+ <em>Flexbox layout. Keyboard-driven. Zero compromises.</em>
10
+ </p>
11
+
12
+ <p align="center">
13
+ <a href="#quick-start">Quick Start</a> &bull;
14
+ <a href="#components">Components</a> &bull;
15
+ <a href="#hooks">Hooks</a> &bull;
16
+ <a href="#styling">Styling</a> &bull;
17
+ <a href="#examples">Examples</a>
18
+ </p>
19
+
20
+ <p align="center">
21
+ <a href="https://www.npmjs.com/package/@semos-labs/glyph"><img src="https://img.shields.io/npm/v/@semos-labs/glyph?color=crimson&logo=npm" alt="npm version"></a>
22
+ <a href="https://github.com/nick-skriabin/glyph/actions/workflows/test.yml"><img src="https://github.com/nick-skriabin/glyph/actions/workflows/test.yml/badge.svg" alt="Tests"></a>
23
+ <img src="https://img.shields.io/badge/React-18%2B-61dafb?logo=react&logoColor=white" alt="React 18+">
24
+ <img src="https://img.shields.io/badge/Yoga-Flexbox-mediumpurple?logo=meta&logoColor=white" alt="Yoga Flexbox">
25
+ <img src="https://img.shields.io/badge/TypeScript-First-3178c6?logo=typescript&logoColor=white" alt="TypeScript">
26
+ <img src="https://img.shields.io/badge/License-MIT-blue" alt="MIT License">
27
+ </p>
28
+
29
+ ---
30
+
31
+ Build real terminal applications with React. Glyph provides a full component model with flexbox layout (powered by Yoga), focus management, keyboard input, and efficient diff-based rendering. Write TUIs the same way you write web apps.
32
+
33
+ | | | |
34
+ |---|---|---|
35
+ | ![Glyph](./screehshots/glyph-main.jpg) | ![Glyph List](./screehshots/glyph-list.jpg) |![Glyph Edit](./screehshots/glyph-edit.jpg) |
36
+
37
+
38
+ ### Features
39
+
40
+ | | |
41
+ |---|---|
42
+ | **Flexbox Layout** | Full CSS-like flexbox via Yoga &mdash; rows, columns, wrapping, alignment, gaps, padding |
43
+ | **Rich Components** | Box, Text, Input, Button, Checkbox, Radio, Select, ScrollView, List, Menu, Progress, Spinner, Image, Toasts, Dialogs, Portal, JumpNav |
44
+ | **Focus System** | Tab navigation, focus scopes, focus trapping for modals, JumpNav quick-jump hints |
45
+ | **Keyboard Input** | `useInput` hook, declarative `<Keybind>` component, vim-style bindings |
46
+ | **Smart Rendering** | Double-buffered framebuffer with character-level diffing &mdash; only changed cells are written |
47
+ | **True Colors** | Named colors, hex, RGB, 256-palette. Auto-contrast text on colored backgrounds |
48
+ | **Borders** | Single, double, rounded, and ASCII border styles |
49
+ | **TypeScript** | Full type coverage. Every prop, style, and hook is typed |
50
+
51
+ ---
52
+
53
+ ## Installation
54
+
55
+ ```bash
56
+ # npm
57
+ npm install @semos-labs/glyph react
58
+
59
+ # pnpm
60
+ pnpm add @semos-labs/glyph react
61
+
62
+ # bun
63
+ bun add @semos-labs/glyph react
64
+ ```
65
+
66
+ ---
67
+
68
+ ## Quick Start
69
+
70
+ ```tsx
71
+ import React from "react";
72
+ import { render, Box, Text, Keybind, useApp } from "@semos-labs/glyph";
73
+
74
+ function App() {
75
+ const { exit } = useApp();
76
+
77
+ return (
78
+ <Box style={{ border: "round", borderColor: "cyan", padding: 1 }}>
79
+ <Text style={{ bold: true, color: "green" }}>Hello, Glyph!</Text>
80
+ <Keybind keypress="q" onPress={() => exit()} />
81
+ </Box>
82
+ );
83
+ }
84
+
85
+ render(<App />);
86
+ ```
87
+
88
+ Run it:
89
+
90
+ ```bash
91
+ npx tsx app.tsx
92
+ ```
93
+
94
+ ---
95
+
96
+ ## Components
97
+
98
+ ### `<Box>`
99
+
100
+ Flexbox container. The fundamental building block.
101
+
102
+ ```tsx
103
+ <Box style={{ flexDirection: "row", gap: 2, border: "single", padding: 1 }}>
104
+ <Box style={{ flexGrow: 1, bg: "blue" }}>
105
+ <Text>Left</Text>
106
+ </Box>
107
+ <Box style={{ flexGrow: 1, bg: "red" }}>
108
+ <Text>Right</Text>
109
+ </Box>
110
+ </Box>
111
+ ```
112
+
113
+ ### `<Text>`
114
+
115
+ Styled text content. Supports wrapping, alignment, bold, dim, italic, underline.
116
+
117
+ ```tsx
118
+ <Text style={{ color: "yellowBright", bold: true, textAlign: "center" }}>
119
+ Warning: something happened
120
+ </Text>
121
+ ```
122
+
123
+ **ANSI Escape Codes:** Text automatically parses and renders embedded ANSI escape codes, making it easy to display colorized output from CLI tools, libraries like `chalk`/`picocolors`, or your own styled strings:
124
+
125
+ ```tsx
126
+ // Using ANSI codes directly
127
+ const coloredText = "\x1b[32mGreen\x1b[0m and \x1b[1;31mBold Red\x1b[0m";
128
+ <Text>{coloredText}</Text>
129
+
130
+ // Works with chalk, picocolors, etc.
131
+ import chalk from "chalk";
132
+ <Text>{chalk.blue("Blue") + " " + chalk.bold.red("Bold Red")}</Text>
133
+
134
+ // Display CLI output with preserved colors
135
+ const gitOutput = execSync("git status --short", { encoding: "utf8" });
136
+ <Text>{gitOutput}</Text>
137
+ ```
138
+
139
+ Supports: basic colors (30-37, 40-47), bright colors (90-97, 100-107), 256-color palette (`\x1b[38;5;Nm`), true color RGB (`\x1b[38;2;R;G;Bm`), and attributes (bold, dim, italic, underline).
140
+
141
+ **Utility functions** for working with ANSI strings:
142
+
143
+ ```tsx
144
+ import { parseAnsi, stripAnsi } from "@semos-labs/glyph";
145
+
146
+ // Parse ANSI into styled segments
147
+ const segments = parseAnsi("\x1b[31mRed\x1b[0m Normal");
148
+ // [{ text: "Red", style: { fg: "red" } }, { text: " Normal", style: {} }]
149
+
150
+ // Strip all ANSI codes (useful for width calculations)
151
+ stripAnsi("\x1b[32mHello\x1b[0m"); // "Hello"
152
+ ```
153
+
154
+ ### `<Input>`
155
+
156
+ Text input field with cursor and placeholder support.
157
+
158
+ ```tsx
159
+ <Input
160
+ value={text}
161
+ onChange={setText}
162
+ placeholder="Type here..."
163
+ style={{ bg: "blackBright", paddingX: 1 }}
164
+ focusedStyle={{ bg: "white", color: "black" }}
165
+ />
166
+ ```
167
+
168
+ Supports `multiline` for multi-line editing, `autoFocus` for automatic focus on mount. The cursor is always visible when focused.
169
+
170
+ **Input types** for validation:
171
+
172
+ ```tsx
173
+ // Text input (default) - accepts any character
174
+ <Input type="text" value={name} onChange={setName} />
175
+
176
+ // Number input - only accepts digits, decimal point, minus sign
177
+ <Input type="number" value={age} onChange={setAge} placeholder="0" />
178
+ ```
179
+
180
+ **Input masking** with `onBeforeChange` for validation/formatting:
181
+
182
+ ```tsx
183
+ import { createMask, masks } from "@semos-labs/glyph";
184
+
185
+ // Pre-built masks
186
+ <Input onBeforeChange={masks.usPhone} placeholder="(___) ___-____" />
187
+ <Input onBeforeChange={masks.creditCard} placeholder="____ ____ ____ ____" />
188
+
189
+ // Custom masks: 9=digit, a=letter, *=alphanumeric
190
+ const licensePlate = createMask("aaa-9999");
191
+ <Input onBeforeChange={licensePlate} placeholder="___-____" />
192
+ ```
193
+
194
+ Available masks: `usPhone`, `intlPhone`, `creditCard`, `dateUS`, `dateEU`, `dateISO`, `time`, `timeFull`, `ssn`, `zip`, `zipPlus4`, `ipv4`, `mac`.
195
+
196
+ ### `<Button>`
197
+
198
+ Focusable button with press handling and visual feedback.
199
+
200
+ ```tsx
201
+ <Button
202
+ onPress={() => console.log("clicked")}
203
+ style={{ border: "single", borderColor: "cyan", paddingX: 2 }}
204
+ focusedStyle={{ borderColor: "yellowBright", bold: true }}
205
+ >
206
+ <Text>Submit</Text>
207
+ </Button>
208
+ ```
209
+
210
+ Buttons participate in the focus system automatically. Press `Enter` or `Space` to activate.
211
+
212
+ ### `<Checkbox>`
213
+
214
+ Toggle checkbox with label support.
215
+
216
+ ```tsx
217
+ const [agreed, setAgreed] = useState(false);
218
+
219
+ <Checkbox
220
+ checked={agreed}
221
+ onChange={setAgreed}
222
+ label="I agree to the terms"
223
+ focusedStyle={{ color: "cyan" }}
224
+ />
225
+ ```
226
+
227
+ Focusable. Press `Enter` or `Space` to toggle. Supports custom `checkedChar` and `uncheckedChar` props.
228
+
229
+ ### `<Radio>`
230
+
231
+ Radio button group for single selection from multiple options.
232
+
233
+ ```tsx
234
+ const [theme, setTheme] = useState<string>("dark");
235
+
236
+ <Radio
237
+ items={[
238
+ { label: "Light", value: "light" },
239
+ { label: "Dark", value: "dark" },
240
+ { label: "System", value: "system" },
241
+ ]}
242
+ value={theme}
243
+ onChange={setTheme}
244
+ focusedItemStyle={{ color: "cyan" }}
245
+ selectedItemStyle={{ bold: true }}
246
+ />
247
+ ```
248
+
249
+ Focusable. Navigate with `Up`/`Down`/`Left`/`Right`/`Tab`/`Shift+Tab`, select with `Enter`/`Space`. Supports `direction` prop (`"column"` or `"row"`), custom `selectedChar` and `unselectedChar`.
250
+
251
+ ### `<ScrollView>`
252
+
253
+ Scrollable container with keyboard navigation and clipping.
254
+
255
+ ```tsx
256
+ <ScrollView style={{ flexGrow: 1, border: "single" }}>
257
+ {items.map((item, i) => (
258
+ <Box key={i}>
259
+ <Text>{item}</Text>
260
+ </Box>
261
+ ))}
262
+ </ScrollView>
263
+ ```
264
+
265
+ **Keyboard:** `PageUp`/`PageDown`, `Ctrl+d`/`Ctrl+u` (half-page), `Ctrl+f`/`Ctrl+b` (full page).
266
+
267
+ Shows a scrollbar when content exceeds viewport (disable with `showScrollbar={false}`). Supports controlled mode with `scrollOffset` and `onScroll` props.
268
+
269
+ **Focus-aware scrolling:** ScrollView is focusable by default and responds to scroll keys when focused (or when it contains the focused element). This prevents multiple ScrollViews from scrolling simultaneously — only the one with focus responds.
270
+
271
+ Set `focusable={false}` if you want the ScrollView to only scroll when a child element has focus:
272
+
273
+ ```tsx
274
+ <ScrollView focusable={false} style={{ flexGrow: 1 }}>
275
+ <Input ... /> {/* ScrollView scrolls only when Input is focused */}
276
+ </ScrollView>
277
+ ```
278
+
279
+ **Virtualization:** For large lists (1000+ items), add the `virtualize` prop to only render visible children. Heights are auto-measured — no configuration needed:
280
+
281
+ ```tsx
282
+ <ScrollView virtualize style={{ height: 20, border: "single" }}>
283
+ {items.map((item) => (
284
+ <Text key={item.id}>{item.name}</Text>
285
+ ))}
286
+ </ScrollView>
287
+ ```
288
+
289
+ This renders only visible items + a small overscan buffer, making it fast even with 10,000+ items. Use `estimatedItemHeight` prop if your items are taller than 1 line (default).
290
+
291
+ ### `<List>`
292
+
293
+ Keyboard-navigable selection list with a render callback.
294
+
295
+ ```tsx
296
+ <List
297
+ count={items.length}
298
+ onSelect={(index) => handleSelect(items[index])}
299
+ disabledIndices={new Set([2, 5])}
300
+ renderItem={({ index, selected, focused }) => (
301
+ <Box style={selected && focused ? { bg: "cyan" } : {}}>
302
+ <Text style={selected ? { bold: true } : {}}>
303
+ {selected ? "> " : " "}{items[index]}
304
+ </Text>
305
+ </Box>
306
+ )}
307
+ />
308
+ ```
309
+
310
+ Focusable. `Up`/`Down`/`j`/`k` to navigate, `G` to jump to bottom, `gg` to jump to top, `Enter` to select. Disabled indices are skipped.
311
+
312
+ ### `<Menu>`
313
+
314
+ Styled menu built on `<List>`. Accepts structured items with labels, values, and disabled state.
315
+
316
+ ```tsx
317
+ <Menu
318
+ items={[
319
+ { label: "New File", value: "new" },
320
+ { label: "Open File", value: "open" },
321
+ { label: "Export", value: "export", disabled: true },
322
+ { label: "Quit", value: "quit" },
323
+ ]}
324
+ onSelect={(value) => handleAction(value)}
325
+ highlightColor="yellow"
326
+ />
327
+ ```
328
+
329
+ ### `<Select>`
330
+
331
+ Dropdown select with keyboard navigation and type-to-filter search.
332
+
333
+ ```tsx
334
+ const [lang, setLang] = useState<string | undefined>();
335
+
336
+ <Select
337
+ items={[
338
+ { label: "TypeScript", value: "ts" },
339
+ { label: "JavaScript", value: "js" },
340
+ { label: "Rust", value: "rust" },
341
+ { label: "Go", value: "go" },
342
+ { label: "COBOL", value: "cobol", disabled: true },
343
+ ]}
344
+ value={lang}
345
+ onChange={setLang}
346
+ placeholder="Pick a language..."
347
+ maxVisible={6}
348
+ highlightColor="yellow"
349
+ />
350
+ ```
351
+
352
+ Focusable. `Enter`/`Space`/`Down` to open, `Up`/`Down` to navigate, `Enter` to confirm, `Escape` to close. Type characters to filter items when open. Disabled items are skipped.
353
+
354
+ Props: `items`, `value`, `onChange`, `placeholder`, `maxVisible`, `highlightColor`, `searchable`, `style`, `focusedStyle`, `dropdownStyle`, `disabled`.
355
+
356
+ ### `<FocusScope>`
357
+
358
+ Focus trapping for modals and overlays.
359
+
360
+ ```tsx
361
+ <FocusScope trap>
362
+ <Input value={v} onChange={setV} />
363
+ <Button onPress={submit}>
364
+ <Text>OK</Text>
365
+ </Button>
366
+ </FocusScope>
367
+ ```
368
+
369
+ ### `<Portal>`
370
+
371
+ Renders children in a fullscreen absolute overlay. Useful for modals and dialogs.
372
+
373
+ ```tsx
374
+ <Portal>
375
+ <Box style={{ width: "100%", height: "100%", justifyContent: "center", alignItems: "center" }}>
376
+ <Box style={{ width: 40, border: "double", bg: "black", padding: 1 }}>
377
+ <Text>Modal content</Text>
378
+ </Box>
379
+ </Box>
380
+ </Portal>
381
+ ```
382
+
383
+ ### `<JumpNav>`
384
+
385
+ Quick keyboard navigation to any focusable element. Press an activation key to show hint labels on all focusable elements, then type the hint to jump directly to that element. Similar to Vim's EasyMotion or browser extensions like Vimium.
386
+
387
+ ```tsx
388
+ function App() {
389
+ return (
390
+ <JumpNav activationKey="ctrl+o">
391
+ <Box style={{ flexDirection: "column", gap: 1 }}>
392
+ <Input placeholder="Name" />
393
+ <Input placeholder="Email" />
394
+ <Select items={countries} />
395
+ <Button onPress={submit}>Submit</Button>
396
+ </Box>
397
+ </JumpNav>
398
+ );
399
+ }
400
+ ```
401
+
402
+ **How it works:**
403
+ 1. Press `Ctrl+O` (or custom `activationKey`) to activate
404
+ 2. Hint labels (a, s, d, f...) appear next to each focusable element
405
+ 3. Type a hint to instantly focus that element
406
+ 4. Press `Escape` to cancel
407
+
408
+ **Props:**
409
+
410
+ | Prop | Type | Default | Description |
411
+ |------|------|---------|-------------|
412
+ | `activationKey` | `string` | `"ctrl+o"` | Key to activate jump mode |
413
+ | `hintChars` | `string` | `"asdfghjkl..."` | Characters used for hints |
414
+ | `hintBg` | `Color` | `"yellow"` | Hint label background |
415
+ | `hintFg` | `Color` | `"black"` | Hint label text color |
416
+ | `hintStyle` | `Style` | `{}` | Additional hint label styling |
417
+ | `enabled` | `boolean` | `true` | Enable/disable JumpNav |
418
+
419
+ **Focus scope aware:** JumpNav automatically respects `<FocusScope trap>`. When a modal with a focus trap is open, only elements inside that trap will show hints.
420
+
421
+ ### `<Keybind>`
422
+
423
+ Declarative keyboard shortcut. Renders nothing.
424
+
425
+ ```tsx
426
+ <Keybind keypress="ctrl+s" onPress={save} />
427
+ <Keybind keypress="escape" onPress={close} />
428
+ <Keybind keypress="q" onPress={() => exit()} />
429
+ ```
430
+
431
+ **Modifiers:** `ctrl`, `alt`, `shift`, `meta` (Cmd/Super). Combine with `+`: `"ctrl+shift+p"`, `"alt+return"`.
432
+
433
+ **Priority keybinds:** Use `priority` prop to run BEFORE focused input handlers. Useful for keybinds that should work even when an Input is focused:
434
+
435
+ ```tsx
436
+ <Keybind keypress="ctrl+return" onPress={submit} priority />
437
+ <Keybind keypress="alt+return" onPress={submit} priority />
438
+ ```
439
+
440
+ **Terminal configuration:** Some keybinds like `ctrl+return` require terminal support:
441
+
442
+ | Terminal | Configuration |
443
+ |----------|---------------|
444
+ | **Ghostty** | Add to `~/.config/ghostty/config`: `keybind = ctrl+enter=text:\x1b[13;5~` |
445
+ | **iTerm2** | Profiles → Keys → General → Enable "CSI u" mode |
446
+ | **Kitty/WezTerm** | Works out of the box |
447
+
448
+ `alt+return` works universally without configuration.
449
+
450
+ ### `<Progress>`
451
+
452
+ Determinate or indeterminate progress bar. Uses `useLayout` to measure actual width and renders block characters.
453
+
454
+ ```tsx
455
+ <Progress value={0.65} showPercent />
456
+ <Progress indeterminate label="Loading" />
457
+ ```
458
+
459
+ Props: `value` (0..1), `indeterminate`, `width`, `label`, `showPercent`, `filled`/`empty` (characters).
460
+
461
+ ### `<Spinner>`
462
+
463
+ Animated spinner with configurable frames. Cleans up timers on unmount.
464
+
465
+ ```tsx
466
+ <Spinner label="Loading..." style={{ color: "green" }} />
467
+ <Spinner frames={["|", "/", "-", "\\"]} intervalMs={100} />
468
+ ```
469
+
470
+ ### `<Image>`
471
+
472
+ Display images in the terminal with inline rendering or OS preview. Supports local files and remote URLs.
473
+
474
+ ```tsx
475
+ <Image
476
+ src="./photo.jpg"
477
+ style={{ width: 40, height: 15 }}
478
+ autoLoad
479
+ />
480
+
481
+ <Image
482
+ src="https://images.unsplash.com/photo-123"
483
+ style={{ flexGrow: 1 }}
484
+ />
485
+ ```
486
+
487
+ **How it works:**
488
+ 1. By default, shows a placeholder with the image name
489
+ 2. Focus the component and press `Space` to load
490
+ 3. Image renders inline (Kitty/iTerm2 protocol) or opens OS preview (Quick Look on macOS)
491
+ 4. Press `Escape` to return to placeholder, `R` to reload
492
+
493
+ **Props:**
494
+
495
+ | Prop | Type | Default | Description |
496
+ |------|------|---------|-------------|
497
+ | `src` | `string` | required | Local path or remote URL |
498
+ | `width` | `number` | auto | Fixed width in cells |
499
+ | `height` | `number` | auto | Fixed height in cells |
500
+ | `style` | `Style` | `{}` | Container style (flexbox) |
501
+ | `focusedStyle` | `Style` | `{}` | Style when focused |
502
+ | `inline` | `boolean` | `true` | Allow inline terminal rendering |
503
+ | `autoLoad` | `boolean` | `false` | Load automatically on mount |
504
+ | `focusable` | `boolean` | `true` | Whether the component is focusable |
505
+ | `placeholder` | `string` | filename | Custom placeholder text |
506
+ | `onStateChange` | `(state) => void` | - | Called when state changes |
507
+ | `onError` | `(error) => void` | - | Called on error |
508
+ | `autoSize` | `boolean` | `false` | Auto-size box to fit image dimensions |
509
+ | `maxWidth` | `number` | `80` | Max width in cells when autoSize is true |
510
+ | `maxHeight` | `number` | `24` | Max height in cells when autoSize is true |
511
+
512
+ **Terminal support:**
513
+ - **Inline rendering:** Kitty, Ghostty, WezTerm, iTerm2 (via Kitty Graphics or iTerm2 protocol)
514
+ - **OS preview fallback:** Quick Look (macOS), xdg-open (Linux), start (Windows)
515
+
516
+ **Remote images** are automatically downloaded and cached. Supported formats: PNG, JPEG, GIF, WebP.
517
+
518
+ ```tsx
519
+ // Inline disabled - always use OS preview
520
+ <Image src="./large-photo.jpg" inline={false} />
521
+
522
+ // Auto-size: box resizes to fit the image (instead of image fitting in box)
523
+ <Image
524
+ src="./photo.jpg"
525
+ autoSize
526
+ maxWidth={60}
527
+ maxHeight={20}
528
+ />
529
+
530
+ // With state callback
531
+ <Image
532
+ src={imageUrl}
533
+ onStateChange={(state) => {
534
+ // "placeholder" | "loading" | "loaded" | "error" | "preview"
535
+ console.log("Image state:", state);
536
+ }}
537
+ />
538
+ ```
539
+
540
+ ### `<ToastHost>` + `useToast()`
541
+
542
+ Lightweight toast notifications rendered via Portal. Wrap your app in `<ToastHost>`, then push toasts from anywhere with `useToast()`.
543
+
544
+ ```tsx
545
+ function App() {
546
+ const toast = useToast();
547
+ return <Keybind keypress="t" onPress={() =>
548
+ toast({ message: "Saved!", variant: "success" })
549
+ } />;
550
+ }
551
+
552
+ render(<ToastHost position="top-right"><App /></ToastHost>);
553
+ ```
554
+
555
+ Variants: `"info"`, `"success"`, `"warning"`, `"error"`. Auto-dismiss after `durationMs` (default 3000).
556
+
557
+ ### `<DialogHost>` + `useDialog()`
558
+
559
+ Imperative `alert()` and `confirm()` dialogs, similar to browser APIs. Wrap your app in `<DialogHost>`, then show dialogs from anywhere.
560
+
561
+ ```tsx
562
+ function App() {
563
+ const { alert, confirm } = useDialog();
564
+
565
+ const handleDelete = async () => {
566
+ const ok = await confirm("Delete this item?", {
567
+ okText: "Delete",
568
+ cancelText: "Keep"
569
+ });
570
+ if (ok) {
571
+ // delete the item
572
+ }
573
+ };
574
+
575
+ const handleSave = async () => {
576
+ await saveData();
577
+ await alert("Saved successfully!");
578
+ };
579
+
580
+ return <Button onPress={handleDelete}><Text>Delete</Text></Button>;
581
+ }
582
+
583
+ render(<DialogHost><App /></DialogHost>);
584
+ ```
585
+
586
+ **Rich content** — pass React elements instead of strings:
587
+
588
+ ```tsx
589
+ await alert(
590
+ <Box style={{ flexDirection: "column" }}>
591
+ <Text style={{ bold: true, color: "green" }}>✓ Success!</Text>
592
+ <Text>Your changes have been saved.</Text>
593
+ </Box>,
594
+ { okText: "Got it!" }
595
+ );
596
+ ```
597
+
598
+ **Keyboard:** Tab/Shift+Tab or arrows to switch buttons, Enter/Space to select, Escape to cancel.
599
+
600
+ **Chained dialogs** work naturally with async/await — each dialog waits for the previous to close.
601
+
602
+ ### `<Spacer>`
603
+
604
+ Flexible space filler. Pushes siblings apart.
605
+
606
+ ```tsx
607
+ <Box style={{ flexDirection: "row" }}>
608
+ <Text>Left</Text>
609
+ <Spacer />
610
+ <Text>Right</Text>
611
+ </Box>
612
+ ```
613
+
614
+ ---
615
+
616
+ ## Hooks
617
+
618
+ ### `useInput(handler)`
619
+
620
+ Listen for all keyboard events.
621
+
622
+ ```tsx
623
+ useInput((key) => {
624
+ if (key.name === "escape") close();
625
+ if (key.ctrl && key.name === "s") save();
626
+ });
627
+ ```
628
+
629
+ ### `useFocus(nodeRef)`
630
+
631
+ Get focus state for a node.
632
+
633
+ ```tsx
634
+ const ref = useRef(null);
635
+ const { focused, focus } = useFocus(ref);
636
+
637
+ <Box ref={ref} focusable>
638
+ <Text style={focused ? { color: "cyan" } : {}}>
639
+ {focused ? "* focused *" : "not focused"}
640
+ </Text>
641
+ </Box>
642
+ ```
643
+
644
+ ### `useFocusable(options)`
645
+
646
+ Make any element focusable with full keyboard support. Perfect for building custom interactive components.
647
+
648
+ ```tsx
649
+ import { useFocusable, Box, Text } from "@semos-labs/glyph";
650
+
651
+ function CustomPicker({ items, onSelect }) {
652
+ const [selected, setSelected] = useState(0);
653
+
654
+ const { ref, isFocused } = useFocusable({
655
+ onKeyPress: (key) => {
656
+ if (key.name === "up") {
657
+ setSelected(s => Math.max(0, s - 1));
658
+ return true; // Consume the key
659
+ }
660
+ if (key.name === "down") {
661
+ setSelected(s => Math.min(items.length - 1, s + 1));
662
+ return true;
663
+ }
664
+ if (key.name === "return") {
665
+ onSelect(items[selected]);
666
+ return true;
667
+ }
668
+ return false; // Let other handlers process
669
+ },
670
+ onFocus: () => console.log("Picker focused"),
671
+ onBlur: () => console.log("Picker blurred"),
672
+ disabled: false, // Set to true to skip in tab order
673
+ });
674
+
675
+ return (
676
+ <Box
677
+ ref={ref}
678
+ focusable
679
+ style={{
680
+ border: "round",
681
+ borderColor: isFocused ? "cyan" : "gray",
682
+ padding: 1,
683
+ }}
684
+ >
685
+ {items.map((item, i) => (
686
+ <Text key={i} style={{ inverse: i === selected }}>
687
+ {i === selected ? "> " : " "}{item}
688
+ </Text>
689
+ ))}
690
+ </Box>
691
+ );
692
+ }
693
+ ```
694
+
695
+ Returns `{ ref, isFocused, focus, focusId }`. The `ref` must be attached to an element with `focusable` prop.
696
+
697
+ ### `useLayout(nodeRef)`
698
+
699
+ Subscribe to a node's computed layout.
700
+
701
+ ```tsx
702
+ const ref = useRef(null);
703
+ const layout = useLayout(ref);
704
+
705
+ // layout: { x, y, width, height, innerX, innerY, innerWidth, innerHeight }
706
+ ```
707
+
708
+ ### `useApp()`
709
+
710
+ Access app-level utilities.
711
+
712
+ ```tsx
713
+ const { exit, columns, rows } = useApp();
714
+ ```
715
+
716
+ ---
717
+
718
+ ## Styling
719
+
720
+ All components accept a `style` prop. Glyph uses Yoga for flexbox layout, so the model is familiar if you've used CSS flexbox or React Native.
721
+
722
+ ### Layout
723
+
724
+ | Property | Type | Description |
725
+ |----------|------|-------------|
726
+ | `width`, `height` | `number \| "${n}%"` | Dimensions |
727
+ | `minWidth`, `minHeight` | `number` | Minimum dimensions |
728
+ | `maxWidth`, `maxHeight` | `number` | Maximum dimensions |
729
+ | `padding` | `number` | Padding on all sides |
730
+ | `paddingX`, `paddingY` | `number` | Horizontal / vertical padding |
731
+ | `paddingTop`, `paddingRight`, `paddingBottom`, `paddingLeft` | `number` | Individual sides |
732
+ | `gap` | `number` | Gap between flex children |
733
+
734
+ ### Flexbox
735
+
736
+ | Property | Type | Default |
737
+ |----------|------|---------|
738
+ | `flexDirection` | `"row" \| "column"` | `"column"` |
739
+ | `flexWrap` | `"nowrap" \| "wrap"` | `"nowrap"` |
740
+ | `justifyContent` | `"flex-start" \| "center" \| "flex-end" \| "space-between" \| "space-around"` | `"flex-start"` |
741
+ | `alignItems` | `"flex-start" \| "center" \| "flex-end" \| "stretch"` | `"stretch"` |
742
+ | `flexGrow` | `number` | `0` |
743
+ | `flexShrink` | `number` | `0` |
744
+
745
+ ### Positioning
746
+
747
+ | Property | Type | Description |
748
+ |----------|------|-------------|
749
+ | `position` | `"relative" \| "absolute"` | Positioning mode |
750
+ | `top`, `right`, `bottom`, `left` | `number \| "${n}%"` | Offsets |
751
+ | `inset` | `number \| "${n}%"` | Shorthand for all four edges |
752
+ | `zIndex` | `number` | Stacking order |
753
+
754
+ ### Visual
755
+
756
+ | Property | Type | Description |
757
+ |----------|------|-------------|
758
+ | `bg` | `Color` | Background color |
759
+ | `border` | `"none" \| "single" \| "double" \| "round" \| "ascii"` | Border style |
760
+ | `borderColor` | `Color` | Border color |
761
+ | `clip` | `boolean` | Clip overflowing children |
762
+
763
+ ### Text
764
+
765
+ | Property | Type | Description |
766
+ |----------|------|-------------|
767
+ | `color` | `Color` | Text color |
768
+ | `bold` | `boolean` | Bold text |
769
+ | `dim` | `boolean` | Dimmed text |
770
+ | `italic` | `boolean` | Italic text |
771
+ | `underline` | `boolean` | Underlined text |
772
+ | `wrap` | `"wrap" \| "truncate" \| "ellipsis" \| "none"` | Text wrapping mode |
773
+ | `textAlign` | `"left" \| "center" \| "right"` | Text alignment |
774
+
775
+ ### Colors
776
+
777
+ Colors can be specified as:
778
+
779
+ - **Named:** `"red"`, `"green"`, `"blueBright"`, `"whiteBright"`, etc.
780
+ - **Hex:** `"#ff0000"`, `"#1a1a2e"`
781
+ - **RGB:** `{ r: 255, g: 0, b: 0 }`
782
+ - **256-palette:** `0`&ndash;`255`
783
+
784
+ Text on colored backgrounds automatically picks black or white for contrast when no explicit color is set.
785
+
786
+ ---
787
+
788
+ ## `render(element, options?)`
789
+
790
+ Mount a React element to the terminal.
791
+
792
+ ```tsx
793
+ const app = render(<App />, {
794
+ stdout: process.stdout,
795
+ stdin: process.stdin,
796
+ debug: false,
797
+ useNativeCursor: true, // Use terminal's native cursor (default: true)
798
+ });
799
+
800
+ app.unmount(); // Tear down
801
+ app.exit(); // Unmount and exit process
802
+ ```
803
+
804
+ ### Options
805
+
806
+ | Option | Type | Default | Description |
807
+ |--------|------|---------|-------------|
808
+ | `stdout` | `NodeJS.WriteStream` | `process.stdout` | Output stream |
809
+ | `stdin` | `NodeJS.ReadStream` | `process.stdin` | Input stream |
810
+ | `debug` | `boolean` | `false` | Enable debug logging |
811
+ | `useNativeCursor` | `boolean` | `true` | Use terminal's native cursor instead of simulated one |
812
+
813
+ ### Native Cursor
814
+
815
+ By default, Glyph uses the terminal's native cursor, which enables:
816
+
817
+ - **Cursor shaders** in terminals that support them (e.g., Ghostty)
818
+ - **Custom cursor shapes** (block, beam, underline) from terminal settings
819
+ - **Cursor animations** and blinking behavior
820
+
821
+ The native cursor is automatically shown when an input is focused and hidden otherwise.
822
+
823
+ To use the simulated cursor instead (inverted colors, no shader support):
824
+
825
+ ```tsx
826
+ render(<App />, { useNativeCursor: false });
827
+ ```
828
+
829
+ ---
830
+
831
+ ## Examples
832
+
833
+ Interactive examples are included in the repo. Each demonstrates different components and patterns:
834
+
835
+ | Example | Description | Source |
836
+ |---------|-------------|--------|
837
+ | **basic-layout** | Flexbox layout fundamentals | [View →](https://github.com/nick-skriabin/glyph/tree/main/examples/basic-layout) |
838
+ | **modal-input** | Modal dialogs, input focus trapping | [View →](https://github.com/nick-skriabin/glyph/tree/main/examples/modal-input) |
839
+ | **scrollview-demo** | Scrollable content with keyboard navigation | [View →](https://github.com/nick-skriabin/glyph/tree/main/examples/scrollview-demo) |
840
+ | **list-demo** | Keyboard-navigable lists | [View →](https://github.com/nick-skriabin/glyph/tree/main/examples/list-demo) |
841
+ | **menu-demo** | Styled menus with icons | [View →](https://github.com/nick-skriabin/glyph/tree/main/examples/menu-demo) |
842
+ | **select-demo** | Dropdown select with search | [View →](https://github.com/nick-skriabin/glyph/tree/main/examples/select-demo) |
843
+ | **forms-demo** | Checkbox and Radio inputs | [View →](https://github.com/nick-skriabin/glyph/tree/main/examples/forms-demo) |
844
+ | **masked-input** | Input masks (phone, credit card, SSN) | [View →](https://github.com/nick-skriabin/glyph/tree/main/examples/masked-input) |
845
+ | **dialog-demo** | Alert and Confirm dialogs | [View →](https://github.com/nick-skriabin/glyph/tree/main/examples/dialog-demo) |
846
+ | **jump-nav** | Quick navigation with keyboard hints | [View →](https://github.com/nick-skriabin/glyph/tree/main/examples/jump-nav) |
847
+ | **ansi-text** | ANSI escape codes and colored output | [View →](https://github.com/nick-skriabin/glyph/tree/main/examples/ansi-text) |
848
+ | **image** | Inline images and OS preview | [View →](https://github.com/nick-skriabin/glyph/tree/main/examples/image) |
849
+ | **virtualized-list** | Virtualized ScrollView with 10k+ items | [View →](https://github.com/nick-skriabin/glyph/tree/main/examples/virtualized-list) |
850
+ | **showcase** | Progress bars, Spinners, Toasts | [View →](https://github.com/nick-skriabin/glyph/tree/main/examples/showcase) |
851
+ | **dashboard** | Full task manager (all components) | [View →](https://github.com/nick-skriabin/glyph/tree/main/examples/dashboard) |
852
+
853
+ ### Running Examples Locally
854
+
855
+ ```bash
856
+ # Clone and install
857
+ git clone https://github.com/nick-skriabin/glyph.git && cd glyph
858
+ bun install && bun run build
859
+
860
+ # Run any example
861
+ bun run --filter <example-name> dev
862
+
863
+ # e.g.
864
+ bun run --filter dashboard dev
865
+ bun run --filter jump-nav dev
866
+ ```
867
+
868
+ ---
869
+
870
+ ## Who Uses Glyph
871
+
872
+ <table>
873
+ <tr>
874
+ <td align="center">
875
+ <a href="https://github.com/nick-skriabin/aion">
876
+ <strong>Aion</strong>
877
+ </a>
878
+ <br>
879
+ <sub>Calendar & time management TUI</sub>
880
+ </td>
881
+ </tr>
882
+ </table>
883
+
884
+ <sub>Using Glyph in your project? <a href="https://github.com/nick-skriabin/glyph/issues">Let us know!</a></sub>
885
+
886
+ ---
887
+
888
+ ## Architecture
889
+
890
+ ```
891
+ src/
892
+ ├── reconciler/ React reconciler (host config + GlyphNode tree)
893
+ ├── layout/ Yoga-based flexbox + text measurement
894
+ ├── paint/ Framebuffer, character diffing, borders, colors
895
+ ├── runtime/ Terminal raw mode, key parsing, OSC handling
896
+ ├── components/ Box, Text, Input, Button, ScrollView, List, Menu, ...
897
+ ├── hooks/ useInput, useFocus, useLayout, useApp
898
+ └── render.ts Entry point tying it all together
899
+ ```
900
+
901
+ **Render pipeline:** React reconciler builds a GlyphNode tree &rarr; Yoga computes flexbox layout &rarr; painter rasterizes to a framebuffer &rarr; diff engine writes only changed cells to stdout.
902
+
903
+ ---
904
+
905
+ ## License
906
+
907
+ MIT
908
+
909
+ ---
910
+
911
+ <p align="center">
912
+ <sub>Built with React &bull; Yoga &bull; a lot of ANSI escape codes</sub>
913
+ </p>