qr-terminal 1.0.0
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 +159 -0
- package/index.js +63 -0
- package/package.json +47 -0
- package/src/braille.js +104 -0
- package/src/destruct.js +123 -0
- package/src/engine.js +286 -0
- package/src/export.js +76 -0
- package/src/formatters.js +44 -0
- package/src/handshake.js +112 -0
- package/src/live-preview.js +199 -0
- package/src/luma.js +58 -0
- package/src/matrix-rain.js +115 -0
- package/src/particle.js +91 -0
- package/src/prompts.js +449 -0
- package/src/shortlink.js +58 -0
- package/src/ui.js +183 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live Preview Engine
|
|
3
|
+
*
|
|
4
|
+
* WYSIWYG terminal experience: QR code re-renders on every keystroke.
|
|
5
|
+
* Per-keystroke info badge shows QR version + char count in real-time.
|
|
6
|
+
* Placeholder frame holds the exact space so there's no layout jump.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import chalk from 'chalk';
|
|
10
|
+
import { createMatrix, renderHalfBlock, getQrVersion } from './engine.js';
|
|
11
|
+
|
|
12
|
+
const ESC = {
|
|
13
|
+
up: n => `\x1b[${n}A`,
|
|
14
|
+
clearDown: '\x1b[J',
|
|
15
|
+
hideCursor: '\x1b[?25l',
|
|
16
|
+
showCursor: '\x1b[?25h',
|
|
17
|
+
clearLine: '\x1b[2K\r',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const strip = s => s.replace(/\x1b\[[0-9;]*m/g, '');
|
|
21
|
+
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Launch a live-updating QR preview input.
|
|
25
|
+
* Returns a Promise that resolves with the final string on Enter.
|
|
26
|
+
*/
|
|
27
|
+
export function liveInput({ label = 'Enter text', errorLevel = 'M', gradient = 'none' } = {}) {
|
|
28
|
+
return new Promise((resolve, reject) => {
|
|
29
|
+
let input = '';
|
|
30
|
+
let lastHeight = 0;
|
|
31
|
+
let debounce = null;
|
|
32
|
+
let lastVersion = 0;
|
|
33
|
+
|
|
34
|
+
// ── Frame builder ─────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
const buildLines = (text) => {
|
|
37
|
+
const lines = [];
|
|
38
|
+
const termCols = Math.min(process.stdout.columns || 80, 100);
|
|
39
|
+
|
|
40
|
+
if (!text) {
|
|
41
|
+
// Ghost placeholder: exact dimensions of a version-3 QR
|
|
42
|
+
const boxW = 33;
|
|
43
|
+
const boxH = 17;
|
|
44
|
+
const pad = 4;
|
|
45
|
+
const p = ' '.repeat(pad);
|
|
46
|
+
|
|
47
|
+
lines.push('');
|
|
48
|
+
lines.push(p + chalk.rgb(30, 40, 60)('┌' + '─'.repeat(boxW + 2) + '┐'));
|
|
49
|
+
for (let i = 0; i < boxH; i++) {
|
|
50
|
+
if (i === Math.floor(boxH / 2) - 1) {
|
|
51
|
+
const msg = ' Enter text to generate QR ';
|
|
52
|
+
const side = Math.floor((boxW + 2 - msg.length) / 2);
|
|
53
|
+
lines.push(
|
|
54
|
+
p + chalk.rgb(30,40,60)('│') +
|
|
55
|
+
' '.repeat(side) +
|
|
56
|
+
chalk.rgb(60, 80, 120).italic(msg) +
|
|
57
|
+
' '.repeat(boxW + 2 - side - msg.length) +
|
|
58
|
+
chalk.rgb(30,40,60)('│')
|
|
59
|
+
);
|
|
60
|
+
} else if (i === Math.floor(boxH / 2) + 1) {
|
|
61
|
+
const hint = ' ▀▄ half-block engine ready ';
|
|
62
|
+
const hside = Math.floor((boxW + 2 - hint.length) / 2);
|
|
63
|
+
lines.push(
|
|
64
|
+
p + chalk.rgb(30,40,60)('│') +
|
|
65
|
+
' '.repeat(hside) +
|
|
66
|
+
chalk.rgb(40, 60, 90).dim(hint) +
|
|
67
|
+
' '.repeat(boxW + 2 - hside - hint.length) +
|
|
68
|
+
chalk.rgb(30,40,60)('│')
|
|
69
|
+
);
|
|
70
|
+
} else {
|
|
71
|
+
// Subtle scanline pattern
|
|
72
|
+
const dotRow = (i % 4 === 0)
|
|
73
|
+
? chalk.rgb(20, 30, 50)('·'.repeat(boxW + 2))
|
|
74
|
+
: ' '.repeat(boxW + 2);
|
|
75
|
+
lines.push(p + chalk.rgb(30,40,60)('│') + dotRow + chalk.rgb(30,40,60)('│'));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
lines.push(p + chalk.rgb(30, 40, 60)('└' + '─'.repeat(boxW + 2) + '┘'));
|
|
79
|
+
lines.push('');
|
|
80
|
+
} else {
|
|
81
|
+
try {
|
|
82
|
+
const modules = createMatrix(text, { errorLevel });
|
|
83
|
+
const qrLines = renderHalfBlock(modules, { gradient });
|
|
84
|
+
const qrWidth = strip(qrLines[0] ?? '').length;
|
|
85
|
+
const pad = Math.max(4, Math.floor((termCols - qrWidth) / 2));
|
|
86
|
+
const p = ' '.repeat(pad);
|
|
87
|
+
const v = getQrVersion(modules.size);
|
|
88
|
+
|
|
89
|
+
// Version bump badge (flash when version increases)
|
|
90
|
+
const vChanged = v !== lastVersion;
|
|
91
|
+
lastVersion = v;
|
|
92
|
+
|
|
93
|
+
const vBadge = vChanged
|
|
94
|
+
? chalk.bgCyan.bold.black(` QR v${v} `) + chalk.cyan(' ◄ upgraded')
|
|
95
|
+
: chalk.bgRgb(20,20,50).rgb(80,160,255)(` QR v${v} `);
|
|
96
|
+
|
|
97
|
+
const capBadge = chalk.dim(`${modules.size}×${modules.size}`);
|
|
98
|
+
const lenBadge = text.length > 0
|
|
99
|
+
? chalk.dim(`${text.length} chars`)
|
|
100
|
+
: '';
|
|
101
|
+
|
|
102
|
+
lines.push('');
|
|
103
|
+
lines.push(
|
|
104
|
+
p + vBadge +
|
|
105
|
+
chalk.dim(' · ') + capBadge +
|
|
106
|
+
chalk.dim(' · ') + lenBadge
|
|
107
|
+
);
|
|
108
|
+
lines.push('');
|
|
109
|
+
|
|
110
|
+
for (const line of qrLines) lines.push(p + line);
|
|
111
|
+
lines.push('');
|
|
112
|
+
} catch (e) {
|
|
113
|
+
lines.push('');
|
|
114
|
+
lines.push(chalk.red(' ⚠ Input exceeds QR capacity — try shortening the text'));
|
|
115
|
+
lines.push('');
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Input field
|
|
120
|
+
const promptPrefix = chalk.bold.cyan(' › ') + chalk.bold.white(label + ':') + ' ';
|
|
121
|
+
const cursor = chalk.bgCyan.black(' ');
|
|
122
|
+
lines.push(promptPrefix + chalk.white(text) + cursor);
|
|
123
|
+
lines.push('');
|
|
124
|
+
|
|
125
|
+
return lines;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// ── Draw / clear ──────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
const draw = (lines) => {
|
|
131
|
+
for (const line of lines) process.stdout.write(line + '\n');
|
|
132
|
+
lastHeight = lines.length;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const clear = () => {
|
|
136
|
+
if (lastHeight > 0) {
|
|
137
|
+
process.stdout.write(ESC.up(lastHeight) + ESC.clearDown);
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const redraw = () => {
|
|
142
|
+
if (debounce) clearTimeout(debounce);
|
|
143
|
+
debounce = setTimeout(() => {
|
|
144
|
+
clear();
|
|
145
|
+
draw(buildLines(input));
|
|
146
|
+
}, 16);
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
// ── Keypress handler ──────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
const onData = (key) => {
|
|
152
|
+
if (key === '\r' || key === '\n') {
|
|
153
|
+
if (debounce) clearTimeout(debounce);
|
|
154
|
+
clear();
|
|
155
|
+
process.stdout.write(
|
|
156
|
+
chalk.bold.cyan(' › ') +
|
|
157
|
+
chalk.bold.white(label + ':') + ' ' +
|
|
158
|
+
chalk.white(input) + '\n'
|
|
159
|
+
);
|
|
160
|
+
done(null, input);
|
|
161
|
+
|
|
162
|
+
} else if (key === '\u0003') {
|
|
163
|
+
done(new Error('Cancelled'));
|
|
164
|
+
|
|
165
|
+
} else if (key === '\u007f' || key === '\b') {
|
|
166
|
+
input = input.slice(0, -1);
|
|
167
|
+
redraw();
|
|
168
|
+
|
|
169
|
+
} else if (key === '\u001b') {
|
|
170
|
+
input = '';
|
|
171
|
+
redraw();
|
|
172
|
+
|
|
173
|
+
} else if (key.charCodeAt(0) >= 32 && !key.startsWith('\x1b')) {
|
|
174
|
+
input += key;
|
|
175
|
+
redraw();
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const done = (err, result) => {
|
|
180
|
+
process.stdin.removeListener('data', onData);
|
|
181
|
+
try {
|
|
182
|
+
process.stdin.setRawMode(false);
|
|
183
|
+
process.stdin.pause();
|
|
184
|
+
} catch {}
|
|
185
|
+
process.stdout.write(ESC.showCursor);
|
|
186
|
+
if (err) reject(err);
|
|
187
|
+
else resolve(result);
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
// ── Boot ──────────────────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
process.stdout.write(ESC.hideCursor);
|
|
193
|
+
process.stdin.setRawMode(true);
|
|
194
|
+
process.stdin.resume();
|
|
195
|
+
process.stdin.setEncoding('utf8');
|
|
196
|
+
process.stdin.on('data', onData);
|
|
197
|
+
draw(buildLines(input));
|
|
198
|
+
});
|
|
199
|
+
}
|
package/src/luma.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Luma Detection via OSC 11
|
|
3
|
+
*
|
|
4
|
+
* Sends the OSC 11 escape sequence to query the terminal's actual background
|
|
5
|
+
* color, then computes its luminance. Returns { r, g, b, luminance, isLight }
|
|
6
|
+
* or null if the terminal doesn't support it (with a 400ms timeout).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export async function detectTerminalBgColor() {
|
|
10
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) return null;
|
|
11
|
+
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
let buffer = '';
|
|
14
|
+
|
|
15
|
+
const timeout = setTimeout(() => {
|
|
16
|
+
cleanup();
|
|
17
|
+
resolve(null);
|
|
18
|
+
}, 400);
|
|
19
|
+
|
|
20
|
+
const onData = (chunk) => {
|
|
21
|
+
buffer += chunk;
|
|
22
|
+
// OSC 11 response format: \x1b]11;rgb:RRRR/GGGG/BBBB\x07
|
|
23
|
+
// Some terminals send \x1b\\ as terminator instead of \x07
|
|
24
|
+
const match = buffer.match(/\x1b\]11;rgb:([0-9a-fA-F]+)\/([0-9a-fA-F]+)\/([0-9a-fA-F]+)/);
|
|
25
|
+
if (match) {
|
|
26
|
+
cleanup();
|
|
27
|
+
// Values are 16-bit (0000–FFFF); take the high byte for 0–255
|
|
28
|
+
const r = parseInt(match[1].slice(0, 2), 16);
|
|
29
|
+
const g = parseInt(match[2].slice(0, 2), 16);
|
|
30
|
+
const b = parseInt(match[3].slice(0, 2), 16);
|
|
31
|
+
// WCAG relative luminance
|
|
32
|
+
const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
33
|
+
resolve({ r, g, b, luminance, isLight: luminance > 127 });
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const cleanup = () => {
|
|
38
|
+
clearTimeout(timeout);
|
|
39
|
+
try {
|
|
40
|
+
process.stdin.removeListener('data', onData);
|
|
41
|
+
process.stdin.setRawMode(false);
|
|
42
|
+
process.stdin.pause();
|
|
43
|
+
} catch {}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
process.stdin.setRawMode(true);
|
|
48
|
+
process.stdin.resume();
|
|
49
|
+
process.stdin.setEncoding('latin1');
|
|
50
|
+
process.stdin.on('data', onData);
|
|
51
|
+
// Send OSC 11 query
|
|
52
|
+
process.stdout.write('\x1b]11;?\x07');
|
|
53
|
+
} catch {
|
|
54
|
+
cleanup();
|
|
55
|
+
resolve(null);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Matrix Rain Animation
|
|
3
|
+
*
|
|
4
|
+
* After the QR appears, cascading green "rain droplets" sweep down each
|
|
5
|
+
* column before the code settles to its final gradient.
|
|
6
|
+
* Contrast is preserved: each droplet is bright (#00FF41), which has
|
|
7
|
+
* sufficient luminance against dark terminal backgrounds.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { buildParticleData } from './engine.js';
|
|
11
|
+
|
|
12
|
+
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {object} modules - QR modules from createMatrix()
|
|
16
|
+
* @param {object} options
|
|
17
|
+
* @param {string} options.gradient - Final color gradient
|
|
18
|
+
* @param {number} options.duration - Rain duration in ms (default 2000)
|
|
19
|
+
* @param {number} options.fps - Frames per second (default 24)
|
|
20
|
+
*/
|
|
21
|
+
export async function matrixRainQR(modules, { gradient = 'none', duration = 2000, fps = 24 } = {}) {
|
|
22
|
+
const { particles, displayRows, totalCols } = buildParticleData(modules, { gradient });
|
|
23
|
+
const frameMs = Math.floor(1000 / fps);
|
|
24
|
+
const frameCount = Math.floor(duration / frameMs);
|
|
25
|
+
|
|
26
|
+
// Per-column rain phase offset (0–1, cycles continuously)
|
|
27
|
+
const phases = Array.from({ length: totalCols }, () => Math.random());
|
|
28
|
+
|
|
29
|
+
// Build a lookup for quick per-cell access: fb[row][col] = particle data
|
|
30
|
+
const lookup = Array.from({ length: displayRows }, () => new Array(totalCols).fill(null));
|
|
31
|
+
for (const p of particles) lookup[p.targetRow][p.targetCol] = p;
|
|
32
|
+
|
|
33
|
+
process.stdout.write('\x1b[?25l');
|
|
34
|
+
|
|
35
|
+
// Print initial static frame first (so there's no blank before rain starts)
|
|
36
|
+
renderStatic(lookup, displayRows, totalCols, phases, 0);
|
|
37
|
+
|
|
38
|
+
for (let f = 0; f < frameCount; f++) {
|
|
39
|
+
const progress = f / frameCount; // 0 → 1
|
|
40
|
+
|
|
41
|
+
// Advance rain phases
|
|
42
|
+
for (let c = 0; c < totalCols; c++) {
|
|
43
|
+
phases[c] = (phases[c] + 0.04) % 1;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
process.stdout.write(`\x1b[${displayRows}A`);
|
|
47
|
+
|
|
48
|
+
for (let row = 0; row < displayRows; row++) {
|
|
49
|
+
let line = '', curColor = null;
|
|
50
|
+
const ty = displayRows > 1 ? row / (displayRows - 1) : 0;
|
|
51
|
+
|
|
52
|
+
for (let col = 0; col < totalCols; col++) {
|
|
53
|
+
const cell = lookup[row][col];
|
|
54
|
+
if (!cell) {
|
|
55
|
+
if (curColor) { line += '\x1b[0m'; curColor = null; }
|
|
56
|
+
line += ' ';
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Rain droplet: bright where phase matches row position
|
|
61
|
+
const phase = phases[col];
|
|
62
|
+
const relRow = ty;
|
|
63
|
+
const dist = Math.abs(((phase - relRow + 1.5) % 1) - 0.5); // wrapped distance
|
|
64
|
+
const droplet = Math.max(0, 1 - dist * 8);
|
|
65
|
+
|
|
66
|
+
// Fade rain out as progress → 1, revealing final gradient
|
|
67
|
+
const rainStr = droplet * (1 - progress);
|
|
68
|
+
|
|
69
|
+
const base = cell.color;
|
|
70
|
+
const rain = [0, 255, 65]; // matrix green
|
|
71
|
+
const r = Math.round(base[0] * (1 - rainStr) + rain[0] * rainStr);
|
|
72
|
+
const g = Math.round(base[1] * (1 - rainStr) + rain[1] * rainStr);
|
|
73
|
+
const b = Math.round(base[2] * (1 - rainStr) + rain[2] * rainStr);
|
|
74
|
+
|
|
75
|
+
const key = `${r},${g},${b}`;
|
|
76
|
+
if (key !== curColor) {
|
|
77
|
+
line += `\x1b[38;2;${r};${g};${b}m`;
|
|
78
|
+
curColor = key;
|
|
79
|
+
}
|
|
80
|
+
line += cell.char;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (curColor) line += '\x1b[0m';
|
|
84
|
+
process.stdout.write(' ' + line + '\n');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
await sleep(frameMs);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Final settle: reprint without rain
|
|
91
|
+
process.stdout.write(`\x1b[${displayRows}A`);
|
|
92
|
+
renderStatic(lookup, displayRows, totalCols, phases, 1);
|
|
93
|
+
|
|
94
|
+
process.stdout.write('\x1b[?25h');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function renderStatic(lookup, displayRows, totalCols, phases, _progress) {
|
|
98
|
+
for (let row = 0; row < displayRows; row++) {
|
|
99
|
+
let line = '', curColor = null;
|
|
100
|
+
for (let col = 0; col < totalCols; col++) {
|
|
101
|
+
const cell = lookup[row][col];
|
|
102
|
+
if (!cell) {
|
|
103
|
+
if (curColor) { line += '\x1b[0m'; curColor = null; }
|
|
104
|
+
line += ' ';
|
|
105
|
+
} else {
|
|
106
|
+
const [r, g, b] = cell.color;
|
|
107
|
+
const key = `${r},${g},${b}`;
|
|
108
|
+
if (key !== curColor) { line += `\x1b[38;2;${r};${g};${b}m`; curColor = key; }
|
|
109
|
+
line += cell.char;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (curColor) line += '\x1b[0m';
|
|
113
|
+
process.stdout.write(' ' + line + '\n');
|
|
114
|
+
}
|
|
115
|
+
}
|
package/src/particle.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Particle Fly-In Animation — "The Transformer Effect"
|
|
3
|
+
*
|
|
4
|
+
* Each dark QR module becomes a particle that starts at a random scatter
|
|
5
|
+
* position and flies into its correct place with cubic ease-out.
|
|
6
|
+
* A stagger delay (0–30% of duration) makes the arrival feel organic.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { buildParticleData } from './engine.js';
|
|
10
|
+
|
|
11
|
+
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Animate QR code appearing via particle fly-in.
|
|
15
|
+
*
|
|
16
|
+
* @param {object} modules - QR modules from createMatrix()
|
|
17
|
+
* @param {object} options
|
|
18
|
+
* @param {string} options.gradient - Gradient preset name
|
|
19
|
+
* @param {number} options.fps - Frames per second (default 30)
|
|
20
|
+
* @param {number} options.duration - Total animation ms (default 1100)
|
|
21
|
+
*/
|
|
22
|
+
export async function animateParticleFlyIn(modules, options = {}) {
|
|
23
|
+
const { gradient = 'none', fps = 30, duration = 1100 } = options;
|
|
24
|
+
const frameCount = Math.floor(duration / (1000 / fps));
|
|
25
|
+
const frameMs = Math.floor(1000 / fps);
|
|
26
|
+
|
|
27
|
+
const { particles, displayRows, totalCols } = buildParticleData(modules, { gradient });
|
|
28
|
+
|
|
29
|
+
// Assign each particle a random starting position + stagger delay
|
|
30
|
+
const rng = () => Math.random();
|
|
31
|
+
const seeded = particles.map(p => ({
|
|
32
|
+
...p,
|
|
33
|
+
startRow: rng() * displayRows,
|
|
34
|
+
startCol: (rng() - 0.5) * totalCols * 2 + totalCols / 2, // wider scatter on X
|
|
35
|
+
delay: rng() * 0.30, // 0–30% stagger
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
// Print blank placeholder
|
|
39
|
+
process.stdout.write('\x1b[?25l');
|
|
40
|
+
const blank = ' '.repeat(totalCols);
|
|
41
|
+
for (let i = 0; i < displayRows; i++) process.stdout.write(' ' + blank + '\n');
|
|
42
|
+
|
|
43
|
+
for (let f = 0; f <= frameCount; f++) {
|
|
44
|
+
const globalT = f / frameCount;
|
|
45
|
+
|
|
46
|
+
// Build framebuffer: 2D array of { char, color } | null
|
|
47
|
+
const fb = Array.from({ length: displayRows }, () => new Array(totalCols).fill(null));
|
|
48
|
+
|
|
49
|
+
for (const p of seeded) {
|
|
50
|
+
// Apply per-particle stagger
|
|
51
|
+
const localT = Math.max(0, (globalT - p.delay) / (1 - p.delay));
|
|
52
|
+
// Cubic ease-out
|
|
53
|
+
const e = 1 - Math.pow(1 - localT, 3);
|
|
54
|
+
|
|
55
|
+
const row = Math.round(p.startRow + (p.targetRow - p.startRow) * e);
|
|
56
|
+
const col = Math.round(p.startCol + (p.targetCol - p.startCol) * e);
|
|
57
|
+
|
|
58
|
+
if (row >= 0 && row < displayRows && col >= 0 && col < totalCols) {
|
|
59
|
+
fb[row][col] = { char: p.char, color: p.color };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Move cursor back to top of animation area
|
|
64
|
+
process.stdout.write(`\x1b[${displayRows}A`);
|
|
65
|
+
|
|
66
|
+
// Render framebuffer
|
|
67
|
+
for (const row of fb) {
|
|
68
|
+
let line = '', curColor = null;
|
|
69
|
+
for (const cell of row) {
|
|
70
|
+
if (!cell) {
|
|
71
|
+
if (curColor) { line += '\x1b[0m'; curColor = null; }
|
|
72
|
+
line += ' ';
|
|
73
|
+
} else {
|
|
74
|
+
const [r, g, b] = cell.color;
|
|
75
|
+
const key = `${r},${g},${b}`;
|
|
76
|
+
if (key !== curColor) {
|
|
77
|
+
line += `\x1b[38;2;${r};${g};${b}m`;
|
|
78
|
+
curColor = key;
|
|
79
|
+
}
|
|
80
|
+
line += cell.char;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (curColor) line += '\x1b[0m';
|
|
84
|
+
process.stdout.write(' ' + line + '\n');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
await sleep(frameMs);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
process.stdout.write('\x1b[?25h');
|
|
91
|
+
}
|