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,519 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Terminal Canvas
|
|
3
|
-
*
|
|
4
|
-
* Click-drag to draw pixel art on a terminal canvas using half-block characters.
|
|
5
|
-
* Each terminal cell holds 2 vertical pixels using Unicode half-block technique.
|
|
6
|
-
*
|
|
7
|
-
* Features:
|
|
8
|
-
* - Half-block pixel art (2x vertical resolution)
|
|
9
|
-
* - Full RGB color picker with HSL gradient
|
|
10
|
-
* - Hue bar + saturation bar for intuitive color selection
|
|
11
|
-
* - Pen and eraser tools
|
|
12
|
-
* - Keyboard shortcuts for color/tool selection
|
|
13
|
-
*
|
|
14
|
-
* Run: bun vendor/silvery/examples/kitty/terminal-canvas.tsx
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import { createTerm, enableMouse, disableMouse, parseMouseSequence, isMouseSequence } from "../../src/index.js"
|
|
18
|
-
import type { ExampleMeta } from "../_banner.js"
|
|
19
|
-
|
|
20
|
-
export const meta: ExampleMeta = {
|
|
21
|
-
name: "Char Draw",
|
|
22
|
-
description: "Click-drag to draw with half-block pixel art, RGB color picker",
|
|
23
|
-
features: ["parseMouseSequence()", "enableMouse()", "half-block rendering", "drag tracking", "HSL color picker"],
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
// Half-block characters for 2x vertical resolution
|
|
27
|
-
const UPPER_HALF = "\u2580" // ▀ — top filled, bottom empty
|
|
28
|
-
const LOWER_HALF = "\u2584" // ▄ — top empty, bottom filled
|
|
29
|
-
const FULL_BLOCK = "\u2588" // █ — both filled
|
|
30
|
-
|
|
31
|
-
type RGB = [number, number, number]
|
|
32
|
-
|
|
33
|
-
// Preset colors accessible via 1-9/0
|
|
34
|
-
const PRESETS: { name: string; color: RGB }[] = [
|
|
35
|
-
{ name: "white", color: [255, 255, 255] },
|
|
36
|
-
{ name: "red", color: [255, 0, 0] },
|
|
37
|
-
{ name: "orange", color: [255, 165, 0] },
|
|
38
|
-
{ name: "yellow", color: [255, 255, 0] },
|
|
39
|
-
{ name: "green", color: [0, 255, 0] },
|
|
40
|
-
{ name: "cyan", color: [0, 255, 255] },
|
|
41
|
-
{ name: "blue", color: [0, 100, 255] },
|
|
42
|
-
{ name: "magenta", color: [255, 0, 255] },
|
|
43
|
-
{ name: "pink", color: [255, 128, 200] },
|
|
44
|
-
{ name: "black", color: [0, 0, 0] },
|
|
45
|
-
]
|
|
46
|
-
|
|
47
|
-
// --- HSL <-> RGB conversion ---
|
|
48
|
-
|
|
49
|
-
function hslToRgb(h: number, s: number, l: number): RGB {
|
|
50
|
-
// h: 0-360, s: 0-1, l: 0-1
|
|
51
|
-
const c = (1 - Math.abs(2 * l - 1)) * s
|
|
52
|
-
const x = c * (1 - Math.abs(((h / 60) % 2) - 1))
|
|
53
|
-
const m = l - c / 2
|
|
54
|
-
|
|
55
|
-
let r1: number, g1: number, b1: number
|
|
56
|
-
if (h < 60) {
|
|
57
|
-
;[r1, g1, b1] = [c, x, 0]
|
|
58
|
-
} else if (h < 120) {
|
|
59
|
-
;[r1, g1, b1] = [x, c, 0]
|
|
60
|
-
} else if (h < 180) {
|
|
61
|
-
;[r1, g1, b1] = [0, c, x]
|
|
62
|
-
} else if (h < 240) {
|
|
63
|
-
;[r1, g1, b1] = [0, x, c]
|
|
64
|
-
} else if (h < 300) {
|
|
65
|
-
;[r1, g1, b1] = [x, 0, c]
|
|
66
|
-
} else {
|
|
67
|
-
;[r1, g1, b1] = [c, 0, x]
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
return [Math.round((r1 + m) * 255), Math.round((g1 + m) * 255), Math.round((b1 + m) * 255)]
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
|
|
74
|
-
r /= 255
|
|
75
|
-
g /= 255
|
|
76
|
-
b /= 255
|
|
77
|
-
const max = Math.max(r, g, b)
|
|
78
|
-
const min = Math.min(r, g, b)
|
|
79
|
-
const l = (max + min) / 2
|
|
80
|
-
if (max === min) return [0, 0, l]
|
|
81
|
-
const d = max - min
|
|
82
|
-
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
|
|
83
|
-
let h: number
|
|
84
|
-
if (max === r) {
|
|
85
|
-
h = ((g - b) / d + (g < b ? 6 : 0)) * 60
|
|
86
|
-
} else if (max === g) {
|
|
87
|
-
h = ((b - r) / d + 2) * 60
|
|
88
|
-
} else {
|
|
89
|
-
h = ((r - g) / d + 4) * 60
|
|
90
|
-
}
|
|
91
|
-
return [h, s, l]
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
type Tool = "pen" | "eraser"
|
|
95
|
-
|
|
96
|
-
interface CanvasState {
|
|
97
|
-
/** 2D array of pixel colors as RGB tuples (null = empty). Width x Height where height = rows*2 */
|
|
98
|
-
pixels: (RGB | null)[][]
|
|
99
|
-
/** Canvas width in terminal columns */
|
|
100
|
-
width: number
|
|
101
|
-
/** Canvas height in pixel rows (2x terminal rows) */
|
|
102
|
-
height: number
|
|
103
|
-
/** Currently selected color as RGB */
|
|
104
|
-
currentColor: RGB
|
|
105
|
-
/** Current HSL values for picker state */
|
|
106
|
-
hue: number
|
|
107
|
-
saturation: number
|
|
108
|
-
lightness: number
|
|
109
|
-
/** Current tool */
|
|
110
|
-
tool: Tool
|
|
111
|
-
/** Mouse position for status bar */
|
|
112
|
-
mouseX: number
|
|
113
|
-
mouseY: number
|
|
114
|
-
/** Whether mouse is currently pressed */
|
|
115
|
-
isDrawing: boolean
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// Reserve 4 rows: 1 header, 1 hue bar, 1 saturation bar, 1 status
|
|
119
|
-
const RESERVED_ROWS = 4
|
|
120
|
-
|
|
121
|
-
function createCanvas(cols: number, rows: number): CanvasState {
|
|
122
|
-
const canvasRows = rows - RESERVED_ROWS
|
|
123
|
-
const width = cols
|
|
124
|
-
const height = canvasRows * 2
|
|
125
|
-
|
|
126
|
-
const pixels: (RGB | null)[][] = []
|
|
127
|
-
for (let y = 0; y < height; y++) {
|
|
128
|
-
pixels.push(new Array(width).fill(null))
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
return {
|
|
132
|
-
pixels,
|
|
133
|
-
width,
|
|
134
|
-
height,
|
|
135
|
-
currentColor: [255, 255, 255],
|
|
136
|
-
hue: 0,
|
|
137
|
-
saturation: 1.0,
|
|
138
|
-
lightness: 0.5,
|
|
139
|
-
tool: "pen",
|
|
140
|
-
mouseX: 0,
|
|
141
|
-
mouseY: 0,
|
|
142
|
-
isDrawing: false,
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function setPixel(state: CanvasState, termX: number, termY: number): void {
|
|
147
|
-
// termY is relative to terminal row; row 0 is the header
|
|
148
|
-
// Canvas starts at row 1
|
|
149
|
-
const canvasTermRow = termY - 1
|
|
150
|
-
if (canvasTermRow < 0) return
|
|
151
|
-
|
|
152
|
-
const pixelY0 = canvasTermRow * 2
|
|
153
|
-
const pixelY1 = pixelY0 + 1
|
|
154
|
-
|
|
155
|
-
const x = termX
|
|
156
|
-
if (x < 0 || x >= state.width) return
|
|
157
|
-
|
|
158
|
-
const value: RGB | null = state.tool === "pen" ? [...state.currentColor] : null
|
|
159
|
-
|
|
160
|
-
if (pixelY0 >= 0 && pixelY0 < state.height) {
|
|
161
|
-
state.pixels[pixelY0]![x] = value
|
|
162
|
-
}
|
|
163
|
-
if (pixelY1 >= 0 && pixelY1 < state.height) {
|
|
164
|
-
state.pixels[pixelY1]![x] = value
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
function renderFrame(state: CanvasState, term: ReturnType<typeof createTerm>): string {
|
|
169
|
-
const lines: string[] = []
|
|
170
|
-
|
|
171
|
-
// Header
|
|
172
|
-
lines.push(
|
|
173
|
-
term.dim.yellow("▸ silvery") +
|
|
174
|
-
" " +
|
|
175
|
-
term.bold("Terminal Canvas") +
|
|
176
|
-
" " +
|
|
177
|
-
term.dim("— click-drag to draw") +
|
|
178
|
-
" " +
|
|
179
|
-
term.dim("1-9/0 preset [/] hue -/= sat b bright e eraser c clear q quit"),
|
|
180
|
-
)
|
|
181
|
-
|
|
182
|
-
// Canvas: convert pixel pairs to half-block characters
|
|
183
|
-
const canvasRows = Math.floor(state.height / 2)
|
|
184
|
-
for (let row = 0; row < canvasRows; row++) {
|
|
185
|
-
let line = ""
|
|
186
|
-
for (let col = 0; col < state.width; col++) {
|
|
187
|
-
const topPixel = state.pixels[row * 2]![col]
|
|
188
|
-
const bottomPixel = state.pixels[row * 2 + 1]![col]
|
|
189
|
-
|
|
190
|
-
if (topPixel === null && bottomPixel === null) {
|
|
191
|
-
line += " "
|
|
192
|
-
} else if (topPixel !== null && bottomPixel === null) {
|
|
193
|
-
const [r, g, b] = topPixel
|
|
194
|
-
line += term.rgb(r, g, b)(UPPER_HALF)
|
|
195
|
-
} else if (topPixel === null && bottomPixel !== null) {
|
|
196
|
-
const [r, g, b] = bottomPixel
|
|
197
|
-
line += term.rgb(r, g, b)(LOWER_HALF)
|
|
198
|
-
} else if (
|
|
199
|
-
topPixel !== null &&
|
|
200
|
-
topPixel[0] === bottomPixel?.[0] &&
|
|
201
|
-
topPixel[1] === bottomPixel[1] &&
|
|
202
|
-
topPixel[2] === bottomPixel[2]
|
|
203
|
-
) {
|
|
204
|
-
const [r, g, b] = topPixel
|
|
205
|
-
line += term.rgb(r, g, b)(FULL_BLOCK)
|
|
206
|
-
} else {
|
|
207
|
-
const [tr, tg, tb] = topPixel!
|
|
208
|
-
const [br, bg, bb] = bottomPixel!
|
|
209
|
-
line += term.rgb(tr, tg, tb).bgRgb(br, bg, bb)(UPPER_HALF)
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
lines.push(line)
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
// --- Hue gradient bar ---
|
|
216
|
-
// Each column maps to a hue value; full saturation, 0.5 lightness
|
|
217
|
-
let hueLine = ""
|
|
218
|
-
for (let col = 0; col < state.width; col++) {
|
|
219
|
-
const hue = (col / state.width) * 360
|
|
220
|
-
const [r, g, b] = hslToRgb(hue, 1.0, 0.5)
|
|
221
|
-
// Mark the selected hue column
|
|
222
|
-
const isSelected = Math.abs(hue - state.hue) < 360 / state.width / 2 + 0.5
|
|
223
|
-
if (isSelected) {
|
|
224
|
-
hueLine += term.bgRgb(r, g, b).black("▼")
|
|
225
|
-
} else {
|
|
226
|
-
hueLine += term.bgRgb(r, g, b)(" ")
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
lines.push(hueLine)
|
|
230
|
-
|
|
231
|
-
// --- Saturation/brightness bar ---
|
|
232
|
-
// Left half: saturation gradient at current hue. Right half: lightness gradient.
|
|
233
|
-
const halfWidth = Math.floor(state.width / 2)
|
|
234
|
-
let satLine = ""
|
|
235
|
-
for (let col = 0; col < halfWidth; col++) {
|
|
236
|
-
const sat = col / (halfWidth - 1)
|
|
237
|
-
const [r, g, b] = hslToRgb(state.hue, sat, state.lightness)
|
|
238
|
-
const isSelected = Math.abs(sat - state.saturation) < 1 / (halfWidth - 1) / 2 + 0.01
|
|
239
|
-
if (isSelected) {
|
|
240
|
-
satLine += term.bgRgb(r, g, b)(r + g + b > 384 ? term.black("◆") : term.white("◆"))
|
|
241
|
-
} else {
|
|
242
|
-
satLine += term.bgRgb(r, g, b)(" ")
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
// Separator
|
|
246
|
-
satLine += term.dim("│")
|
|
247
|
-
// Lightness gradient
|
|
248
|
-
for (let col = 0; col < state.width - halfWidth - 1; col++) {
|
|
249
|
-
const lit = col / (state.width - halfWidth - 2)
|
|
250
|
-
const [r, g, b] = hslToRgb(state.hue, state.saturation, lit)
|
|
251
|
-
const isSelected = Math.abs(lit - state.lightness) < 1 / (state.width - halfWidth - 2) / 2 + 0.01
|
|
252
|
-
if (isSelected) {
|
|
253
|
-
satLine += term.bgRgb(r, g, b)(r + g + b > 384 ? term.black("◆") : term.white("◆"))
|
|
254
|
-
} else {
|
|
255
|
-
satLine += term.bgRgb(r, g, b)(" ")
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
lines.push(satLine)
|
|
259
|
-
|
|
260
|
-
// --- Status bar with color preview ---
|
|
261
|
-
const [cr, cg, cb] = state.currentColor
|
|
262
|
-
const colorSwatch = term.bgRgb(cr, cg, cb)(" ") // 4-char swatch
|
|
263
|
-
const toolLabel = state.tool === "pen" ? "Pen" : "Eraser"
|
|
264
|
-
const pos = `(${state.mouseX}, ${state.mouseY})`
|
|
265
|
-
const rgbLabel = `rgb(${cr}, ${cg}, ${cb})`
|
|
266
|
-
const hexLabel = `#${cr.toString(16).padStart(2, "0")}${cg.toString(16).padStart(2, "0")}${cb.toString(16).padStart(2, "0")}`
|
|
267
|
-
|
|
268
|
-
lines.push(
|
|
269
|
-
` ${colorSwatch} ${term.bold(rgbLabel)} ${term.dim(hexLabel)}` +
|
|
270
|
-
` ${term.dim("Tool:")} ${term.bold(toolLabel)}` +
|
|
271
|
-
` ${term.dim("HSL:")} ${Math.round(state.hue)}° ${Math.round(state.saturation * 100)}% ${Math.round(state.lightness * 100)}%` +
|
|
272
|
-
` ${term.dim("Pos:")} ${pos}`,
|
|
273
|
-
)
|
|
274
|
-
|
|
275
|
-
return lines.join("\n")
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
async function main() {
|
|
279
|
-
const crashCleanup = () => {
|
|
280
|
-
const stdout = process.stdout
|
|
281
|
-
stdout.write("\x1b[?1003l\x1b[?1006l") // Disable mouse
|
|
282
|
-
stdout.write("\x1b[?25h") // Show cursor
|
|
283
|
-
stdout.write("\x1b[?1049l") // Exit alternate screen
|
|
284
|
-
stdout.write("\x1b[0m") // Reset colors
|
|
285
|
-
if (process.stdin.isTTY && process.stdin.isRaw) {
|
|
286
|
-
try {
|
|
287
|
-
process.stdin.setRawMode(false)
|
|
288
|
-
} catch {}
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
process.on("uncaughtException", (err) => {
|
|
292
|
-
crashCleanup()
|
|
293
|
-
throw err
|
|
294
|
-
})
|
|
295
|
-
|
|
296
|
-
using term = createTerm()
|
|
297
|
-
const cols = term.cols ?? 80
|
|
298
|
-
const rows = term.rows ?? 24
|
|
299
|
-
|
|
300
|
-
const state = createCanvas(cols, rows)
|
|
301
|
-
|
|
302
|
-
const { stdin, stdout } = process
|
|
303
|
-
|
|
304
|
-
// Enable raw mode and mouse tracking
|
|
305
|
-
if (stdin.isTTY) {
|
|
306
|
-
stdin.setRawMode(true)
|
|
307
|
-
}
|
|
308
|
-
stdin.resume()
|
|
309
|
-
stdout.write(enableMouse())
|
|
310
|
-
|
|
311
|
-
// Enter alternate screen, hide cursor
|
|
312
|
-
stdout.write("\x1b[?1049h")
|
|
313
|
-
stdout.write("\x1b[?25l")
|
|
314
|
-
|
|
315
|
-
// Clear screen and render initial frame
|
|
316
|
-
stdout.write("\x1b[2J\x1b[H")
|
|
317
|
-
stdout.write(renderFrame(state, term))
|
|
318
|
-
|
|
319
|
-
const redraw = () => {
|
|
320
|
-
stdout.write("\x1b[H")
|
|
321
|
-
stdout.write(renderFrame(state, term))
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
/** Update currentColor from current HSL state */
|
|
325
|
-
const syncColor = () => {
|
|
326
|
-
state.currentColor = hslToRgb(state.hue, state.saturation, state.lightness)
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
/** Compute terminal row indices for the UI bars */
|
|
330
|
-
const canvasTermRows = () => Math.floor(state.height / 2)
|
|
331
|
-
|
|
332
|
-
const onData = (data: Buffer) => {
|
|
333
|
-
const raw = data.toString()
|
|
334
|
-
|
|
335
|
-
// Check for mouse events
|
|
336
|
-
if (isMouseSequence(raw)) {
|
|
337
|
-
const parsed = parseMouseSequence(raw)
|
|
338
|
-
if (!parsed) return
|
|
339
|
-
|
|
340
|
-
state.mouseX = parsed.x
|
|
341
|
-
state.mouseY = parsed.y
|
|
342
|
-
|
|
343
|
-
const hueBarRow = 1 + canvasTermRows() // row after header + canvas
|
|
344
|
-
const satBarRow = hueBarRow + 1
|
|
345
|
-
const halfWidth = Math.floor(state.width / 2)
|
|
346
|
-
|
|
347
|
-
if (parsed.action === "down" && parsed.button === 0) {
|
|
348
|
-
if (parsed.y === hueBarRow) {
|
|
349
|
-
// Click on hue bar
|
|
350
|
-
state.hue = (parsed.x / state.width) * 360
|
|
351
|
-
syncColor()
|
|
352
|
-
state.tool = "pen"
|
|
353
|
-
redraw()
|
|
354
|
-
} else if (parsed.y === satBarRow) {
|
|
355
|
-
if (parsed.x < halfWidth) {
|
|
356
|
-
// Click on saturation section
|
|
357
|
-
state.saturation = Math.max(0, Math.min(1, parsed.x / (halfWidth - 1)))
|
|
358
|
-
syncColor()
|
|
359
|
-
state.tool = "pen"
|
|
360
|
-
} else if (parsed.x > halfWidth) {
|
|
361
|
-
// Click on lightness section
|
|
362
|
-
const litCol = parsed.x - halfWidth - 1
|
|
363
|
-
const litWidth = state.width - halfWidth - 2
|
|
364
|
-
state.lightness = Math.max(0, Math.min(1, litCol / litWidth))
|
|
365
|
-
syncColor()
|
|
366
|
-
state.tool = "pen"
|
|
367
|
-
}
|
|
368
|
-
redraw()
|
|
369
|
-
} else if (parsed.y > 0 && parsed.y < hueBarRow) {
|
|
370
|
-
// Click on canvas
|
|
371
|
-
state.isDrawing = true
|
|
372
|
-
setPixel(state, parsed.x, parsed.y)
|
|
373
|
-
redraw()
|
|
374
|
-
} else {
|
|
375
|
-
redraw()
|
|
376
|
-
}
|
|
377
|
-
} else if (parsed.action === "move" && state.isDrawing) {
|
|
378
|
-
if (parsed.y > 0 && parsed.y < hueBarRow) {
|
|
379
|
-
setPixel(state, parsed.x, parsed.y)
|
|
380
|
-
}
|
|
381
|
-
redraw()
|
|
382
|
-
} else if (parsed.action === "up") {
|
|
383
|
-
state.isDrawing = false
|
|
384
|
-
redraw()
|
|
385
|
-
} else {
|
|
386
|
-
redraw()
|
|
387
|
-
}
|
|
388
|
-
return
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
// Keyboard input
|
|
392
|
-
for (const ch of raw) {
|
|
393
|
-
if (ch === "q" || ch === "\x1b") {
|
|
394
|
-
cleanup()
|
|
395
|
-
return
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
if (ch === "e") {
|
|
399
|
-
state.tool = state.tool === "eraser" ? "pen" : "eraser"
|
|
400
|
-
redraw()
|
|
401
|
-
} else if (ch === "c") {
|
|
402
|
-
for (let y = 0; y < state.height; y++) {
|
|
403
|
-
state.pixels[y]!.fill(null)
|
|
404
|
-
}
|
|
405
|
-
redraw()
|
|
406
|
-
} else if (ch >= "1" && ch <= "9") {
|
|
407
|
-
const preset = PRESETS[Number(ch) - 1]
|
|
408
|
-
if (preset) {
|
|
409
|
-
state.currentColor = [...preset.color]
|
|
410
|
-
const [h, s, l] = rgbToHsl(...preset.color)
|
|
411
|
-
state.hue = h
|
|
412
|
-
state.saturation = s
|
|
413
|
-
state.lightness = l
|
|
414
|
-
state.tool = "pen"
|
|
415
|
-
redraw()
|
|
416
|
-
}
|
|
417
|
-
} else if (ch === "0") {
|
|
418
|
-
const preset = PRESETS[9]
|
|
419
|
-
if (preset) {
|
|
420
|
-
state.currentColor = [...preset.color]
|
|
421
|
-
const [h, s, l] = rgbToHsl(...preset.color)
|
|
422
|
-
state.hue = h
|
|
423
|
-
state.saturation = s
|
|
424
|
-
state.lightness = l
|
|
425
|
-
state.tool = "pen"
|
|
426
|
-
redraw()
|
|
427
|
-
}
|
|
428
|
-
} else if (ch === "[") {
|
|
429
|
-
// Cycle hue left
|
|
430
|
-
state.hue = (state.hue - 5 + 360) % 360
|
|
431
|
-
syncColor()
|
|
432
|
-
state.tool = "pen"
|
|
433
|
-
redraw()
|
|
434
|
-
} else if (ch === "]") {
|
|
435
|
-
// Cycle hue right
|
|
436
|
-
state.hue = (state.hue + 5) % 360
|
|
437
|
-
syncColor()
|
|
438
|
-
state.tool = "pen"
|
|
439
|
-
redraw()
|
|
440
|
-
} else if (ch === "-") {
|
|
441
|
-
// Decrease saturation
|
|
442
|
-
state.saturation = Math.max(0, state.saturation - 0.05)
|
|
443
|
-
syncColor()
|
|
444
|
-
state.tool = "pen"
|
|
445
|
-
redraw()
|
|
446
|
-
} else if (ch === "=") {
|
|
447
|
-
// Increase saturation
|
|
448
|
-
state.saturation = Math.min(1, state.saturation + 0.05)
|
|
449
|
-
syncColor()
|
|
450
|
-
state.tool = "pen"
|
|
451
|
-
redraw()
|
|
452
|
-
} else if (ch === "b") {
|
|
453
|
-
// Cycle brightness: 0.5 -> 0.75 -> 1.0 -> 0.25 -> 0.5
|
|
454
|
-
if (state.lightness < 0.3) {
|
|
455
|
-
state.lightness = 0.5
|
|
456
|
-
} else if (state.lightness < 0.55) {
|
|
457
|
-
state.lightness = 0.75
|
|
458
|
-
} else if (state.lightness < 0.8) {
|
|
459
|
-
state.lightness = 1.0
|
|
460
|
-
} else {
|
|
461
|
-
state.lightness = 0.25
|
|
462
|
-
}
|
|
463
|
-
syncColor()
|
|
464
|
-
state.tool = "pen"
|
|
465
|
-
redraw()
|
|
466
|
-
}
|
|
467
|
-
}
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
const cleanup = () => {
|
|
471
|
-
stdout.write(disableMouse())
|
|
472
|
-
stdout.write("\x1b[?25h") // Show cursor
|
|
473
|
-
stdout.write("\x1b[?1049l") // Exit alternate screen
|
|
474
|
-
if (stdin.isTTY) {
|
|
475
|
-
stdin.setRawMode(false)
|
|
476
|
-
}
|
|
477
|
-
stdin.off("data", onData)
|
|
478
|
-
stdin.pause()
|
|
479
|
-
process.exit(0)
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
stdin.on("data", onData)
|
|
483
|
-
|
|
484
|
-
// Handle terminal resize
|
|
485
|
-
stdout.on("resize", () => {
|
|
486
|
-
const newCols = stdout.columns ?? cols
|
|
487
|
-
const newRows = stdout.rows ?? rows
|
|
488
|
-
const newState = createCanvas(newCols, newRows)
|
|
489
|
-
// Copy existing pixels that still fit
|
|
490
|
-
for (let y = 0; y < Math.min(state.height, newState.height); y++) {
|
|
491
|
-
for (let x = 0; x < Math.min(state.width, newState.width); x++) {
|
|
492
|
-
newState.pixels[y]![x] = state.pixels[y]![x]!
|
|
493
|
-
}
|
|
494
|
-
}
|
|
495
|
-
state.pixels = newState.pixels
|
|
496
|
-
state.width = newState.width
|
|
497
|
-
state.height = newState.height
|
|
498
|
-
stdout.write("\x1b[2J\x1b[H")
|
|
499
|
-
redraw()
|
|
500
|
-
})
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
if (import.meta.main) {
|
|
504
|
-
main().catch((err) => {
|
|
505
|
-
// Restore terminal on crash
|
|
506
|
-
const stdout = process.stdout
|
|
507
|
-
stdout.write("\x1b[?1003l\x1b[?1006l") // Disable mouse
|
|
508
|
-
stdout.write("\x1b[?25h") // Show cursor
|
|
509
|
-
stdout.write("\x1b[?1049l") // Exit alternate screen
|
|
510
|
-
stdout.write("\x1b[0m") // Reset colors
|
|
511
|
-
if (process.stdin.isTTY && process.stdin.isRaw) {
|
|
512
|
-
try {
|
|
513
|
-
process.stdin.setRawMode(false)
|
|
514
|
-
} catch {}
|
|
515
|
-
}
|
|
516
|
-
console.error(err)
|
|
517
|
-
process.exit(1)
|
|
518
|
-
})
|
|
519
|
-
}
|