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,1017 +0,0 @@
1
- /**
2
- * Paint — Draw Over Images
3
- *
4
- * The flagship demo combining Kitty graphics protocol image display with
5
- * half-block pixel art drawing. Load a PNG, view it via Kitty graphics,
6
- * and paint over it with a transparent overlay layer.
7
- *
8
- * Features:
9
- * - Load PNG files or generate a test pattern
10
- * - Kitty graphics protocol for base image display
11
- * - Transparent half-block drawing overlay on top
12
- * - Pencil (click-drag) and eraser tools
13
- * - HSL color picker with hue bar + saturation/lightness bar
14
- * - View mode (image only) and draw mode (image + overlay)
15
- * - Zoom with +/-, fit with f
16
- * - Brush size via scroll wheel
17
- * - Clear overlay, toggle modes via keyboard
18
- *
19
- * Run: bun vendor/silvery/examples/kitty/paint.tsx [image.png]
20
- */
21
-
22
- import { readFileSync, existsSync, readdirSync } from "node:fs"
23
- import { basename, resolve, dirname, extname } from "node:path"
24
- import { fileURLToPath } from "node:url"
25
- import { createTerm, enableMouse, disableMouse, parseMouseSequence, isMouseSequence } from "../../src/index.js"
26
- import type { ExampleMeta } from "../_banner.js"
27
-
28
- export const meta: ExampleMeta = {
29
- name: "Photo Paint",
30
- description: "Draw over images — Kitty graphics + half-block pixel art overlay",
31
- features: [
32
- "Kitty graphics",
33
- "half-block overlay",
34
- "parseMouseSequence()",
35
- "enableMouse()",
36
- "HSL color picker",
37
- "brush size",
38
- "zoom/pan",
39
- ],
40
- }
41
-
42
- // ---------------------------------------------------------------------------
43
- // Half-block characters for 2x vertical resolution
44
- // ---------------------------------------------------------------------------
45
-
46
- const UPPER_HALF = "\u2580" // ▀ — top filled, bottom empty
47
- const LOWER_HALF = "\u2584" // ▄ — top empty, bottom filled
48
- const FULL_BLOCK = "\u2588" // █ — both filled
49
-
50
- type RGB = [number, number, number]
51
-
52
- // ---------------------------------------------------------------------------
53
- // HSL <-> RGB conversion
54
- // ---------------------------------------------------------------------------
55
-
56
- function hslToRgb(h: number, s: number, l: number): RGB {
57
- const c = (1 - Math.abs(2 * l - 1)) * s
58
- const x = c * (1 - Math.abs(((h / 60) % 2) - 1))
59
- const m = l - c / 2
60
-
61
- let r1: number, g1: number, b1: number
62
- if (h < 60) {
63
- ;[r1, g1, b1] = [c, x, 0]
64
- } else if (h < 120) {
65
- ;[r1, g1, b1] = [x, c, 0]
66
- } else if (h < 180) {
67
- ;[r1, g1, b1] = [0, c, x]
68
- } else if (h < 240) {
69
- ;[r1, g1, b1] = [0, x, c]
70
- } else if (h < 300) {
71
- ;[r1, g1, b1] = [x, 0, c]
72
- } else {
73
- ;[r1, g1, b1] = [c, 0, x]
74
- }
75
-
76
- return [Math.round((r1 + m) * 255), Math.round((g1 + m) * 255), Math.round((b1 + m) * 255)]
77
- }
78
-
79
- function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
80
- r /= 255
81
- g /= 255
82
- b /= 255
83
- const max = Math.max(r, g, b)
84
- const min = Math.min(r, g, b)
85
- const l = (max + min) / 2
86
- if (max === min) return [0, 0, l]
87
- const d = max - min
88
- const s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
89
- let h: number
90
- if (max === r) {
91
- h = ((g - b) / d + (g < b ? 6 : 0)) * 60
92
- } else if (max === g) {
93
- h = ((b - r) / d + 2) * 60
94
- } else {
95
- h = ((r - g) / d + 4) * 60
96
- }
97
- return [h, s, l]
98
- }
99
-
100
- // ---------------------------------------------------------------------------
101
- // HSV to RGB (for test pattern)
102
- // ---------------------------------------------------------------------------
103
-
104
- function hsvToRgb(h: number, s: number, v: number): RGB {
105
- const c = v * s
106
- const x = c * (1 - Math.abs(((h / 60) % 2) - 1))
107
- const m = v - c
108
-
109
- let r = 0,
110
- g = 0,
111
- b = 0
112
- if (h < 60) {
113
- r = c
114
- g = x
115
- } else if (h < 120) {
116
- r = x
117
- g = c
118
- } else if (h < 180) {
119
- g = c
120
- b = x
121
- } else if (h < 240) {
122
- g = x
123
- b = c
124
- } else if (h < 300) {
125
- r = x
126
- b = c
127
- } else {
128
- r = c
129
- b = x
130
- }
131
-
132
- return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)]
133
- }
134
-
135
- // ---------------------------------------------------------------------------
136
- // Kitty graphics helpers
137
- // ---------------------------------------------------------------------------
138
-
139
- const CHUNK_SIZE = 4096
140
-
141
- /** Build Kitty graphics escape sequences for a PNG image. */
142
- function kittyDisplayPng(pngData: Buffer, cols: number, rows: number, id: number = 1): string {
143
- const b64 = pngData.toString("base64")
144
- const chunks: string[] = []
145
-
146
- for (let i = 0; i < b64.length; i += CHUNK_SIZE) {
147
- const chunk = b64.slice(i, i + CHUNK_SIZE)
148
- const isLast = i + CHUNK_SIZE >= b64.length
149
- const more = isLast ? 0 : 1
150
-
151
- if (i === 0) {
152
- chunks.push(`\x1b_Ga=T,f=100,t=d,i=${id},c=${cols},r=${rows},m=${more};${chunk}\x1b\\`)
153
- } else {
154
- chunks.push(`\x1b_Gm=${more};${chunk}\x1b\\`)
155
- }
156
- }
157
-
158
- return chunks.join("")
159
- }
160
-
161
- /** Build Kitty graphics escape sequences for raw RGBA pixel data. */
162
- function kittyDisplayRgba(
163
- rgbaData: Buffer,
164
- srcWidth: number,
165
- srcHeight: number,
166
- cols: number,
167
- rows: number,
168
- id: number = 1,
169
- ): string {
170
- const b64 = rgbaData.toString("base64")
171
- const chunks: string[] = []
172
-
173
- for (let i = 0; i < b64.length; i += CHUNK_SIZE) {
174
- const chunk = b64.slice(i, i + CHUNK_SIZE)
175
- const isLast = i + CHUNK_SIZE >= b64.length
176
- const more = isLast ? 0 : 1
177
-
178
- if (i === 0) {
179
- chunks.push(
180
- `\x1b_Ga=T,f=32,t=d,i=${id},s=${srcWidth},v=${srcHeight},c=${cols},r=${rows},m=${more};${chunk}\x1b\\`,
181
- )
182
- } else {
183
- chunks.push(`\x1b_Gm=${more};${chunk}\x1b\\`)
184
- }
185
- }
186
-
187
- return chunks.join("")
188
- }
189
-
190
- /** Delete all Kitty graphics images from the terminal. */
191
- function kittyDeleteAll(): string {
192
- return "\x1b_Ga=d;\x1b\\"
193
- }
194
-
195
- // ---------------------------------------------------------------------------
196
- // Test pattern generator
197
- // ---------------------------------------------------------------------------
198
-
199
- /** Generate a colorful test pattern as RGBA pixel data. */
200
- function generateTestPattern(width: number, height: number): Buffer {
201
- const buf = Buffer.alloc(width * height * 4)
202
- const checkerSize = 16
203
-
204
- for (let y = 0; y < height; y++) {
205
- for (let x = 0; x < width; x++) {
206
- const offset = (y * width + x) * 4
207
-
208
- if (y < height / 2) {
209
- // Top half: rainbow gradient
210
- const hue = (x / width) * 360
211
- const brightness = 0.3 + 0.7 * (1 - y / (height / 2))
212
- const [r, g, b] = hsvToRgb(hue, 1.0, brightness)
213
- buf[offset] = r
214
- buf[offset + 1] = g
215
- buf[offset + 2] = b
216
- buf[offset + 3] = 255
217
- } else {
218
- // Bottom half: checkerboard with color tint
219
- const cy = y - Math.floor(height / 2)
220
- const isLight = (Math.floor(x / checkerSize) + Math.floor(cy / checkerSize)) % 2 === 0
221
- const hue = (x / width) * 360
222
- const [hr, hg, hb] = hsvToRgb(hue, 0.4, 1.0)
223
-
224
- if (isLight) {
225
- buf[offset] = Math.min(255, hr + 40)
226
- buf[offset + 1] = Math.min(255, hg + 40)
227
- buf[offset + 2] = Math.min(255, hb + 40)
228
- } else {
229
- buf[offset] = Math.max(0, hr - 80)
230
- buf[offset + 1] = Math.max(0, hg - 80)
231
- buf[offset + 2] = Math.max(0, hb - 80)
232
- }
233
- buf[offset + 3] = 255
234
- }
235
- }
236
- }
237
-
238
- return buf
239
- }
240
-
241
- // ---------------------------------------------------------------------------
242
- // PNG dimension reader
243
- // ---------------------------------------------------------------------------
244
-
245
- function readPngDimensions(data: Buffer): { width: number; height: number } {
246
- if (data.length < 24) return { width: 0, height: 0 }
247
-
248
- const sig = data.slice(0, 8)
249
- const pngSig = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
250
- if (!sig.equals(pngSig)) return { width: 0, height: 0 }
251
-
252
- const chunkType = data.slice(12, 16).toString("ascii")
253
- if (chunkType !== "IHDR") return { width: 0, height: 0 }
254
-
255
- const width = data.readUInt32BE(16)
256
- const height = data.readUInt32BE(20)
257
- return { width, height }
258
- }
259
-
260
- // ---------------------------------------------------------------------------
261
- // Preset colors
262
- // ---------------------------------------------------------------------------
263
-
264
- const PRESETS: { name: string; color: RGB }[] = [
265
- { name: "white", color: [255, 255, 255] },
266
- { name: "red", color: [255, 0, 0] },
267
- { name: "orange", color: [255, 165, 0] },
268
- { name: "yellow", color: [255, 255, 0] },
269
- { name: "green", color: [0, 255, 0] },
270
- { name: "cyan", color: [0, 255, 255] },
271
- { name: "blue", color: [0, 100, 255] },
272
- { name: "magenta", color: [255, 0, 255] },
273
- { name: "pink", color: [255, 128, 200] },
274
- { name: "black", color: [0, 0, 0] },
275
- ]
276
-
277
- // ---------------------------------------------------------------------------
278
- // Application state
279
- // ---------------------------------------------------------------------------
280
-
281
- type Mode = "view" | "draw"
282
- type Tool = "pen" | "eraser"
283
-
284
- interface PhotoCanvasState {
285
- // -- Image --
286
- filename: string
287
- imgWidth: number
288
- imgHeight: number
289
- isPng: boolean
290
- imageData: Buffer
291
-
292
- // -- Viewport --
293
- zoom: number
294
- panX: number
295
- panY: number
296
- termCols: number
297
- /** Terminal rows available for the image area (excluding header + UI bars) */
298
- imageRows: number
299
-
300
- // -- Mode --
301
- mode: Mode
302
-
303
- // -- Drawing overlay --
304
- /** 2D array of overlay pixel colors (null = transparent). Dimensions: overlayHeight x overlayWidth */
305
- overlay: (RGB | null)[][]
306
- overlayWidth: number
307
- /** Pixel rows (2x terminal rows for the image area) */
308
- overlayHeight: number
309
-
310
- // -- Color / Tool --
311
- currentColor: RGB
312
- hue: number
313
- saturation: number
314
- lightness: number
315
- tool: Tool
316
- brushSize: number
317
-
318
- // -- Mouse --
319
- mouseX: number
320
- mouseY: number
321
- isDrawing: boolean
322
- }
323
-
324
- // Reserve rows: 1 header, 1 hue bar, 1 saturation bar, 1 status
325
- const RESERVED_ROWS = 4
326
- const ZOOM_STEP = 0.25
327
- const MIN_BRUSH = 1
328
- const MAX_BRUSH = 8
329
-
330
- function createState(
331
- cols: number,
332
- rows: number,
333
- imageData: Buffer,
334
- filename: string,
335
- imgWidth: number,
336
- imgHeight: number,
337
- isPng: boolean,
338
- ): PhotoCanvasState {
339
- const imageRows = rows - RESERVED_ROWS
340
- const overlayWidth = cols
341
- const overlayHeight = imageRows * 2
342
-
343
- const overlay: (RGB | null)[][] = []
344
- for (let y = 0; y < overlayHeight; y++) {
345
- overlay.push(new Array(overlayWidth).fill(null))
346
- }
347
-
348
- return {
349
- filename,
350
- imgWidth,
351
- imgHeight,
352
- isPng,
353
- imageData,
354
- zoom: 1.0,
355
- panX: 0,
356
- panY: 0,
357
- termCols: cols,
358
- imageRows,
359
- mode: "draw",
360
- overlay,
361
- overlayWidth,
362
- overlayHeight,
363
- currentColor: [255, 0, 0],
364
- hue: 0,
365
- saturation: 1.0,
366
- lightness: 0.5,
367
- tool: "pen",
368
- brushSize: 1,
369
- mouseX: 0,
370
- mouseY: 0,
371
- isDrawing: false,
372
- }
373
- }
374
-
375
- // ---------------------------------------------------------------------------
376
- // Drawing
377
- // ---------------------------------------------------------------------------
378
-
379
- /** Paint onto the overlay at the given terminal position with the current brush. */
380
- function paintOverlay(state: PhotoCanvasState, termX: number, termY: number): void {
381
- // termY is relative to full terminal; row 0 = header, image starts at row 1
382
- const canvasTermRow = termY - 1
383
- if (canvasTermRow < 0 || canvasTermRow >= state.imageRows) return
384
-
385
- const centerPixelY = canvasTermRow * 2
386
- const centerX = termX
387
- const radius = state.brushSize - 1
388
- const value: RGB | null = state.tool === "pen" ? [...state.currentColor] : null
389
-
390
- // Paint a circle of pixels around the center
391
- for (let dy = -radius; dy <= radius; dy++) {
392
- for (let dx = -radius; dx <= radius; dx++) {
393
- // Circular brush: skip corners
394
- if (dx * dx + dy * dy > (radius + 0.5) * (radius + 0.5)) continue
395
-
396
- const px = centerX + dx
397
- // Each terminal cell covers 2 pixel rows; paint both sub-pixels
398
- for (let subY = 0; subY <= 1; subY++) {
399
- const py = centerPixelY + subY + dy * 2
400
- if (px >= 0 && px < state.overlayWidth && py >= 0 && py < state.overlayHeight) {
401
- state.overlay[py]![px] = value
402
- }
403
- }
404
- }
405
- }
406
- }
407
-
408
- // ---------------------------------------------------------------------------
409
- // Zoom / pan helpers
410
- // ---------------------------------------------------------------------------
411
-
412
- function displayCols(state: PhotoCanvasState): number {
413
- return Math.max(1, Math.round(state.termCols * state.zoom))
414
- }
415
-
416
- function displayRows(state: PhotoCanvasState): number {
417
- return Math.max(1, Math.round(state.imageRows * state.zoom))
418
- }
419
-
420
- function clampPan(state: PhotoCanvasState): void {
421
- const dCols = displayCols(state)
422
- const dRows = displayRows(state)
423
- const maxPanX = Math.max(0, dCols - state.termCols)
424
- const maxPanY = Math.max(0, dRows - state.imageRows)
425
- state.panX = Math.max(0, Math.min(state.panX, maxPanX))
426
- state.panY = Math.max(0, Math.min(state.panY, maxPanY))
427
- }
428
-
429
- // ---------------------------------------------------------------------------
430
- // Rendering
431
- // ---------------------------------------------------------------------------
432
-
433
- function renderHeader(state: PhotoCanvasState, term: ReturnType<typeof createTerm>): string {
434
- const modeTag = state.mode === "draw" ? term.bold.green(" DRAW ") : term.bold.blue(" VIEW ")
435
-
436
- const toolTag =
437
- state.tool === "pen" ? term.rgb(...state.currentColor)(`[Pen ${state.brushSize}px]`) : term.dim("[Eraser]")
438
-
439
- const zoomPct = Math.round(state.zoom * 100)
440
-
441
- return (
442
- term.dim.yellow("▸ silvery") +
443
- " " +
444
- term.bold("Photo Canvas") +
445
- " " +
446
- modeTag +
447
- " " +
448
- toolTag +
449
- " " +
450
- term.dim("File:") +
451
- " " +
452
- term.bold(state.filename) +
453
- " " +
454
- term.dim(`${state.imgWidth}x${state.imgHeight}`) +
455
- " " +
456
- term.dim("Zoom:") +
457
- " " +
458
- `${zoomPct}%` +
459
- " " +
460
- term.dim("d draw v view e eraser c clear +/- zoom scroll brush q quit")
461
- )
462
- }
463
-
464
- /** Render the Kitty graphics image (placed at row 2, below the header). */
465
- function renderImage(state: PhotoCanvasState): string {
466
- const dCols = displayCols(state)
467
- const dRows = displayRows(state)
468
- const startRow = 2 // row 1 is header (1-indexed)
469
- const parts: string[] = []
470
-
471
- // Position cursor
472
- parts.push(`\x1b[${startRow};1H`)
473
-
474
- // Clear the image area
475
- for (let r = 0; r < state.imageRows; r++) {
476
- parts.push(`\x1b[${startRow + r};1H\x1b[2K`)
477
- }
478
- parts.push(`\x1b[${startRow};1H`)
479
-
480
- // Delete previous images and draw new one
481
- parts.push(kittyDeleteAll())
482
-
483
- if (state.isPng) {
484
- parts.push(kittyDisplayPng(state.imageData, dCols, dRows))
485
- } else {
486
- parts.push(kittyDisplayRgba(state.imageData, state.imgWidth, state.imgHeight, dCols, dRows))
487
- }
488
-
489
- return parts.join("")
490
- }
491
-
492
- /** Render the half-block overlay on top of the image (in draw mode). */
493
- function renderOverlay(state: PhotoCanvasState, term: ReturnType<typeof createTerm>): string {
494
- if (state.mode === "view") return ""
495
-
496
- const parts: string[] = []
497
- const startRow = 2 // 1-indexed, row below header
498
-
499
- for (let row = 0; row < state.imageRows; row++) {
500
- let hasPixels = false
501
- // Check if this row has any overlay pixels
502
- for (let col = 0; col < state.overlayWidth; col++) {
503
- if (state.overlay[row * 2]?.[col] !== null || state.overlay[row * 2 + 1]?.[col] !== null) {
504
- hasPixels = true
505
- break
506
- }
507
- }
508
- if (!hasPixels) continue
509
-
510
- // Position cursor at the start of this terminal row
511
- parts.push(`\x1b[${startRow + row};1H`)
512
-
513
- let line = ""
514
- for (let col = 0; col < state.overlayWidth; col++) {
515
- const topPixel = state.overlay[row * 2]?.[col] ?? null
516
- const bottomPixel = state.overlay[row * 2 + 1]?.[col] ?? null
517
-
518
- if (topPixel === null && bottomPixel === null) {
519
- // Transparent — skip this cell (move cursor right)
520
- if (line.length > 0) {
521
- parts.push(line)
522
- line = ""
523
- }
524
- parts.push("\x1b[C") // cursor forward 1
525
- } else if (topPixel !== null && bottomPixel === null) {
526
- line += term.rgb(topPixel[0], topPixel[1], topPixel[2])(UPPER_HALF)
527
- } else if (topPixel === null && bottomPixel !== null) {
528
- line += term.rgb(bottomPixel[0], bottomPixel[1], bottomPixel[2])(LOWER_HALF)
529
- } else if (
530
- topPixel !== null &&
531
- topPixel[0] === bottomPixel?.[0] &&
532
- topPixel[1] === bottomPixel[1] &&
533
- topPixel[2] === bottomPixel[2]
534
- ) {
535
- line += term.rgb(topPixel[0], topPixel[1], topPixel[2])(FULL_BLOCK)
536
- } else {
537
- // Both pixels different colors: upper half with fg=top, bg=bottom
538
- line += term
539
- .rgb(topPixel![0], topPixel![1], topPixel![2])
540
- .bgRgb(
541
- bottomPixel![0],
542
- bottomPixel![1],
543
- bottomPixel![2],
544
- )(UPPER_HALF)
545
- }
546
- }
547
- if (line.length > 0) {
548
- parts.push(line)
549
- }
550
- }
551
-
552
- return parts.join("")
553
- }
554
-
555
- /** Render the hue gradient bar. */
556
- function renderHueBar(state: PhotoCanvasState, term: ReturnType<typeof createTerm>): string {
557
- let line = ""
558
- for (let col = 0; col < state.termCols; col++) {
559
- const hue = (col / state.termCols) * 360
560
- const [r, g, b] = hslToRgb(hue, 1.0, 0.5)
561
- const isSelected = Math.abs(hue - state.hue) < 360 / state.termCols / 2 + 0.5
562
- if (isSelected) {
563
- line += term.bgRgb(r, g, b).black("\u25bc") // ▼
564
- } else {
565
- line += term.bgRgb(r, g, b)(" ")
566
- }
567
- }
568
- return line
569
- }
570
-
571
- /** Render the saturation + lightness bar. */
572
- function renderSatLightBar(state: PhotoCanvasState, term: ReturnType<typeof createTerm>): string {
573
- const halfWidth = Math.floor(state.termCols / 2)
574
- let line = ""
575
-
576
- // Saturation gradient (left half)
577
- for (let col = 0; col < halfWidth; col++) {
578
- const sat = col / (halfWidth - 1)
579
- const [r, g, b] = hslToRgb(state.hue, sat, state.lightness)
580
- const isSelected = Math.abs(sat - state.saturation) < 1 / (halfWidth - 1) / 2 + 0.01
581
- if (isSelected) {
582
- line += term.bgRgb(r, g, b)(r + g + b > 384 ? term.black("\u25c6") : term.white("\u25c6")) // ◆
583
- } else {
584
- line += term.bgRgb(r, g, b)(" ")
585
- }
586
- }
587
-
588
- // Separator
589
- line += term.dim("\u2502") // │
590
-
591
- // Lightness gradient (right half)
592
- const rightWidth = state.termCols - halfWidth - 1
593
- for (let col = 0; col < rightWidth; col++) {
594
- const lit = col / (rightWidth - 1 || 1)
595
- const [r, g, b] = hslToRgb(state.hue, state.saturation, lit)
596
- const isSelected = Math.abs(lit - state.lightness) < 1 / (rightWidth - 1 || 1) / 2 + 0.01
597
- if (isSelected) {
598
- line += term.bgRgb(r, g, b)(r + g + b > 384 ? term.black("\u25c6") : term.white("\u25c6"))
599
- } else {
600
- line += term.bgRgb(r, g, b)(" ")
601
- }
602
- }
603
-
604
- return line
605
- }
606
-
607
- /** Render the status bar at the bottom. */
608
- function renderStatusBar(state: PhotoCanvasState, term: ReturnType<typeof createTerm>): string {
609
- const [cr, cg, cb] = state.currentColor
610
- const colorSwatch = term.bgRgb(cr, cg, cb)(" ")
611
- const toolLabel = state.tool === "pen" ? `Pen (${state.brushSize}px)` : "Eraser"
612
- const pos = `(${state.mouseX}, ${state.mouseY})`
613
- const rgbLabel = `rgb(${cr}, ${cg}, ${cb})`
614
- const hexLabel = `#${cr.toString(16).padStart(2, "0")}${cg.toString(16).padStart(2, "0")}${cb.toString(16).padStart(2, "0")}`
615
- const overlayCount = countOverlayPixels(state)
616
-
617
- return (
618
- ` ${colorSwatch} ${term.bold(rgbLabel)} ${term.dim(hexLabel)}` +
619
- ` ${term.dim("Tool:")} ${term.bold(toolLabel)}` +
620
- ` ${term.dim("HSL:")} ${Math.round(state.hue)}\u00b0 ${Math.round(state.saturation * 100)}% ${Math.round(state.lightness * 100)}%` +
621
- ` ${term.dim("Pos:")} ${pos}` +
622
- ` ${term.dim("Overlay:")} ${overlayCount}px` +
623
- (overlayCount > 0 ? ` ${term.dim("[save possible]")}` : "")
624
- )
625
- }
626
-
627
- function countOverlayPixels(state: PhotoCanvasState): number {
628
- let count = 0
629
- for (let y = 0; y < state.overlayHeight; y++) {
630
- for (let x = 0; x < state.overlayWidth; x++) {
631
- if (state.overlay[y]![x] !== null) count++
632
- }
633
- }
634
- return count
635
- }
636
-
637
- // ---------------------------------------------------------------------------
638
- // Main
639
- // ---------------------------------------------------------------------------
640
-
641
- async function main() {
642
- const crashCleanup = () => {
643
- const stdout = process.stdout
644
- stdout.write("\x1b[?1003l\x1b[?1006l") // Disable mouse
645
- stdout.write("\x1b[?25h") // Show cursor
646
- stdout.write("\x1b[?1049l") // Exit alternate screen
647
- stdout.write("\x1b[0m") // Reset colors
648
- if (process.stdin.isTTY && process.stdin.isRaw) {
649
- try {
650
- process.stdin.setRawMode(false)
651
- } catch {}
652
- }
653
- }
654
- process.on("uncaughtException", (err) => {
655
- crashCleanup()
656
- throw err
657
- })
658
-
659
- using term = createTerm()
660
- const cols = term.cols ?? 80
661
- const rows = term.rows ?? 24
662
-
663
- const { stdin, stdout } = process
664
-
665
- // Load image or generate test pattern
666
- let filePath = process.argv[2]
667
- let imageData: Buffer
668
- let filename: string
669
- let imgWidth: number
670
- let imgHeight: number
671
- let isPng: boolean
672
-
673
- // Auto-load first sample image if no path given
674
- if (!filePath) {
675
- const samplesDir = resolve(dirname(fileURLToPath(import.meta.url)), "samples")
676
- if (existsSync(samplesDir)) {
677
- const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp"])
678
- const samples = readdirSync(samplesDir)
679
- .filter((f) => IMAGE_EXTENSIONS.has(extname(f).toLowerCase()))
680
- .sort()
681
- if (samples.length > 0) {
682
- filePath = resolve(samplesDir, samples[0]!)
683
- }
684
- }
685
- }
686
-
687
- if (filePath && existsSync(filePath)) {
688
- imageData = Buffer.from(readFileSync(filePath))
689
- const dims = readPngDimensions(imageData)
690
- filename = basename(filePath)
691
- imgWidth = dims.width
692
- imgHeight = dims.height
693
- isPng = true
694
- } else {
695
- const patternW = 320
696
- const patternH = 240
697
- imageData = generateTestPattern(patternW, patternH)
698
- filename = filePath ? `${filePath} (not found, showing test pattern)` : "test pattern"
699
- imgWidth = patternW
700
- imgHeight = patternH
701
- isPng = false
702
- }
703
-
704
- const state = createState(cols, rows, imageData, filename, imgWidth, imgHeight, isPng)
705
-
706
- // Enter alternate screen, hide cursor, enable raw mode + mouse
707
- stdout.write("\x1b[?1049h")
708
- stdout.write("\x1b[?25l")
709
- stdout.write("\x1b[2J\x1b[H")
710
-
711
- if (stdin.isTTY) {
712
- stdin.setRawMode(true)
713
- }
714
- stdin.resume()
715
- stdout.write(enableMouse())
716
-
717
- /** Full redraw: header + image + overlay + UI bars */
718
- const redraw = () => {
719
- // Header (row 1)
720
- stdout.write("\x1b[1;1H\x1b[2K")
721
- stdout.write(renderHeader(state, term))
722
-
723
- // Image via Kitty graphics
724
- stdout.write(renderImage(state))
725
-
726
- // Overlay (only in draw mode, rendered on top of image)
727
- stdout.write(renderOverlay(state, term))
728
-
729
- // Hue bar
730
- const hueRow = 1 + state.imageRows + 1 // 1-indexed
731
- stdout.write(`\x1b[${hueRow};1H\x1b[2K`)
732
- stdout.write(renderHueBar(state, term))
733
-
734
- // Saturation/lightness bar
735
- stdout.write(`\x1b[${hueRow + 1};1H\x1b[2K`)
736
- stdout.write(renderSatLightBar(state, term))
737
-
738
- // Status bar
739
- stdout.write(`\x1b[${hueRow + 2};1H\x1b[2K`)
740
- stdout.write(renderStatusBar(state, term))
741
- }
742
-
743
- redraw()
744
-
745
- /** Update currentColor from current HSL state */
746
- const syncColor = () => {
747
- state.currentColor = hslToRgb(state.hue, state.saturation, state.lightness)
748
- }
749
-
750
- const cleanup = () => {
751
- stdout.write(disableMouse())
752
- stdout.write(kittyDeleteAll())
753
- stdout.write("\x1b[?25h")
754
- stdout.write("\x1b[?1049l")
755
- if (stdin.isTTY) {
756
- stdin.setRawMode(false)
757
- }
758
- stdin.off("data", onData)
759
- stdin.pause()
760
- process.exit(0)
761
- }
762
-
763
- const hueBarRow = 1 + state.imageRows // 0-indexed terminal row
764
- const satBarRow = hueBarRow + 1
765
-
766
- const onData = (data: Buffer) => {
767
- const raw = data.toString()
768
-
769
- // --- Mouse events ---
770
- if (isMouseSequence(raw)) {
771
- const parsed = parseMouseSequence(raw)
772
- if (!parsed) return
773
-
774
- state.mouseX = parsed.x
775
- state.mouseY = parsed.y
776
-
777
- const halfWidth = Math.floor(state.termCols / 2)
778
-
779
- // Scroll wheel: change brush size
780
- if (parsed.action === "wheel") {
781
- state.brushSize = Math.max(MIN_BRUSH, Math.min(MAX_BRUSH, state.brushSize + (parsed.delta ?? 0)))
782
- redraw()
783
- return
784
- }
785
-
786
- if (parsed.action === "down" && parsed.button === 0) {
787
- if (parsed.y === hueBarRow) {
788
- // Click on hue bar
789
- state.hue = (parsed.x / state.termCols) * 360
790
- syncColor()
791
- state.tool = "pen"
792
- redraw()
793
- } else if (parsed.y === satBarRow) {
794
- if (parsed.x < halfWidth) {
795
- // Click on saturation section
796
- state.saturation = Math.max(0, Math.min(1, parsed.x / (halfWidth - 1)))
797
- syncColor()
798
- state.tool = "pen"
799
- } else if (parsed.x > halfWidth) {
800
- // Click on lightness section
801
- const litCol = parsed.x - halfWidth - 1
802
- const litWidth = state.termCols - halfWidth - 2
803
- state.lightness = Math.max(0, Math.min(1, litCol / litWidth))
804
- syncColor()
805
- state.tool = "pen"
806
- }
807
- redraw()
808
- } else if (state.mode === "draw" && parsed.y > 0 && parsed.y < hueBarRow) {
809
- // Click on canvas area (draw mode only)
810
- state.isDrawing = true
811
- paintOverlay(state, parsed.x, parsed.y)
812
- redraw()
813
- } else {
814
- redraw()
815
- }
816
- } else if (parsed.action === "move" && state.isDrawing) {
817
- if (state.mode === "draw" && parsed.y > 0 && parsed.y < hueBarRow) {
818
- paintOverlay(state, parsed.x, parsed.y)
819
- }
820
- redraw()
821
- } else if (parsed.action === "up") {
822
- state.isDrawing = false
823
- redraw()
824
- } else {
825
- redraw()
826
- }
827
- return
828
- }
829
-
830
- // --- Arrow keys (pan) ---
831
- if (raw === "\x1b[A" || raw === "\x1bOA") {
832
- state.panY = Math.max(0, state.panY - 2)
833
- redraw()
834
- return
835
- } else if (raw === "\x1b[B" || raw === "\x1bOB") {
836
- state.panY += 2
837
- clampPan(state)
838
- redraw()
839
- return
840
- } else if (raw === "\x1b[D" || raw === "\x1bOD") {
841
- state.panX = Math.max(0, state.panX - 2)
842
- redraw()
843
- return
844
- } else if (raw === "\x1b[C" || raw === "\x1bOC") {
845
- state.panX += 2
846
- clampPan(state)
847
- redraw()
848
- return
849
- }
850
-
851
- // --- Single-character keyboard input ---
852
- for (const ch of raw) {
853
- switch (ch) {
854
- case "q":
855
- case "\x1b":
856
- cleanup()
857
- return
858
-
859
- case "d":
860
- state.mode = "draw"
861
- redraw()
862
- break
863
-
864
- case "v":
865
- state.mode = "view"
866
- redraw()
867
- break
868
-
869
- case "e":
870
- state.tool = state.tool === "eraser" ? "pen" : "eraser"
871
- redraw()
872
- break
873
-
874
- case "c":
875
- // Clear overlay
876
- for (let y = 0; y < state.overlayHeight; y++) {
877
- state.overlay[y]!.fill(null)
878
- }
879
- redraw()
880
- break
881
-
882
- case "+":
883
- state.zoom = Math.min(10.0, state.zoom + ZOOM_STEP)
884
- clampPan(state)
885
- redraw()
886
- break
887
-
888
- case "-":
889
- case "_":
890
- state.zoom = Math.max(ZOOM_STEP, state.zoom - ZOOM_STEP)
891
- clampPan(state)
892
- redraw()
893
- break
894
-
895
- case "f":
896
- state.zoom = 1.0
897
- state.panX = 0
898
- state.panY = 0
899
- redraw()
900
- break
901
-
902
- // Preset colors 1-9, 0
903
- case "1":
904
- case "2":
905
- case "3":
906
- case "4":
907
- case "5":
908
- case "6":
909
- case "7":
910
- case "8":
911
- case "9": {
912
- const preset = PRESETS[Number(ch) - 1]
913
- if (preset) {
914
- state.currentColor = [...preset.color]
915
- const [h, s, l] = rgbToHsl(...preset.color)
916
- state.hue = h
917
- state.saturation = s
918
- state.lightness = l
919
- state.tool = "pen"
920
- redraw()
921
- }
922
- break
923
- }
924
- case "0": {
925
- const preset = PRESETS[9]
926
- if (preset) {
927
- state.currentColor = [...preset.color]
928
- const [h, s, l] = rgbToHsl(...preset.color)
929
- state.hue = h
930
- state.saturation = s
931
- state.lightness = l
932
- state.tool = "pen"
933
- redraw()
934
- }
935
- break
936
- }
937
-
938
- case "[":
939
- state.hue = (state.hue - 5 + 360) % 360
940
- syncColor()
941
- state.tool = "pen"
942
- redraw()
943
- break
944
-
945
- case "]":
946
- state.hue = (state.hue + 5) % 360
947
- syncColor()
948
- state.tool = "pen"
949
- redraw()
950
- break
951
-
952
- case "b":
953
- // Cycle brightness
954
- if (state.lightness < 0.3) state.lightness = 0.5
955
- else if (state.lightness < 0.55) state.lightness = 0.75
956
- else if (state.lightness < 0.8) state.lightness = 1.0
957
- else state.lightness = 0.25
958
- syncColor()
959
- state.tool = "pen"
960
- redraw()
961
- break
962
-
963
- default:
964
- break
965
- }
966
- }
967
- }
968
-
969
- stdin.on("data", onData)
970
-
971
- // Handle terminal resize
972
- stdout.on("resize", () => {
973
- const newCols = stdout.columns ?? cols
974
- const newRows = stdout.rows ?? rows
975
- state.termCols = newCols
976
- state.imageRows = newRows - RESERVED_ROWS
977
-
978
- // Resize overlay, preserving existing pixels
979
- const newOverlayWidth = newCols
980
- const newOverlayHeight = state.imageRows * 2
981
- const newOverlay: (RGB | null)[][] = []
982
- for (let y = 0; y < newOverlayHeight; y++) {
983
- const row: (RGB | null)[] = new Array(newOverlayWidth).fill(null)
984
- for (let x = 0; x < Math.min(state.overlayWidth, newOverlayWidth); x++) {
985
- if (y < state.overlayHeight) {
986
- row[x] = state.overlay[y]![x]!
987
- }
988
- }
989
- newOverlay.push(row)
990
- }
991
- state.overlay = newOverlay
992
- state.overlayWidth = newOverlayWidth
993
- state.overlayHeight = newOverlayHeight
994
-
995
- clampPan(state)
996
- stdout.write("\x1b[2J\x1b[H")
997
- redraw()
998
- })
999
- }
1000
-
1001
- if (import.meta.main) {
1002
- main().catch((err) => {
1003
- // Restore terminal on crash
1004
- const stdout = process.stdout
1005
- stdout.write("\x1b[?1003l\x1b[?1006l") // Disable mouse
1006
- stdout.write("\x1b[?25h") // Show cursor
1007
- stdout.write("\x1b[?1049l") // Exit alternate screen
1008
- stdout.write("\x1b[0m") // Reset colors
1009
- if (process.stdin.isTTY && process.stdin.isRaw) {
1010
- try {
1011
- process.stdin.setRawMode(false)
1012
- } catch {}
1013
- }
1014
- console.error(err)
1015
- process.exit(1)
1016
- })
1017
- }