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.
@@ -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 };
@@ -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
+ }