keyvoid 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/CHANGELOG.md +15 -0
- package/CONTRIBUTING.md +42 -0
- package/LICENSE +21 -0
- package/README.md +233 -0
- package/bin/keyvoid.js +121 -0
- package/package.json +77 -0
- package/src/app.js +294 -0
- package/src/engine/permissions.js +107 -0
- package/src/engine/suppressor/helpers/linux-helper.py +192 -0
- package/src/engine/suppressor/helpers/macos-helper.swift +108 -0
- package/src/engine/suppressor/helpers/macos-pynput-helper.py +179 -0
- package/src/engine/suppressor/helpers/windows-helper.ps1 +144 -0
- package/src/engine/suppressor/index.js +306 -0
- package/src/scripts/postinstall.js +33 -0
- package/src/ui/components/counter.js +64 -0
- package/src/ui/components/header.js +102 -0
- package/src/ui/components/status-bar.js +71 -0
- package/src/ui/components/unvoid-button.js +78 -0
- package/src/ui/mouse.js +97 -0
- package/src/ui/renderer.js +113 -0
- package/src/ui/skins/arcade.js +530 -0
- package/src/ui/skins/cat.js +223 -0
- package/src/ui/skins/clean.js +155 -0
- package/src/ui/skins/hacker.js +194 -0
- package/src/ui/skins/prank.js +195 -0
- package/src/ui/skins/toddler.js +131 -0
- package/src/ui/skins/zen.js +169 -0
- package/src/ui/unlock-sequence.js +105 -0
- package/src/utils/big-digits.js +130 -0
- package/src/utils/colors.js +114 -0
- package/src/utils/terminal.js +119 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// ─── Big Counter Component ───────────────────────────────────────────
|
|
2
|
+
// Renders a large, visually striking keystroke counter.
|
|
3
|
+
|
|
4
|
+
import { renderBigNumber, bigNumberWidth, formatCount } from '../../utils/big-digits.js';
|
|
5
|
+
import { centerText } from '../../utils/terminal.js';
|
|
6
|
+
import { RESET, BOLD, interpolateColor } from '../../utils/colors.js';
|
|
7
|
+
|
|
8
|
+
// Render the counter as big digits with gradient coloring
|
|
9
|
+
export function renderCounter(count, cols, tick = 0, colorConfig = {}) {
|
|
10
|
+
const {
|
|
11
|
+
r1 = 0, g1 = 255, b1 = 224, // Start color (cyan)
|
|
12
|
+
r2 = 191, g2 = 64, b2 = 255, // End color (purple)
|
|
13
|
+
} = colorConfig;
|
|
14
|
+
|
|
15
|
+
const countStr = formatCount(count);
|
|
16
|
+
const bigLines = renderBigNumber(countStr);
|
|
17
|
+
const numWidth = bigNumberWidth(countStr);
|
|
18
|
+
const lines = [];
|
|
19
|
+
|
|
20
|
+
// Animated subtle pulse: brightness oscillates
|
|
21
|
+
const pulse = 0.85 + 0.15 * Math.sin(tick * 0.15);
|
|
22
|
+
|
|
23
|
+
for (let row = 0; row < bigLines.length; row++) {
|
|
24
|
+
const rawLine = bigLines[row];
|
|
25
|
+
let colored = '';
|
|
26
|
+
|
|
27
|
+
for (let j = 0; j < rawLine.length; j++) {
|
|
28
|
+
const ch = rawLine[j];
|
|
29
|
+
if (ch === ' ') {
|
|
30
|
+
colored += ' ';
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Horizontal gradient across the number
|
|
35
|
+
const t = j / Math.max(rawLine.length - 1, 1);
|
|
36
|
+
const r = Math.round((r1 + (r2 - r1) * t) * pulse);
|
|
37
|
+
const g = Math.round((g1 + (g2 - g1) * t) * pulse);
|
|
38
|
+
const b = Math.round((b1 + (b2 - b1) * t) * pulse);
|
|
39
|
+
|
|
40
|
+
colored += `\x1b[38;2;${r};${g};${b}m${BOLD}${ch}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
lines.push(centerText(colored, cols) + RESET);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return lines;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Render a small inline counter (for status bars etc)
|
|
50
|
+
export function renderSmallCounter(count, color = '\x1b[38;2;0;255;224m') {
|
|
51
|
+
return `${color}${BOLD}${formatCount(count)}${RESET}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Render the "KEYSTROKES BLOCKED" label below the counter
|
|
55
|
+
export function renderCounterLabel(cols, tick = 0, labelColor = '\x1b[38;2;100;100;130m') {
|
|
56
|
+
const label = '◆ KEYSTROKES BLOCKED ◆';
|
|
57
|
+
const phase = Math.floor(tick / 8) % 2;
|
|
58
|
+
|
|
59
|
+
// Subtle pulsing dot indicator
|
|
60
|
+
const dot = phase === 0 ? '●' : '○';
|
|
61
|
+
const fullLabel = `${labelColor}${dot} KEYSTROKES BLOCKED ${dot}`;
|
|
62
|
+
|
|
63
|
+
return centerText(fullLabel, cols) + RESET;
|
|
64
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// ─── Header Component ────────────────────────────────────────────────
|
|
2
|
+
// Renders the KEYVOID ASCII art title with animated gradient colors.
|
|
3
|
+
|
|
4
|
+
import figlet from 'figlet';
|
|
5
|
+
import gradient from 'gradient-string';
|
|
6
|
+
import { centerText, stripAnsi } from '../../utils/terminal.js';
|
|
7
|
+
import { RESET, BOLD, interpolateColor } from '../../utils/colors.js';
|
|
8
|
+
|
|
9
|
+
// Pre-render the figlet text (sync, cached)
|
|
10
|
+
let cachedTitle = null;
|
|
11
|
+
let cachedWidth = 0;
|
|
12
|
+
|
|
13
|
+
function getTitle() {
|
|
14
|
+
if (!cachedTitle) {
|
|
15
|
+
try {
|
|
16
|
+
cachedTitle = figlet.textSync('KEYVOID', {
|
|
17
|
+
font: 'ANSI Shadow',
|
|
18
|
+
horizontalLayout: 'fitted',
|
|
19
|
+
});
|
|
20
|
+
} catch {
|
|
21
|
+
// Fallback if font not available
|
|
22
|
+
try {
|
|
23
|
+
cachedTitle = figlet.textSync('KEYVOID', { font: 'Standard' });
|
|
24
|
+
} catch {
|
|
25
|
+
cachedTitle = ' K E Y V O I D ';
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
cachedWidth = Math.max(...cachedTitle.split('\n').map(l => l.length));
|
|
29
|
+
}
|
|
30
|
+
return cachedTitle;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Render the header with animated gradient
|
|
34
|
+
// `tick` is a frame counter for animation
|
|
35
|
+
export function renderHeader(cols, tick = 0) {
|
|
36
|
+
const title = getTitle();
|
|
37
|
+
const titleLines = title.split('\n');
|
|
38
|
+
const lines = [];
|
|
39
|
+
|
|
40
|
+
// Animated gradient that shifts over time
|
|
41
|
+
const phase = (tick * 0.03) % 1;
|
|
42
|
+
|
|
43
|
+
for (let i = 0; i < titleLines.length; i++) {
|
|
44
|
+
const raw = titleLines[i];
|
|
45
|
+
let colored = '';
|
|
46
|
+
|
|
47
|
+
for (let j = 0; j < raw.length; j++) {
|
|
48
|
+
const ch = raw[j];
|
|
49
|
+
if (ch === ' ') {
|
|
50
|
+
colored += ' ';
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Create a cycling gradient: cyan → purple → pink → cyan
|
|
55
|
+
const t = ((j / Math.max(raw.length, 1)) + phase) % 1;
|
|
56
|
+
let r, g, b;
|
|
57
|
+
|
|
58
|
+
if (t < 0.33) {
|
|
59
|
+
const p = t / 0.33;
|
|
60
|
+
r = Math.round(0 + 191 * p);
|
|
61
|
+
g = Math.round(255 - 191 * p);
|
|
62
|
+
b = Math.round(224 + 31 * p);
|
|
63
|
+
} else if (t < 0.66) {
|
|
64
|
+
const p = (t - 0.33) / 0.33;
|
|
65
|
+
r = Math.round(191 + 64 * p);
|
|
66
|
+
g = Math.round(64 + 56 * p);
|
|
67
|
+
b = Math.round(255 - 55 * p);
|
|
68
|
+
} else {
|
|
69
|
+
const p = (t - 0.66) / 0.34;
|
|
70
|
+
r = Math.round(255 - 255 * p);
|
|
71
|
+
g = Math.round(120 + 135 * p);
|
|
72
|
+
b = Math.round(200 + 24 * p);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
colored += `\x1b[38;2;${r};${g};${b}m${BOLD}${ch}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
lines.push(centerText(colored, cols) + RESET);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return lines;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Simpler static gradient header (fallback)
|
|
85
|
+
export function renderHeaderStatic(cols) {
|
|
86
|
+
const title = getTitle();
|
|
87
|
+
const titleLines = title.split('\n');
|
|
88
|
+
const lines = [];
|
|
89
|
+
|
|
90
|
+
const grad = gradient(['#00FFE0', '#BF40BF', '#FF78C8']);
|
|
91
|
+
|
|
92
|
+
for (const raw of titleLines) {
|
|
93
|
+
lines.push(centerText(grad(raw), cols));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return lines;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Get the number of lines the header takes up
|
|
100
|
+
export function getHeaderHeight() {
|
|
101
|
+
return getTitle().split('\n').length;
|
|
102
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// ─── Status Bar Component ────────────────────────────────────────────
|
|
2
|
+
// Bottom bar showing uptime, failsafe hint, OS info, and active skin.
|
|
3
|
+
|
|
4
|
+
import { RESET, BOLD, DIM } from '../../utils/colors.js';
|
|
5
|
+
import { centerText, horizontalLine, padEnd, visibleLength } from '../../utils/terminal.js';
|
|
6
|
+
import { getPlatformName } from '../../engine/permissions.js';
|
|
7
|
+
|
|
8
|
+
// Format duration in seconds to HH:MM:SS
|
|
9
|
+
function formatUptime(seconds) {
|
|
10
|
+
const h = Math.floor(seconds / 3600);
|
|
11
|
+
const m = Math.floor((seconds % 3600) / 60);
|
|
12
|
+
const s = Math.floor(seconds % 60);
|
|
13
|
+
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Render the status bar (2 lines: divider + info)
|
|
17
|
+
export function renderStatusBar(cols, state, tick = 0) {
|
|
18
|
+
const {
|
|
19
|
+
skinName = 'clean',
|
|
20
|
+
startTime = Date.now(),
|
|
21
|
+
suppressorActive = false,
|
|
22
|
+
failsafeProgress = 0,
|
|
23
|
+
} = state;
|
|
24
|
+
|
|
25
|
+
const lines = [];
|
|
26
|
+
const uptime = formatUptime((Date.now() - startTime) / 1000);
|
|
27
|
+
const platform = getPlatformName();
|
|
28
|
+
const dimColor = '\x1b[38;2;60;60;80m';
|
|
29
|
+
const accentColor = '\x1b[38;2;100;100;140m';
|
|
30
|
+
const activeColor = suppressorActive ? '\x1b[38;2;0;200;100m' : '\x1b[38;2;200;80;80m';
|
|
31
|
+
const activeText = suppressorActive ? '● LOCKED' : '○ UNLOCKED';
|
|
32
|
+
|
|
33
|
+
// Divider line
|
|
34
|
+
lines.push(`${dimColor}${horizontalLine(cols, '─')}${RESET}`);
|
|
35
|
+
|
|
36
|
+
// Info line
|
|
37
|
+
const left = `${accentColor} ⏱ ${uptime} ${dimColor}│${RESET} ${activeColor}${BOLD}${activeText}${RESET}`;
|
|
38
|
+
const center = `${dimColor}Hold ${BOLD}ESC+SPACE${RESET}${dimColor} for 5s to force-quit${RESET}`;
|
|
39
|
+
const right = `${accentColor}${platform} ${dimColor}│ ${accentColor}${skinName.toUpperCase()} ${RESET}`;
|
|
40
|
+
|
|
41
|
+
// Build the line with proper spacing
|
|
42
|
+
const leftLen = visibleLength(left);
|
|
43
|
+
const rightLen = visibleLength(right);
|
|
44
|
+
const centerLen = visibleLength(center);
|
|
45
|
+
const totalContent = leftLen + centerLen + rightLen;
|
|
46
|
+
const gaps = Math.max(0, cols - totalContent);
|
|
47
|
+
const leftGap = Math.floor(gaps / 2);
|
|
48
|
+
const rightGap = gaps - leftGap;
|
|
49
|
+
|
|
50
|
+
let infoLine = left + ' '.repeat(leftGap) + center + ' '.repeat(rightGap) + right;
|
|
51
|
+
|
|
52
|
+
lines.push(infoLine);
|
|
53
|
+
|
|
54
|
+
// Failsafe progress bar (only shown when ESC+SPACE is held)
|
|
55
|
+
if (failsafeProgress > 0) {
|
|
56
|
+
const barWidth = Math.min(40, cols - 4);
|
|
57
|
+
const filled = Math.floor(barWidth * failsafeProgress);
|
|
58
|
+
const empty = barWidth - filled;
|
|
59
|
+
const bar = `\x1b[38;2;255;80;80m${'█'.repeat(filled)}\x1b[38;2;60;60;80m${'░'.repeat(empty)}${RESET}`;
|
|
60
|
+
const pct = Math.floor(failsafeProgress * 100);
|
|
61
|
+
const label = ` FORCE QUIT: ${pct}%`;
|
|
62
|
+
lines.push(centerText(`${bar}${label}`, cols));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return lines;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Get the height of the status bar
|
|
69
|
+
export function getStatusBarHeight(state = {}) {
|
|
70
|
+
return state.failsafeProgress > 0 ? 3 : 2;
|
|
71
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// ─── UNVOID Button Component ─────────────────────────────────────────
|
|
2
|
+
// A large, prominent mouse-click-only button to exit the keyboard lock.
|
|
3
|
+
|
|
4
|
+
import { centerText, box } from '../../utils/terminal.js';
|
|
5
|
+
import { RESET, BOLD } from '../../utils/colors.js';
|
|
6
|
+
|
|
7
|
+
const BUTTON_WIDTH = 32;
|
|
8
|
+
const BUTTON_HEIGHT = 5;
|
|
9
|
+
|
|
10
|
+
// Render the UNVOID button
|
|
11
|
+
// Returns { lines: string[], row: number } where row is the Y position
|
|
12
|
+
// for mouse region registration
|
|
13
|
+
export function renderUnvoidButton(cols, row, tick = 0, hidden = false) {
|
|
14
|
+
if (hidden) return { lines: [], row, width: 0, height: 0, x: 0 };
|
|
15
|
+
|
|
16
|
+
const lines = [];
|
|
17
|
+
const x = Math.max(1, Math.floor((cols - BUTTON_WIDTH) / 2));
|
|
18
|
+
|
|
19
|
+
// Animated border glow
|
|
20
|
+
const phase = (tick * 0.08) % (Math.PI * 2);
|
|
21
|
+
const glow = Math.floor(100 + 155 * ((Math.sin(phase) + 1) / 2));
|
|
22
|
+
const borderColor = `\x1b[38;2;${glow};${Math.floor(glow * 0.4)};${Math.floor(glow * 0.4)}m`;
|
|
23
|
+
|
|
24
|
+
// Button content
|
|
25
|
+
const sublabel = 'mouse click only';
|
|
26
|
+
|
|
27
|
+
// Top border
|
|
28
|
+
lines.push(centerText(
|
|
29
|
+
`${borderColor}${BOLD}${box.topLeft}${'━'.repeat(BUTTON_WIDTH - 2)}${box.topRight}`,
|
|
30
|
+
cols
|
|
31
|
+
) + RESET);
|
|
32
|
+
|
|
33
|
+
// Empty line
|
|
34
|
+
lines.push(centerText(
|
|
35
|
+
`${borderColor}${box.vertical}${RESET}${' '.repeat(BUTTON_WIDTH - 2)}${borderColor}${box.vertical}`,
|
|
36
|
+
cols
|
|
37
|
+
) + RESET);
|
|
38
|
+
|
|
39
|
+
// Main label
|
|
40
|
+
// We avoid emojis here to ensure strict ASCII alignment across all terminals
|
|
41
|
+
const label = 'CLICK TO UNVOID';
|
|
42
|
+
const labelPadded = centerPad(label, BUTTON_WIDTH - 2);
|
|
43
|
+
lines.push(centerText(
|
|
44
|
+
`${borderColor}${box.vertical}${RESET}\x1b[38;2;255;80;80m${BOLD}${labelPadded}\x1b[0m${borderColor}${box.vertical}`,
|
|
45
|
+
cols
|
|
46
|
+
) + RESET);
|
|
47
|
+
|
|
48
|
+
// Sub label
|
|
49
|
+
const subPadded = centerPad(sublabel, BUTTON_WIDTH - 2);
|
|
50
|
+
lines.push(centerText(
|
|
51
|
+
`${borderColor}${box.vertical}${RESET}\x1b[38;2;120;100;100m${subPadded}${RESET}${borderColor}${box.vertical}`,
|
|
52
|
+
cols
|
|
53
|
+
) + RESET);
|
|
54
|
+
|
|
55
|
+
// Bottom border
|
|
56
|
+
lines.push(centerText(
|
|
57
|
+
`${borderColor}${BOLD}${box.bottomLeft}${'━'.repeat(BUTTON_WIDTH - 2)}${box.bottomRight}`,
|
|
58
|
+
cols
|
|
59
|
+
) + RESET);
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
lines,
|
|
63
|
+
row,
|
|
64
|
+
width: BUTTON_WIDTH,
|
|
65
|
+
height: BUTTON_HEIGHT,
|
|
66
|
+
x: Math.floor((cols - BUTTON_WIDTH) / 2) + 1,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Center-pad a string within a given width
|
|
71
|
+
function centerPad(str, width) {
|
|
72
|
+
const pad = Math.max(0, width - str.length);
|
|
73
|
+
const left = Math.floor(pad / 2);
|
|
74
|
+
const right = pad - left;
|
|
75
|
+
return ' '.repeat(left) + str + ' '.repeat(right);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export { BUTTON_WIDTH, BUTTON_HEIGHT };
|
package/src/ui/mouse.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// ─── Terminal Mouse Tracking ─────────────────────────────────────────
|
|
2
|
+
// Parses SGR extended mouse events from stdin and provides a clean
|
|
3
|
+
// event API for clickable regions.
|
|
4
|
+
|
|
5
|
+
import { EventEmitter } from 'events';
|
|
6
|
+
|
|
7
|
+
export class MouseTracker extends EventEmitter {
|
|
8
|
+
constructor() {
|
|
9
|
+
super();
|
|
10
|
+
this.regions = []; // { id, x, y, width, height, onClick }
|
|
11
|
+
this._boundHandler = this._handleData.bind(this);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Start listening for mouse events on stdin
|
|
15
|
+
start() {
|
|
16
|
+
process.stdin.on('data', this._boundHandler);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Stop listening
|
|
20
|
+
stop() {
|
|
21
|
+
process.stdin.removeListener('data', this._boundHandler);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Register a clickable region
|
|
25
|
+
addRegion(id, x, y, width, height, onClick) {
|
|
26
|
+
// Remove existing region with same id
|
|
27
|
+
this.regions = this.regions.filter(r => r.id !== id);
|
|
28
|
+
this.regions.push({ id, x, y, width, height, onClick });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Remove a region
|
|
32
|
+
removeRegion(id) {
|
|
33
|
+
this.regions = this.regions.filter(r => r.id !== id);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Clear all regions
|
|
37
|
+
clearRegions() {
|
|
38
|
+
this.regions = [];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Update a region's position (e.g., after resize)
|
|
42
|
+
updateRegion(id, x, y, width, height) {
|
|
43
|
+
const region = this.regions.find(r => r.id === id);
|
|
44
|
+
if (region) {
|
|
45
|
+
region.x = x;
|
|
46
|
+
region.y = y;
|
|
47
|
+
if (width !== undefined) region.width = width;
|
|
48
|
+
if (height !== undefined) region.height = height;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
_handleData(data) {
|
|
53
|
+
const str = data.toString();
|
|
54
|
+
|
|
55
|
+
// Parse SGR mouse events: \x1b[<Btn;X;YM (press) or \x1b[<Btn;X;Ym (release)
|
|
56
|
+
const regex = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g;
|
|
57
|
+
let match;
|
|
58
|
+
|
|
59
|
+
while ((match = regex.exec(str)) !== null) {
|
|
60
|
+
const btn = parseInt(match[1], 10);
|
|
61
|
+
const x = parseInt(match[2], 10);
|
|
62
|
+
const y = parseInt(match[3], 10);
|
|
63
|
+
const isPress = match[4] === 'M';
|
|
64
|
+
|
|
65
|
+
// btn: 0 = left click, 1 = middle, 2 = right
|
|
66
|
+
// btn >= 64 = scroll
|
|
67
|
+
const event = {
|
|
68
|
+
button: btn & 0x03, // 0=left, 1=middle, 2=right
|
|
69
|
+
x,
|
|
70
|
+
y,
|
|
71
|
+
isPress,
|
|
72
|
+
isRelease: !isPress,
|
|
73
|
+
isScroll: btn >= 64,
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
this.emit('mouse', event);
|
|
77
|
+
|
|
78
|
+
// Only process left-click presses
|
|
79
|
+
if (event.isPress && event.button === 0 && !event.isScroll) {
|
|
80
|
+
this.emit('click', event);
|
|
81
|
+
|
|
82
|
+
// Check if click is within any registered region
|
|
83
|
+
for (const region of this.regions) {
|
|
84
|
+
if (
|
|
85
|
+
x >= region.x && x < region.x + region.width &&
|
|
86
|
+
y >= region.y && y < region.y + region.height
|
|
87
|
+
) {
|
|
88
|
+
this.emit('region-click', { regionId: region.id, ...event });
|
|
89
|
+
if (region.onClick) {
|
|
90
|
+
region.onClick(event);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// ─── Full-Screen Terminal Renderer ───────────────────────────────────
|
|
2
|
+
// Manages the terminal lifecycle: alt screen, raw mode, cursor hiding,
|
|
3
|
+
// and provides a frame-based rendering API for the skins.
|
|
4
|
+
|
|
5
|
+
import cliCursor from 'cli-cursor';
|
|
6
|
+
import {
|
|
7
|
+
enterAltScreen, exitAltScreen, clearScreen,
|
|
8
|
+
hideCursor, showCursor, enableMouse, disableMouse,
|
|
9
|
+
moveTo, getSize, write, padEnd, centerText,
|
|
10
|
+
} from '../utils/terminal.js';
|
|
11
|
+
import { RESET } from '../utils/colors.js';
|
|
12
|
+
|
|
13
|
+
export class Renderer {
|
|
14
|
+
constructor() {
|
|
15
|
+
this.size = getSize();
|
|
16
|
+
this.running = false;
|
|
17
|
+
this.renderFn = null;
|
|
18
|
+
this.frameTimer = null;
|
|
19
|
+
this.fps = 15; // 15 fps is smooth enough for terminal
|
|
20
|
+
this.lastFrame = [];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Initialize the terminal for full-screen rendering
|
|
24
|
+
setup() {
|
|
25
|
+
this.size = getSize();
|
|
26
|
+
|
|
27
|
+
// Enter alternate screen buffer (preserves user's terminal)
|
|
28
|
+
write(enterAltScreen);
|
|
29
|
+
write(hideCursor);
|
|
30
|
+
write(clearScreen);
|
|
31
|
+
|
|
32
|
+
// Enable mouse tracking for UNVOID button
|
|
33
|
+
write(enableMouse);
|
|
34
|
+
|
|
35
|
+
// Put stdin in raw mode for mouse events + key detection
|
|
36
|
+
if (process.stdin.isTTY) {
|
|
37
|
+
process.stdin.setRawMode(true);
|
|
38
|
+
process.stdin.resume();
|
|
39
|
+
process.stdin.setEncoding('utf8');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Handle terminal resize
|
|
43
|
+
process.stdout.on('resize', () => {
|
|
44
|
+
this.size = getSize();
|
|
45
|
+
this.fullRender();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
this.running = true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Tear down: restore terminal state
|
|
52
|
+
teardown() {
|
|
53
|
+
this.running = false;
|
|
54
|
+
|
|
55
|
+
if (this.frameTimer) {
|
|
56
|
+
clearInterval(this.frameTimer);
|
|
57
|
+
this.frameTimer = null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Disable mouse tracking
|
|
61
|
+
write(disableMouse);
|
|
62
|
+
|
|
63
|
+
// Restore terminal
|
|
64
|
+
write(showCursor);
|
|
65
|
+
write(exitAltScreen);
|
|
66
|
+
write(RESET);
|
|
67
|
+
|
|
68
|
+
cliCursor.show();
|
|
69
|
+
|
|
70
|
+
if (process.stdin.isTTY) {
|
|
71
|
+
process.stdin.setRawMode(false);
|
|
72
|
+
process.stdin.pause();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Set the render function (called on each frame)
|
|
77
|
+
setRenderFunction(fn) {
|
|
78
|
+
this.renderFn = fn;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Start the render loop
|
|
82
|
+
startLoop() {
|
|
83
|
+
if (this.frameTimer) return;
|
|
84
|
+
this.fullRender();
|
|
85
|
+
this.frameTimer = setInterval(() => {
|
|
86
|
+
this.fullRender();
|
|
87
|
+
}, Math.floor(1000 / this.fps));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Force a full re-render
|
|
91
|
+
fullRender() {
|
|
92
|
+
if (!this.running || !this.renderFn) return;
|
|
93
|
+
|
|
94
|
+
const { cols, rows } = this.size;
|
|
95
|
+
const lines = this.renderFn(cols, rows);
|
|
96
|
+
|
|
97
|
+
// Build output: move to top-left and write all lines
|
|
98
|
+
let output = moveTo(1, 1);
|
|
99
|
+
for (let i = 0; i < rows; i++) {
|
|
100
|
+
const line = lines[i] || '';
|
|
101
|
+
output += padEnd(line, cols) + RESET;
|
|
102
|
+
if (i < rows - 1) output += '\n';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
write(output);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Get current terminal dimensions
|
|
109
|
+
getSize() {
|
|
110
|
+
this.size = getSize();
|
|
111
|
+
return this.size;
|
|
112
|
+
}
|
|
113
|
+
}
|