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
package/examples/kitty/paint.tsx
DELETED
|
@@ -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
|
-
}
|