silvery 0.3.0 → 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.
Files changed (120) hide show
  1. package/README.md +41 -145
  2. package/dist/chalk.js +3 -0
  3. package/dist/chalk.js.map +11 -0
  4. package/dist/index.js +340 -0
  5. package/dist/index.js.map +282 -0
  6. package/dist/ink.js +129 -0
  7. package/dist/ink.js.map +140 -0
  8. package/dist/runtime.js +394 -0
  9. package/dist/runtime.js.map +286 -0
  10. package/dist/theme.js +343 -0
  11. package/dist/theme.js.map +286 -0
  12. package/dist/ui/animation.js +3 -0
  13. package/dist/ui/animation.js.map +15 -0
  14. package/dist/ui/ansi.js +3 -0
  15. package/dist/ui/ansi.js.map +10 -0
  16. package/dist/ui/cli.js +8 -0
  17. package/dist/ui/cli.js.map +14 -0
  18. package/dist/ui/display.js +4 -0
  19. package/dist/ui/display.js.map +10 -0
  20. package/dist/ui/image.js +4 -0
  21. package/dist/ui/image.js.map +15 -0
  22. package/dist/ui/input.js +3 -0
  23. package/dist/ui/input.js.map +11 -0
  24. package/dist/ui/progress.js +8 -0
  25. package/dist/ui/progress.js.map +20 -0
  26. package/dist/ui/react.js +3 -0
  27. package/dist/ui/react.js.map +15 -0
  28. package/dist/ui/utils.js +3 -0
  29. package/dist/ui/utils.js.map +10 -0
  30. package/dist/ui/wrappers.js +14 -0
  31. package/dist/ui/wrappers.js.map +19 -0
  32. package/dist/ui.js +17 -0
  33. package/dist/ui.js.map +20 -0
  34. package/package.json +67 -15
  35. package/src/index.ts +67 -1
  36. package/src/runtime.ts +4 -0
  37. package/src/theme.ts +4 -0
  38. package/src/ui/animation.ts +2 -0
  39. package/src/ui/ansi.ts +2 -0
  40. package/src/ui/cli.ts +2 -0
  41. package/src/ui/display.ts +2 -0
  42. package/src/ui/image.ts +2 -0
  43. package/src/ui/input.ts +2 -0
  44. package/src/ui/progress.ts +2 -0
  45. package/src/ui/react.ts +2 -0
  46. package/src/ui/utils.ts +2 -0
  47. package/src/ui/wrappers.ts +2 -0
  48. package/src/ui.ts +4 -0
  49. package/examples/CLAUDE.md +0 -75
  50. package/examples/_banner.tsx +0 -60
  51. package/examples/cli.ts +0 -228
  52. package/examples/index.md +0 -101
  53. package/examples/inline/inline-nontty.tsx +0 -98
  54. package/examples/inline/inline-progress.tsx +0 -79
  55. package/examples/inline/inline-simple.tsx +0 -63
  56. package/examples/inline/scrollback.tsx +0 -185
  57. package/examples/interactive/_input-debug.tsx +0 -110
  58. package/examples/interactive/_stdin-test.ts +0 -71
  59. package/examples/interactive/_textarea-bare.tsx +0 -45
  60. package/examples/interactive/aichat/components.tsx +0 -468
  61. package/examples/interactive/aichat/index.tsx +0 -207
  62. package/examples/interactive/aichat/script.ts +0 -460
  63. package/examples/interactive/aichat/state.ts +0 -326
  64. package/examples/interactive/aichat/types.ts +0 -19
  65. package/examples/interactive/app-todo.tsx +0 -198
  66. package/examples/interactive/async-data.tsx +0 -208
  67. package/examples/interactive/cli-wizard.tsx +0 -332
  68. package/examples/interactive/clipboard.tsx +0 -183
  69. package/examples/interactive/components.tsx +0 -463
  70. package/examples/interactive/data-explorer.tsx +0 -506
  71. package/examples/interactive/dev-tools.tsx +0 -379
  72. package/examples/interactive/explorer.tsx +0 -747
  73. package/examples/interactive/gallery.tsx +0 -652
  74. package/examples/interactive/inline-bench.tsx +0 -136
  75. package/examples/interactive/kanban.tsx +0 -267
  76. package/examples/interactive/layout-ref.tsx +0 -185
  77. package/examples/interactive/outline.tsx +0 -171
  78. package/examples/interactive/paste-demo.tsx +0 -198
  79. package/examples/interactive/scroll.tsx +0 -77
  80. package/examples/interactive/search-filter.tsx +0 -240
  81. package/examples/interactive/task-list.tsx +0 -279
  82. package/examples/interactive/terminal.tsx +0 -798
  83. package/examples/interactive/textarea.tsx +0 -103
  84. package/examples/interactive/theme.tsx +0 -336
  85. package/examples/interactive/transform.tsx +0 -256
  86. package/examples/interactive/virtual-10k.tsx +0 -413
  87. package/examples/kitty/canvas.tsx +0 -519
  88. package/examples/kitty/generate-samples.ts +0 -236
  89. package/examples/kitty/image-component.tsx +0 -273
  90. package/examples/kitty/images.tsx +0 -604
  91. package/examples/kitty/input.tsx +0 -371
  92. package/examples/kitty/keys.tsx +0 -378
  93. package/examples/kitty/paint.tsx +0 -1017
  94. package/examples/layout/dashboard.tsx +0 -551
  95. package/examples/layout/live-resize.tsx +0 -290
  96. package/examples/layout/overflow.tsx +0 -51
  97. package/examples/playground/README.md +0 -69
  98. package/examples/playground/build.ts +0 -61
  99. package/examples/playground/index.html +0 -420
  100. package/examples/playground/playground-app.tsx +0 -416
  101. package/examples/runtime/elm-counter.tsx +0 -206
  102. package/examples/runtime/hello-runtime.tsx +0 -73
  103. package/examples/runtime/pipe-composition.tsx +0 -184
  104. package/examples/runtime/run-counter.tsx +0 -78
  105. package/examples/runtime/runtime-counter.tsx +0 -197
  106. package/examples/screenshots/generate.tsx +0 -563
  107. package/examples/scrollback-perf.tsx +0 -230
  108. package/examples/viewer.tsx +0 -654
  109. package/examples/web/build.ts +0 -365
  110. package/examples/web/canvas-app.tsx +0 -80
  111. package/examples/web/canvas.html +0 -89
  112. package/examples/web/dom-app.tsx +0 -81
  113. package/examples/web/dom.html +0 -113
  114. package/examples/web/showcase-app.tsx +0 -107
  115. package/examples/web/showcase.html +0 -34
  116. package/examples/web/showcases/index.tsx +0 -56
  117. package/examples/web/viewer-app.tsx +0 -555
  118. package/examples/web/viewer.html +0 -30
  119. package/examples/web/xterm-app.tsx +0 -105
  120. package/examples/web/xterm.html +0 -118
@@ -1,604 +0,0 @@
1
- /**
2
- * Image Gallery (Kitty Graphics Protocol)
3
- *
4
- * Browse and display images in the terminal using the Kitty graphics protocol.
5
- * Pass one or more image paths, or a directory to browse all images within it.
6
- * With a single image, displays it directly. With multiple, shows a navigable gallery.
7
- * Without arguments, generates a colorful test pattern.
8
- *
9
- * Features:
10
- * - Kitty graphics protocol (inline image display)
11
- * - Multi-image gallery with navigation (n/p or arrows)
12
- * - PNG file display with base64 chunked transfer
13
- * - Built-in RGBA test pattern (rainbow + checkerboard)
14
- * - Pan with arrow keys, zoom with +/-, fit with f
15
- *
16
- * Run: bun vendor/silvery/examples/kitty/image-gallery.tsx [image.png ...]
17
- * bun vendor/silvery/examples/kitty/image-gallery.tsx ~/Pictures/
18
- */
19
-
20
- import { readFileSync, existsSync, readdirSync, statSync } from "node:fs"
21
- import { basename, resolve, extname, dirname } from "node:path"
22
- import { fileURLToPath } from "node:url"
23
- import { createTerm } from "../../src/index.js"
24
- import type { ExampleMeta } from "../_banner.js"
25
-
26
- export const meta: ExampleMeta = {
27
- name: "Image Viewer",
28
- description: "Browse and display images using Kitty graphics protocol",
29
- features: ["Kitty graphics", "PNG display", "gallery navigation", "zoom/pan", "true color"],
30
- }
31
-
32
- // ---------------------------------------------------------------------------
33
- // Kitty graphics helpers
34
- // ---------------------------------------------------------------------------
35
-
36
- const CHUNK_SIZE = 4096
37
-
38
- /** Image file extensions we recognize */
39
- const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".tiff", ".tif"])
40
-
41
- /** Build Kitty graphics escape sequences for a PNG image. */
42
- function kittyDisplayPng(pngData: Buffer, cols: number, rows: number): string {
43
- const b64 = pngData.toString("base64")
44
- const chunks: string[] = []
45
-
46
- for (let i = 0; i < b64.length; i += CHUNK_SIZE) {
47
- const chunk = b64.slice(i, i + CHUNK_SIZE)
48
- const isLast = i + CHUNK_SIZE >= b64.length
49
- const more = isLast ? 0 : 1
50
-
51
- if (i === 0) {
52
- chunks.push(`\x1b_Ga=T,f=100,t=d,c=${cols},r=${rows},m=${more};${chunk}\x1b\\`)
53
- } else {
54
- chunks.push(`\x1b_Gm=${more};${chunk}\x1b\\`)
55
- }
56
- }
57
-
58
- return chunks.join("")
59
- }
60
-
61
- /** Build Kitty graphics escape sequences for raw RGBA pixel data. */
62
- function kittyDisplayRgba(rgbaData: Buffer, srcWidth: number, srcHeight: number, cols: number, rows: number): string {
63
- const b64 = rgbaData.toString("base64")
64
- const chunks: string[] = []
65
-
66
- for (let i = 0; i < b64.length; i += CHUNK_SIZE) {
67
- const chunk = b64.slice(i, i + CHUNK_SIZE)
68
- const isLast = i + CHUNK_SIZE >= b64.length
69
- const more = isLast ? 0 : 1
70
-
71
- if (i === 0) {
72
- chunks.push(`\x1b_Ga=T,f=32,t=d,s=${srcWidth},v=${srcHeight},c=${cols},r=${rows},m=${more};${chunk}\x1b\\`)
73
- } else {
74
- chunks.push(`\x1b_Gm=${more};${chunk}\x1b\\`)
75
- }
76
- }
77
-
78
- return chunks.join("")
79
- }
80
-
81
- /** Delete all Kitty graphics images from the terminal. */
82
- function kittyDeleteAll(): string {
83
- return "\x1b_Ga=d;\x1b\\"
84
- }
85
-
86
- // ---------------------------------------------------------------------------
87
- // Test pattern generator
88
- // ---------------------------------------------------------------------------
89
-
90
- /** HSV to RGB conversion (h: 0-360, s/v: 0-1) */
91
- function hsvToRgb(h: number, s: number, v: number): [number, number, number] {
92
- const c = v * s
93
- const x = c * (1 - Math.abs(((h / 60) % 2) - 1))
94
- const m = v - c
95
-
96
- let r = 0,
97
- g = 0,
98
- b = 0
99
- if (h < 60) {
100
- r = c
101
- g = x
102
- } else if (h < 120) {
103
- r = x
104
- g = c
105
- } else if (h < 180) {
106
- g = c
107
- b = x
108
- } else if (h < 240) {
109
- g = x
110
- b = c
111
- } else if (h < 300) {
112
- r = x
113
- b = c
114
- } else {
115
- r = c
116
- b = x
117
- }
118
-
119
- return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)]
120
- }
121
-
122
- /** Generate a colorful test pattern as RGBA pixel data. */
123
- function generateTestPattern(width: number, height: number): Buffer {
124
- const buf = Buffer.alloc(width * height * 4)
125
- const checkerSize = 16
126
-
127
- for (let y = 0; y < height; y++) {
128
- for (let x = 0; x < width; x++) {
129
- const offset = (y * width + x) * 4
130
-
131
- // Top half: rainbow gradient
132
- if (y < height / 2) {
133
- const hue = (x / width) * 360
134
- const brightness = 0.3 + 0.7 * (1 - y / (height / 2))
135
- const [r, g, b] = hsvToRgb(hue, 1.0, brightness)
136
- buf[offset] = r
137
- buf[offset + 1] = g
138
- buf[offset + 2] = b
139
- buf[offset + 3] = 255
140
- } else {
141
- // Bottom half: checkerboard with color tint
142
- const cy = y - Math.floor(height / 2)
143
- const isLight = (Math.floor(x / checkerSize) + Math.floor(cy / checkerSize)) % 2 === 0
144
-
145
- const hue = (x / width) * 360
146
- const [hr, hg, hb] = hsvToRgb(hue, 0.4, 1.0)
147
-
148
- if (isLight) {
149
- buf[offset] = Math.min(255, hr + 40)
150
- buf[offset + 1] = Math.min(255, hg + 40)
151
- buf[offset + 2] = Math.min(255, hb + 40)
152
- } else {
153
- buf[offset] = Math.max(0, hr - 80)
154
- buf[offset + 1] = Math.max(0, hg - 80)
155
- buf[offset + 2] = Math.max(0, hb - 80)
156
- }
157
- buf[offset + 3] = 255
158
- }
159
- }
160
- }
161
-
162
- return buf
163
- }
164
-
165
- // ---------------------------------------------------------------------------
166
- // Image entry
167
- // ---------------------------------------------------------------------------
168
-
169
- interface ImageEntry {
170
- /** Display name */
171
- filename: string
172
- /** Full path (empty for test pattern) */
173
- path: string
174
- /** Source image width in pixels */
175
- imgWidth: number
176
- /** Source image height in pixels */
177
- imgHeight: number
178
- /** Whether the source is a PNG file (vs raw RGBA test pattern) */
179
- isPng: boolean
180
- /** The raw image data (PNG bytes or RGBA buffer) */
181
- data: Buffer
182
- }
183
-
184
- function loadImage(filePath: string): ImageEntry | null {
185
- if (!existsSync(filePath)) return null
186
- const data = Buffer.from(readFileSync(filePath))
187
- const { width, height } = readPngDimensions(data)
188
- return {
189
- filename: basename(filePath),
190
- path: filePath,
191
- imgWidth: width,
192
- imgHeight: height,
193
- isPng: true,
194
- data,
195
- }
196
- }
197
-
198
- function createTestPatternEntry(): ImageEntry {
199
- const patternW = 320
200
- const patternH = 240
201
- return {
202
- filename: "test pattern",
203
- path: "",
204
- imgWidth: patternW,
205
- imgHeight: patternH,
206
- isPng: false,
207
- data: generateTestPattern(patternW, patternH),
208
- }
209
- }
210
-
211
- /** Discover image files from CLI arguments (files and/or directories). */
212
- function discoverImages(args: string[]): ImageEntry[] {
213
- const entries: ImageEntry[] = []
214
- const seen = new Set<string>()
215
-
216
- for (const arg of args) {
217
- if (!existsSync(arg)) continue
218
-
219
- const stat = statSync(arg)
220
- if (stat.isDirectory()) {
221
- // Scan directory for image files
222
- const files = readdirSync(arg)
223
- .filter((f) => IMAGE_EXTENSIONS.has(extname(f).toLowerCase()))
224
- .sort()
225
- for (const file of files) {
226
- const fullPath = resolve(arg, file)
227
- if (seen.has(fullPath)) continue
228
- seen.add(fullPath)
229
- const entry = loadImage(fullPath)
230
- if (entry) entries.push(entry)
231
- }
232
- } else {
233
- const fullPath = resolve(arg)
234
- if (seen.has(fullPath)) continue
235
- seen.add(fullPath)
236
- const entry = loadImage(fullPath)
237
- if (entry) entries.push(entry)
238
- }
239
- }
240
-
241
- return entries
242
- }
243
-
244
- // ---------------------------------------------------------------------------
245
- // Viewer state
246
- // ---------------------------------------------------------------------------
247
-
248
- interface ViewerState {
249
- /** All images in the gallery */
250
- images: ImageEntry[]
251
- /** Current image index */
252
- currentIndex: number
253
- /** Current zoom level (1.0 = fit to terminal) */
254
- zoom: number
255
- /** Pan offset in columns */
256
- panX: number
257
- /** Pan offset in rows */
258
- panY: number
259
- /** Terminal columns */
260
- termCols: number
261
- /** Terminal rows (minus status bar) */
262
- termRows: number
263
- }
264
-
265
- const ZOOM_STEP = 0.25
266
- const PAN_STEP = 2
267
-
268
- function currentImage(state: ViewerState): ImageEntry {
269
- return state.images[state.currentIndex]!
270
- }
271
-
272
- function displayCols(state: ViewerState): number {
273
- return Math.max(1, Math.round(state.termCols * state.zoom))
274
- }
275
-
276
- function displayRows(state: ViewerState): number {
277
- return Math.max(1, Math.round(state.termRows * state.zoom))
278
- }
279
-
280
- function clampPan(state: ViewerState): void {
281
- const dCols = displayCols(state)
282
- const dRows = displayRows(state)
283
-
284
- // Pan is how much the image extends beyond the viewport
285
- const maxPanX = Math.max(0, dCols - state.termCols)
286
- const maxPanY = Math.max(0, dRows - state.termRows)
287
-
288
- state.panX = Math.max(0, Math.min(state.panX, maxPanX))
289
- state.panY = Math.max(0, Math.min(state.panY, maxPanY))
290
- }
291
-
292
- // ---------------------------------------------------------------------------
293
- // Rendering
294
- // ---------------------------------------------------------------------------
295
-
296
- function renderStatusBar(state: ViewerState, term: ReturnType<typeof createTerm>): string {
297
- const img = currentImage(state)
298
- const zoomPct = Math.round(state.zoom * 100)
299
- const dCols = displayCols(state)
300
- const dRows = displayRows(state)
301
-
302
- const galleryInfo =
303
- state.images.length > 1
304
- ? ` ${term.dim("Image:")} ${term.bold(`${state.currentIndex + 1}/${state.images.length}`)}`
305
- : ""
306
-
307
- const navHelp = state.images.length > 1 ? "n/p navigate " : ""
308
-
309
- return (
310
- term.dim.yellow(" silvery") +
311
- " " +
312
- term.bold("Image Gallery") +
313
- galleryInfo +
314
- " " +
315
- term.dim("File:") +
316
- " " +
317
- term.bold(img.filename) +
318
- " " +
319
- term.dim("Size:") +
320
- " " +
321
- `${img.imgWidth}x${img.imgHeight}` +
322
- " " +
323
- term.dim("Display:") +
324
- " " +
325
- `${dCols}x${dRows}` +
326
- " " +
327
- term.dim("Zoom:") +
328
- " " +
329
- term.bold(`${zoomPct}%`) +
330
- " " +
331
- term.dim("Pan:") +
332
- " " +
333
- `${state.panX},${state.panY}` +
334
- " " +
335
- term.dim(`${navHelp}arrows pan +/- zoom f fit q quit`)
336
- )
337
- }
338
-
339
- function renderImage(state: ViewerState): string {
340
- const img = currentImage(state)
341
- const dCols = displayCols(state)
342
- const dRows = displayRows(state)
343
-
344
- const parts: string[] = []
345
-
346
- // Move cursor to image start position (row 1, accounting for pan)
347
- const startRow = 2 // row 1 is status bar (1-indexed)
348
- parts.push(`\x1b[${startRow};1H`)
349
-
350
- // Clear the image area
351
- for (let r = 0; r < state.termRows; r++) {
352
- parts.push(`\x1b[${startRow + r};1H\x1b[2K`)
353
- }
354
- parts.push(`\x1b[${startRow};1H`)
355
-
356
- // Delete previous images
357
- parts.push(kittyDeleteAll())
358
-
359
- // Display the image
360
- if (img.isPng) {
361
- parts.push(kittyDisplayPng(img.data, dCols, dRows))
362
- } else {
363
- parts.push(kittyDisplayRgba(img.data, img.imgWidth, img.imgHeight, dCols, dRows))
364
- }
365
-
366
- return parts.join("")
367
- }
368
-
369
- // ---------------------------------------------------------------------------
370
- // Main
371
- // ---------------------------------------------------------------------------
372
-
373
- async function main() {
374
- const crashCleanup = () => {
375
- const stdout = process.stdout
376
- stdout.write("\x1b[?1003l\x1b[?1006l") // Disable mouse
377
- stdout.write("\x1b[?25h") // Show cursor
378
- stdout.write("\x1b[?1049l") // Exit alternate screen
379
- stdout.write("\x1b[0m") // Reset colors
380
- if (process.stdin.isTTY && process.stdin.isRaw) {
381
- try {
382
- process.stdin.setRawMode(false)
383
- } catch {}
384
- }
385
- }
386
- process.on("uncaughtException", (err) => {
387
- crashCleanup()
388
- throw err
389
- })
390
-
391
- using term = createTerm()
392
- const cols = term.cols ?? 80
393
- const rows = term.rows ?? 24
394
-
395
- const { stdin, stdout } = process
396
-
397
- // Gather images from CLI arguments
398
- const args = process.argv.slice(2)
399
- let images: ImageEntry[]
400
-
401
- if (args.length === 0) {
402
- // No arguments: try samples/ directory first, fall back to test pattern
403
- const samplesDir = resolve(dirname(fileURLToPath(import.meta.url)), "samples")
404
- if (existsSync(samplesDir)) {
405
- images = discoverImages([samplesDir])
406
- }
407
- if (!images || images.length === 0) {
408
- images = [createTestPatternEntry()]
409
- }
410
- } else {
411
- images = discoverImages(args)
412
- if (images.length === 0) {
413
- // No valid images found — show test pattern with a note
414
- const entry = createTestPatternEntry()
415
- entry.filename = `${args.join(", ")} (not found, showing test pattern)`
416
- images = [entry]
417
- }
418
- }
419
-
420
- const state: ViewerState = {
421
- images,
422
- currentIndex: 0,
423
- zoom: 1.0,
424
- panX: 0,
425
- panY: 0,
426
- termCols: cols,
427
- termRows: rows - 1, // reserve 1 row for status bar
428
- }
429
-
430
- // Enter alternate screen, hide cursor
431
- stdout.write("\x1b[?1049h")
432
- stdout.write("\x1b[?25l")
433
- stdout.write("\x1b[2J\x1b[H")
434
-
435
- // Enable raw mode
436
- if (stdin.isTTY) {
437
- stdin.setRawMode(true)
438
- }
439
- stdin.resume()
440
-
441
- const redraw = () => {
442
- // Status bar at row 1
443
- stdout.write("\x1b[1;1H\x1b[2K")
444
- stdout.write(renderStatusBar(state, term))
445
- // Image below
446
- stdout.write(renderImage(state))
447
- }
448
-
449
- /** Navigate to a different image, resetting zoom/pan */
450
- const goToImage = (index: number) => {
451
- if (index < 0 || index >= state.images.length) return
452
- state.currentIndex = index
453
- state.zoom = 1.0
454
- state.panX = 0
455
- state.panY = 0
456
- redraw()
457
- }
458
-
459
- redraw()
460
-
461
- const cleanup = () => {
462
- // Delete images
463
- stdout.write(kittyDeleteAll())
464
- // Show cursor, leave alternate screen
465
- stdout.write("\x1b[?25h")
466
- stdout.write("\x1b[?1049l")
467
- if (stdin.isTTY) {
468
- stdin.setRawMode(false)
469
- }
470
- stdin.off("data", onData)
471
- stdin.pause()
472
- process.exit(0)
473
- }
474
-
475
- const onData = (data: Buffer) => {
476
- const raw = data.toString()
477
-
478
- // Handle arrow keys (escape sequences) before single-character processing
479
- if (raw === "\x1b[A" || raw === "\x1bOA") {
480
- state.panY = Math.max(0, state.panY - PAN_STEP)
481
- redraw()
482
- return
483
- } else if (raw === "\x1b[B" || raw === "\x1bOB") {
484
- state.panY += PAN_STEP
485
- clampPan(state)
486
- redraw()
487
- return
488
- } else if (raw === "\x1b[D" || raw === "\x1bOD") {
489
- state.panX = Math.max(0, state.panX - PAN_STEP)
490
- redraw()
491
- return
492
- } else if (raw === "\x1b[C" || raw === "\x1bOC") {
493
- state.panX += PAN_STEP
494
- clampPan(state)
495
- redraw()
496
- return
497
- }
498
-
499
- // Single-character keyboard input
500
- for (const ch of raw) {
501
- switch (ch) {
502
- case "q":
503
- case "\x1b":
504
- cleanup()
505
- return
506
-
507
- // Gallery navigation
508
- case "n":
509
- case "l":
510
- goToImage((state.currentIndex + 1) % state.images.length)
511
- break
512
- case "p":
513
- case "h":
514
- goToImage((state.currentIndex - 1 + state.images.length) % state.images.length)
515
- break
516
-
517
- case "+":
518
- case "=":
519
- state.zoom = Math.min(10.0, state.zoom + ZOOM_STEP)
520
- clampPan(state)
521
- redraw()
522
- break
523
-
524
- case "-":
525
- case "_":
526
- state.zoom = Math.max(ZOOM_STEP, state.zoom - ZOOM_STEP)
527
- clampPan(state)
528
- redraw()
529
- break
530
-
531
- case "f":
532
- state.zoom = 1.0
533
- state.panX = 0
534
- state.panY = 0
535
- redraw()
536
- break
537
-
538
- default:
539
- break
540
- }
541
- }
542
- }
543
-
544
- stdin.on("data", onData)
545
-
546
- // Handle terminal resize
547
- stdout.on("resize", () => {
548
- state.termCols = stdout.columns ?? cols
549
- state.termRows = (stdout.rows ?? rows) - 1
550
- clampPan(state)
551
- stdout.write("\x1b[2J\x1b[H")
552
- redraw()
553
- })
554
- }
555
-
556
- // ---------------------------------------------------------------------------
557
- // PNG dimension reader
558
- // ---------------------------------------------------------------------------
559
-
560
- /** Read width and height from a PNG file's IHDR chunk. */
561
- function readPngDimensions(data: Buffer): { width: number; height: number } {
562
- // PNG signature is 8 bytes, then first chunk is IHDR
563
- // IHDR starts at offset 8: 4 bytes length, 4 bytes "IHDR", then:
564
- // 4 bytes width (big-endian)
565
- // 4 bytes height (big-endian)
566
- if (data.length < 24) {
567
- return { width: 0, height: 0 }
568
- }
569
-
570
- // Verify PNG signature
571
- const sig = data.slice(0, 8)
572
- const pngSig = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
573
- if (!sig.equals(pngSig)) {
574
- return { width: 0, height: 0 }
575
- }
576
-
577
- // IHDR chunk type at offset 12-15 should be "IHDR"
578
- const chunkType = data.slice(12, 16).toString("ascii")
579
- if (chunkType !== "IHDR") {
580
- return { width: 0, height: 0 }
581
- }
582
-
583
- const width = data.readUInt32BE(16)
584
- const height = data.readUInt32BE(20)
585
- return { width, height }
586
- }
587
-
588
- if (import.meta.main) {
589
- main().catch((err) => {
590
- // Restore terminal on crash
591
- const stdout = process.stdout
592
- stdout.write("\x1b[?1003l\x1b[?1006l") // Disable mouse
593
- stdout.write("\x1b[?25h") // Show cursor
594
- stdout.write("\x1b[?1049l") // Exit alternate screen
595
- stdout.write("\x1b[0m") // Reset colors
596
- if (process.stdin.isTTY && process.stdin.isRaw) {
597
- try {
598
- process.stdin.setRawMode(false)
599
- } catch {}
600
- }
601
- console.error(err)
602
- process.exit(1)
603
- })
604
- }