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.
- package/README.md +41 -145
- package/dist/chalk.js +3 -0
- package/dist/chalk.js.map +11 -0
- package/dist/index.js +340 -0
- package/dist/index.js.map +282 -0
- package/dist/ink.js +129 -0
- package/dist/ink.js.map +140 -0
- package/dist/runtime.js +394 -0
- package/dist/runtime.js.map +286 -0
- package/dist/theme.js +343 -0
- package/dist/theme.js.map +286 -0
- package/dist/ui/animation.js +3 -0
- package/dist/ui/animation.js.map +15 -0
- package/dist/ui/ansi.js +3 -0
- package/dist/ui/ansi.js.map +10 -0
- package/dist/ui/cli.js +8 -0
- package/dist/ui/cli.js.map +14 -0
- package/dist/ui/display.js +4 -0
- package/dist/ui/display.js.map +10 -0
- package/dist/ui/image.js +4 -0
- package/dist/ui/image.js.map +15 -0
- package/dist/ui/input.js +3 -0
- package/dist/ui/input.js.map +11 -0
- package/dist/ui/progress.js +8 -0
- package/dist/ui/progress.js.map +20 -0
- package/dist/ui/react.js +3 -0
- package/dist/ui/react.js.map +15 -0
- package/dist/ui/utils.js +3 -0
- package/dist/ui/utils.js.map +10 -0
- package/dist/ui/wrappers.js +14 -0
- package/dist/ui/wrappers.js.map +19 -0
- package/dist/ui.js +17 -0
- package/dist/ui.js.map +20 -0
- package/package.json +67 -15
- package/src/index.ts +67 -1
- package/src/runtime.ts +4 -0
- package/src/theme.ts +4 -0
- package/src/ui/animation.ts +2 -0
- package/src/ui/ansi.ts +2 -0
- package/src/ui/cli.ts +2 -0
- package/src/ui/display.ts +2 -0
- package/src/ui/image.ts +2 -0
- package/src/ui/input.ts +2 -0
- package/src/ui/progress.ts +2 -0
- package/src/ui/react.ts +2 -0
- package/src/ui/utils.ts +2 -0
- package/src/ui/wrappers.ts +2 -0
- package/src/ui.ts +4 -0
- package/examples/CLAUDE.md +0 -75
- package/examples/_banner.tsx +0 -60
- package/examples/cli.ts +0 -228
- package/examples/index.md +0 -101
- package/examples/inline/inline-nontty.tsx +0 -98
- package/examples/inline/inline-progress.tsx +0 -79
- package/examples/inline/inline-simple.tsx +0 -63
- package/examples/inline/scrollback.tsx +0 -185
- package/examples/interactive/_input-debug.tsx +0 -110
- package/examples/interactive/_stdin-test.ts +0 -71
- package/examples/interactive/_textarea-bare.tsx +0 -45
- package/examples/interactive/aichat/components.tsx +0 -468
- package/examples/interactive/aichat/index.tsx +0 -207
- package/examples/interactive/aichat/script.ts +0 -460
- package/examples/interactive/aichat/state.ts +0 -326
- package/examples/interactive/aichat/types.ts +0 -19
- package/examples/interactive/app-todo.tsx +0 -198
- package/examples/interactive/async-data.tsx +0 -208
- package/examples/interactive/cli-wizard.tsx +0 -332
- package/examples/interactive/clipboard.tsx +0 -183
- package/examples/interactive/components.tsx +0 -463
- package/examples/interactive/data-explorer.tsx +0 -506
- package/examples/interactive/dev-tools.tsx +0 -379
- package/examples/interactive/explorer.tsx +0 -747
- package/examples/interactive/gallery.tsx +0 -652
- package/examples/interactive/inline-bench.tsx +0 -136
- package/examples/interactive/kanban.tsx +0 -267
- package/examples/interactive/layout-ref.tsx +0 -185
- package/examples/interactive/outline.tsx +0 -171
- package/examples/interactive/paste-demo.tsx +0 -198
- package/examples/interactive/scroll.tsx +0 -77
- package/examples/interactive/search-filter.tsx +0 -240
- package/examples/interactive/task-list.tsx +0 -279
- package/examples/interactive/terminal.tsx +0 -798
- package/examples/interactive/textarea.tsx +0 -103
- package/examples/interactive/theme.tsx +0 -336
- package/examples/interactive/transform.tsx +0 -256
- package/examples/interactive/virtual-10k.tsx +0 -413
- package/examples/kitty/canvas.tsx +0 -519
- package/examples/kitty/generate-samples.ts +0 -236
- package/examples/kitty/image-component.tsx +0 -273
- package/examples/kitty/images.tsx +0 -604
- package/examples/kitty/input.tsx +0 -371
- package/examples/kitty/keys.tsx +0 -378
- package/examples/kitty/paint.tsx +0 -1017
- package/examples/layout/dashboard.tsx +0 -551
- package/examples/layout/live-resize.tsx +0 -290
- package/examples/layout/overflow.tsx +0 -51
- package/examples/playground/README.md +0 -69
- package/examples/playground/build.ts +0 -61
- package/examples/playground/index.html +0 -420
- package/examples/playground/playground-app.tsx +0 -416
- package/examples/runtime/elm-counter.tsx +0 -206
- package/examples/runtime/hello-runtime.tsx +0 -73
- package/examples/runtime/pipe-composition.tsx +0 -184
- package/examples/runtime/run-counter.tsx +0 -78
- package/examples/runtime/runtime-counter.tsx +0 -197
- package/examples/screenshots/generate.tsx +0 -563
- package/examples/scrollback-perf.tsx +0 -230
- package/examples/viewer.tsx +0 -654
- package/examples/web/build.ts +0 -365
- package/examples/web/canvas-app.tsx +0 -80
- package/examples/web/canvas.html +0 -89
- package/examples/web/dom-app.tsx +0 -81
- package/examples/web/dom.html +0 -113
- package/examples/web/showcase-app.tsx +0 -107
- package/examples/web/showcase.html +0 -34
- package/examples/web/showcases/index.tsx +0 -56
- package/examples/web/viewer-app.tsx +0 -555
- package/examples/web/viewer.html +0 -30
- package/examples/web/xterm-app.tsx +0 -105
- 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
|
-
}
|