opencode-plugin-boops 1.0.0 → 2.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 +252 -38
- package/boops.default.toml +71 -0
- package/cli/browse +2122 -0
- package/index.ts +389 -36
- package/package.json +19 -2
- package/sounds.json +23289 -0
package/cli/browse
ADDED
|
@@ -0,0 +1,2122 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync, existsSync, mkdirSync, writeFileSync, appendFileSync } from "fs";
|
|
3
|
+
import { join, dirname } from "path";
|
|
4
|
+
import { fileURLToPath } from "url";
|
|
5
|
+
import { homedir } from "os";
|
|
6
|
+
import { spawn, exec } from "child_process";
|
|
7
|
+
import https from "https";
|
|
8
|
+
|
|
9
|
+
// Get the directory where this script is located
|
|
10
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
11
|
+
const __dirname = dirname(__filename);
|
|
12
|
+
|
|
13
|
+
// Load sounds from the bundled JSON file
|
|
14
|
+
// When installed: sounds.json is in same directory as script
|
|
15
|
+
// When in repo: sounds.json is in parent directory
|
|
16
|
+
let soundsPath = join(__dirname, "sounds.json");
|
|
17
|
+
if (!existsSync(soundsPath)) {
|
|
18
|
+
soundsPath = join(__dirname, "..", "sounds.json");
|
|
19
|
+
}
|
|
20
|
+
const sounds = JSON.parse(readFileSync(soundsPath, "utf-8"));
|
|
21
|
+
|
|
22
|
+
// Available OpenCode events
|
|
23
|
+
const availableEvents = [
|
|
24
|
+
"session.idle", "permission.asked", "session.error",
|
|
25
|
+
"command.executed", "file.edited", "file.watcher.updated",
|
|
26
|
+
"installation.updated", "lsp.client.diagnostics", "lsp.updated",
|
|
27
|
+
"message.part.removed", "message.part.updated", "message.removed", "message.updated",
|
|
28
|
+
"permission.replied", "server.connected",
|
|
29
|
+
"session.created", "session.compacted", "session.deleted", "session.diff", "session.status", "session.updated",
|
|
30
|
+
"todo.updated",
|
|
31
|
+
"tool.execute.before", "tool.execute.after",
|
|
32
|
+
"tui.prompt.append", "tui.command.execute", "tui.toast.show"
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
// Cache directory
|
|
36
|
+
const CACHE_DIR = "/tmp/opencode-boops/cache";
|
|
37
|
+
|
|
38
|
+
// Check if plugin is properly installed
|
|
39
|
+
// Plugin is installed if index.ts exists in node_modules or as a symlink in ~/.config/opencode/plugins
|
|
40
|
+
function isPluginInstalled() {
|
|
41
|
+
const configPluginPath = join(homedir(), ".config", "opencode", "plugins", "boops");
|
|
42
|
+
const nodeModulesPath = join(__dirname, "..", "index.ts");
|
|
43
|
+
|
|
44
|
+
// Check if running from ~/.config/opencode/plugins/boops (proper installation)
|
|
45
|
+
if (existsSync(join(configPluginPath, "index.ts"))) {
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Check if running from node_modules (npm install)
|
|
50
|
+
if (existsSync(nodeModulesPath)) {
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const PLUGIN_INSTALLED = isPluginInstalled();
|
|
58
|
+
|
|
59
|
+
// Load current boops.toml config
|
|
60
|
+
function loadCurrentConfig() {
|
|
61
|
+
const configPath = join(homedir(), ".config", "opencode", "boops.toml");
|
|
62
|
+
if (!existsSync(configPath)) {
|
|
63
|
+
return {};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const content = readFileSync(configPath, "utf-8");
|
|
68
|
+
const config = {};
|
|
69
|
+
|
|
70
|
+
// Simple TOML parser for our use case
|
|
71
|
+
const lines = content.split("\n");
|
|
72
|
+
let currentSection = null;
|
|
73
|
+
|
|
74
|
+
for (const line of lines) {
|
|
75
|
+
const trimmed = line.trim();
|
|
76
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
77
|
+
|
|
78
|
+
// Section header
|
|
79
|
+
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
|
80
|
+
currentSection = trimmed.slice(1, -1);
|
|
81
|
+
if (!config[currentSection]) config[currentSection] = {};
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Key = value (handle quoted keys like "session.error")
|
|
86
|
+
const match = trimmed.match(/^"?([^"=]+)"?\s*=\s*"?([^"]+)"?$/);
|
|
87
|
+
if (match && currentSection) {
|
|
88
|
+
const [, key, value] = match;
|
|
89
|
+
config[currentSection][key.trim()] = value.trim().replace(/"/g, "");
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return config;
|
|
94
|
+
} catch (e) {
|
|
95
|
+
return {};
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Save config to boops.toml
|
|
100
|
+
function saveConfig(config) {
|
|
101
|
+
if (!PLUGIN_INSTALLED) {
|
|
102
|
+
console.error("\n⚠️ Cannot save: Plugin not installed");
|
|
103
|
+
console.error(" Install with: npm install opencode-plugin-boops");
|
|
104
|
+
console.error(" Or add to your OpenCode config");
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const configPath = join(homedir(), ".config", "opencode", "boops.toml");
|
|
109
|
+
let content = "# OpenCode Boops Plugin Configuration\n\n[sounds]\n";
|
|
110
|
+
|
|
111
|
+
if (config.sounds) {
|
|
112
|
+
for (const [event, sound] of Object.entries(config.sounds)) {
|
|
113
|
+
content += `"${event}" = "${sound}"\n`;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
writeFileSync(configPath, content);
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Download and cache sound
|
|
122
|
+
async function downloadSound(url, id) {
|
|
123
|
+
mkdirSync(CACHE_DIR, { recursive: true });
|
|
124
|
+
const cachePath = join(CACHE_DIR, `${id}.ogg`);
|
|
125
|
+
|
|
126
|
+
if (existsSync(cachePath)) {
|
|
127
|
+
return cachePath;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return new Promise((resolve, reject) => {
|
|
131
|
+
const file = [];
|
|
132
|
+
https.get(url, (response) => {
|
|
133
|
+
response.on('data', (chunk) => file.push(chunk));
|
|
134
|
+
response.on('end', () => {
|
|
135
|
+
writeFileSync(cachePath, Buffer.concat(file));
|
|
136
|
+
resolve(cachePath);
|
|
137
|
+
});
|
|
138
|
+
}).on('error', reject);
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Track currently playing process
|
|
143
|
+
let currentPlayingProcess = null;
|
|
144
|
+
|
|
145
|
+
// Play sound using available player (non-blocking)
|
|
146
|
+
async function playSound(url, id, onComplete) {
|
|
147
|
+
// Stop any currently playing sound
|
|
148
|
+
if (currentPlayingProcess) {
|
|
149
|
+
try {
|
|
150
|
+
currentPlayingProcess.kill();
|
|
151
|
+
} catch (e) {
|
|
152
|
+
// Ignore errors if process already stopped
|
|
153
|
+
}
|
|
154
|
+
currentPlayingProcess = null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const cachePath = await downloadSound(url, id);
|
|
158
|
+
|
|
159
|
+
// Try different players
|
|
160
|
+
const players = [
|
|
161
|
+
{ cmd: 'ffplay', args: ['-nodisp', '-autoexit', '-loglevel', 'quiet', cachePath] },
|
|
162
|
+
{ cmd: 'mpv', args: ['--no-video', '--really-quiet', cachePath] },
|
|
163
|
+
{ cmd: 'cvlc', args: ['--play-and-exit', cachePath] },
|
|
164
|
+
{ cmd: 'afplay', args: [cachePath] },
|
|
165
|
+
{ cmd: 'paplay', args: [cachePath] }
|
|
166
|
+
];
|
|
167
|
+
|
|
168
|
+
for (const player of players) {
|
|
169
|
+
try {
|
|
170
|
+
const proc = spawn(player.cmd, player.args, {
|
|
171
|
+
stdio: 'ignore',
|
|
172
|
+
detached: false
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
currentPlayingProcess = proc;
|
|
176
|
+
|
|
177
|
+
// Call onComplete when sound finishes
|
|
178
|
+
proc.on('exit', () => {
|
|
179
|
+
if (currentPlayingProcess === proc) {
|
|
180
|
+
currentPlayingProcess = null;
|
|
181
|
+
if (onComplete) onComplete();
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
return true;
|
|
186
|
+
} catch (e) {
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Group sounds by category
|
|
195
|
+
function groupByCategory(soundsList) {
|
|
196
|
+
const groups = {
|
|
197
|
+
positive: [],
|
|
198
|
+
negative: [],
|
|
199
|
+
neutral: [],
|
|
200
|
+
human: [],
|
|
201
|
+
weird: []
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
soundsList.forEach(sound => {
|
|
205
|
+
const cat = sound.category || 'neutral';
|
|
206
|
+
if (groups[cat]) {
|
|
207
|
+
groups[cat].push(sound);
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
return groups;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Get currently configured sounds
|
|
215
|
+
function getCurrentSounds(config) {
|
|
216
|
+
const current = {};
|
|
217
|
+
|
|
218
|
+
for (const [section, values] of Object.entries(config)) {
|
|
219
|
+
if (values.sound) {
|
|
220
|
+
current[section] = values.sound;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return current;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Main TUI
|
|
228
|
+
async function browse() {
|
|
229
|
+
// If in non-interactive mode (piped output), just list all
|
|
230
|
+
if (!process.stdout.isTTY) {
|
|
231
|
+
sounds.forEach((sound) => {
|
|
232
|
+
console.log(`${sound.id} - ${sound.name}`);
|
|
233
|
+
});
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const config = loadCurrentConfig();
|
|
238
|
+
const currentSounds = getCurrentSounds(config);
|
|
239
|
+
const grouped = groupByCategory(sounds);
|
|
240
|
+
|
|
241
|
+
// Get all unique tags from sounds (sorted alphabetically)
|
|
242
|
+
const allTags = [...new Set(sounds.flatMap(s => s.tags || []))].sort();
|
|
243
|
+
|
|
244
|
+
// Color palette for tags (low brightness/contrast)
|
|
245
|
+
// Using 256-color palette - these are muted/dark colors
|
|
246
|
+
const tagColorsDim = [
|
|
247
|
+
60, // dark purple-blue
|
|
248
|
+
61, // dark blue-purple
|
|
249
|
+
62, // dark blue
|
|
250
|
+
63, // dark blue-cyan
|
|
251
|
+
64, // dark cyan
|
|
252
|
+
65, // dark cyan-green
|
|
253
|
+
66, // dark green-cyan
|
|
254
|
+
67, // dark green
|
|
255
|
+
68, // dark blue (current soft blue)
|
|
256
|
+
96, // dark purple
|
|
257
|
+
97, // dark violet
|
|
258
|
+
98, // dark blue-purple
|
|
259
|
+
99, // dark blue
|
|
260
|
+
100, // dark cyan-blue
|
|
261
|
+
];
|
|
262
|
+
|
|
263
|
+
// Brighter versions of the same colors for active state
|
|
264
|
+
const tagColorsBright = [
|
|
265
|
+
99, // bright purple-blue
|
|
266
|
+
105, // bright blue-purple
|
|
267
|
+
111, // bright blue
|
|
268
|
+
117, // bright blue-cyan
|
|
269
|
+
123, // bright cyan
|
|
270
|
+
87, // bright cyan-green
|
|
271
|
+
86, // bright green-cyan
|
|
272
|
+
120, // bright green
|
|
273
|
+
75, // bright blue
|
|
274
|
+
141, // bright purple
|
|
275
|
+
147, // bright violet
|
|
276
|
+
153, // bright blue-purple
|
|
277
|
+
159, // bright blue
|
|
278
|
+
195, // bright cyan-blue
|
|
279
|
+
];
|
|
280
|
+
|
|
281
|
+
// Map each tag to a color
|
|
282
|
+
const tagColorMap = {};
|
|
283
|
+
allTags.forEach((tag, i) => {
|
|
284
|
+
const colorIndex = i % tagColorsDim.length;
|
|
285
|
+
tagColorMap[tag] = {
|
|
286
|
+
dim: tagColorsDim[colorIndex],
|
|
287
|
+
bright: tagColorsBright[colorIndex]
|
|
288
|
+
};
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// TUI state
|
|
292
|
+
const state = {
|
|
293
|
+
selectedLine: 0,
|
|
294
|
+
scrollOffset: 0,
|
|
295
|
+
playingSound: null, // Track which sound is currently playing
|
|
296
|
+
showFullUrls: false, // Toggle for showing full URLs
|
|
297
|
+
searchMode: false, // Whether we're in search mode
|
|
298
|
+
searchQuery: '', // Current search query
|
|
299
|
+
selectedTagIndex: -1, // -1 means "all tags", >= 0 is index into allTags
|
|
300
|
+
searchResults: [], // Filtered results from last search
|
|
301
|
+
selectedTags: [], // Tags of the currently selected sound
|
|
302
|
+
targetSoundIndex: 0, // Target sound position (like column in text editor)
|
|
303
|
+
pickerMode: false, // Whether we're in event picker mode
|
|
304
|
+
pickerSelectedEvent: 0, // Selected event in picker
|
|
305
|
+
pickerSound: null, // The sound being assigned
|
|
306
|
+
hoveredTag: null, // Currently hovered tag on a sound line (for highlighting)
|
|
307
|
+
hoveredNavbarTagIndex: null, // Currently hovered tag in navbar (-1 for *, null for none, 0+ for tag index)
|
|
308
|
+
hoveredAttribution: false, // Whether the attribution link is hovered
|
|
309
|
+
hoveredUrl: null // Line index of sound whose URL is hovered
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
// Helper function to strip ANSI codes and get visible length
|
|
313
|
+
function getVisibleLength(str) {
|
|
314
|
+
return str
|
|
315
|
+
.replace(/\x1b\[[0-9;]*m/g, '') // Strip CSI sequences (colors)
|
|
316
|
+
.replace(/\x1b\]8;;[^\x1b]*\x1b\\/g, '') // Strip OSC 8 hyperlinks
|
|
317
|
+
.length;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Helper function to create a clickable hyperlink (OSC 8)
|
|
321
|
+
// This makes the terminal show a pointer cursor on hover
|
|
322
|
+
function makeHyperlink(url, text) {
|
|
323
|
+
return `\x1b]8;;${url}\x1b\\${text}\x1b]8;;\x1b\\`;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Helper function to ensure a string fits on one line
|
|
327
|
+
function ensureSingleLine(str, maxWidth) {
|
|
328
|
+
const visibleLen = getVisibleLength(str);
|
|
329
|
+
if (visibleLen <= maxWidth) {
|
|
330
|
+
return str;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Need to truncate - preserve ANSI codes at the end
|
|
334
|
+
const reset = '\x1b[0m';
|
|
335
|
+
// Strip all ANSI, truncate, add ellipsis
|
|
336
|
+
const stripped = str.replace(/\x1b\[[0-9;]*m/g, '');
|
|
337
|
+
const truncated = stripped.slice(0, maxWidth - 1) + '…';
|
|
338
|
+
return truncated + reset;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Helper function to render a single line
|
|
342
|
+
function renderLine(line, isSelected, termWidth, lineIndex, pickerWidth = 0) {
|
|
343
|
+
const reset = '\x1b[0m';
|
|
344
|
+
|
|
345
|
+
if (line.type === 'sound') {
|
|
346
|
+
// In picker mode: hide tags/URLs, dim non-selected sounds
|
|
347
|
+
if (state.pickerMode) {
|
|
348
|
+
const nameWithIcon = line.playIcon + line.name;
|
|
349
|
+
const nameWidth = 25;
|
|
350
|
+
let namePadded;
|
|
351
|
+
if (nameWithIcon.length > nameWidth) {
|
|
352
|
+
namePadded = nameWithIcon.slice(0, nameWidth - 1) + '…';
|
|
353
|
+
} else {
|
|
354
|
+
namePadded = nameWithIcon.padEnd(nameWidth);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const dimColor = isSelected ? '' : '\x1b[38;5;240m'; // Dim non-selected
|
|
358
|
+
const bgHighlight = isSelected ? '\x1b[48;5;235m' : '';
|
|
359
|
+
const availableWidth = termWidth - pickerWidth - line.indent.length;
|
|
360
|
+
const padding = ' '.repeat(Math.max(0, availableWidth - nameWidth));
|
|
361
|
+
|
|
362
|
+
console.log(`${line.indent}${bgHighlight}${dimColor}${namePadded}${reset}${padding}`);
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Normal mode: Sound lines: name + tags + URL (strictly one line, truncate if needed)
|
|
367
|
+
const nameWithIcon = line.playIcon + line.name;
|
|
368
|
+
const nameWidth = 25;
|
|
369
|
+
// Truncate if too long, pad if too short
|
|
370
|
+
let namePadded;
|
|
371
|
+
if (nameWithIcon.length > nameWidth) {
|
|
372
|
+
namePadded = nameWithIcon.slice(0, nameWidth - 1) + '…';
|
|
373
|
+
} else {
|
|
374
|
+
namePadded = nameWithIcon.padEnd(nameWidth);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const indentLength = line.indent.length;
|
|
378
|
+
const spacing = ' '; // spacing between tags and URL
|
|
379
|
+
|
|
380
|
+
// URL styling - manual since OSC 8 doesn't work well with dynamic content
|
|
381
|
+
const isUrlHovered = state.hoveredUrl === lineIndex;
|
|
382
|
+
const urlColor = isUrlHovered ? '\x1b[38;5;75m' : '\x1b[38;5;237m'; // bright blue on hover, dim gray normally
|
|
383
|
+
const urlUnderline = isUrlHovered ? '\x1b[4m' : ''; // Underline only on hover
|
|
384
|
+
|
|
385
|
+
// Use displayUrl for rendering (shortened version)
|
|
386
|
+
const urlToDisplay = line.displayUrl || line.url;
|
|
387
|
+
|
|
388
|
+
// Calculate available space for tags + URL
|
|
389
|
+
// Format: indent(2) + name(25) + tags + spacing(3) + url + margin(2)
|
|
390
|
+
const remainingWidth = termWidth - indentLength - nameWidth - spacing.length - 2;
|
|
391
|
+
|
|
392
|
+
// Build tag strings
|
|
393
|
+
const tagData = [];
|
|
394
|
+
if (line.soundTags && line.soundTags.length > 0) {
|
|
395
|
+
for (const tag of line.soundTags) {
|
|
396
|
+
const colors = tagColorMap[tag];
|
|
397
|
+
const useBright = state.selectedTags.includes(tag);
|
|
398
|
+
const isHovered = isSelected && state.hoveredTag === tag;
|
|
399
|
+
const color = useBright ? colors.bright : colors.dim;
|
|
400
|
+
const bgHighlight = '\x1b[48;5;235m';
|
|
401
|
+
const underline = useBright ? '\x1b[4m' : ''; // Underline if highlighted (matching selected sound)
|
|
402
|
+
const formatted = isHovered
|
|
403
|
+
? `${bgHighlight}\x1b[38;5;${color}m${underline}${tag}\x1b[0m`
|
|
404
|
+
: `\x1b[38;5;${color}m${underline}${tag}\x1b[0m`;
|
|
405
|
+
const visibleLength = tag.length; // just the tag, no trailing space
|
|
406
|
+
tagData.push({ formatted, visibleLength, tag });
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Calculate total tags length (with leading space if tags exist)
|
|
411
|
+
// Format: " tag1 tag2 tag3" (leading space + tags joined with spaces, no trailing space)
|
|
412
|
+
let tagsVisibleLength = 0;
|
|
413
|
+
if (tagData.length > 0) {
|
|
414
|
+
tagsVisibleLength = 1 + tagData.reduce((sum, t) => sum + t.visibleLength, 0) + (tagData.length - 1);
|
|
415
|
+
// 1 for leading space + sum of tag lengths + (n-1) spaces between tags
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
let tagsFormatted = '';
|
|
419
|
+
let urlFormatted = '';
|
|
420
|
+
|
|
421
|
+
// Minimum space reserved for URL
|
|
422
|
+
const minUrlLength = 15; // Shortened since we're showing just the ID now
|
|
423
|
+
|
|
424
|
+
// Check if tags + URL fit in available space
|
|
425
|
+
const urlVisibleLength = urlToDisplay.length;
|
|
426
|
+
|
|
427
|
+
if (tagsVisibleLength + urlVisibleLength > remainingWidth) {
|
|
428
|
+
// Need to truncate something
|
|
429
|
+
if (tagsVisibleLength + minUrlLength > remainingWidth) {
|
|
430
|
+
// Tags take too much space, truncate tags
|
|
431
|
+
const availableForTags = Math.max(0, remainingWidth - minUrlLength);
|
|
432
|
+
let usedLength = 1; // start with leading space
|
|
433
|
+
const truncatedTags = [];
|
|
434
|
+
|
|
435
|
+
for (let i = 0; i < tagData.length; i++) {
|
|
436
|
+
const tag = tagData[i];
|
|
437
|
+
const needSpace = i > 0 ? 1 : 0; // space before tag (except first)
|
|
438
|
+
const totalNeeded = usedLength + needSpace + tag.visibleLength;
|
|
439
|
+
|
|
440
|
+
if (totalNeeded + 2 <= availableForTags) { // +2 for " …"
|
|
441
|
+
truncatedTags.push(tag.formatted);
|
|
442
|
+
usedLength += needSpace + tag.visibleLength;
|
|
443
|
+
} else {
|
|
444
|
+
break;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (truncatedTags.length > 0) {
|
|
449
|
+
tagsFormatted = ' ' + truncatedTags.join(' ') + ' \x1b[38;5;240m…\x1b[0m';
|
|
450
|
+
tagsVisibleLength = usedLength + 2; // +2 for " …"
|
|
451
|
+
} else {
|
|
452
|
+
tagsFormatted = '';
|
|
453
|
+
tagsVisibleLength = 0;
|
|
454
|
+
}
|
|
455
|
+
} else {
|
|
456
|
+
// Tags fit, keep them all
|
|
457
|
+
if (tagData.length > 0) {
|
|
458
|
+
tagsFormatted = ' ' + tagData.map(t => t.formatted).join(' ');
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Truncate URL to fit remaining space
|
|
463
|
+
const availableForUrl = remainingWidth - tagsVisibleLength;
|
|
464
|
+
if (availableForUrl > 3) {
|
|
465
|
+
const truncatedUrl = '…' + urlToDisplay.slice(-(availableForUrl - 1));
|
|
466
|
+
urlFormatted = `${urlColor}${urlUnderline}${truncatedUrl}${reset}`;
|
|
467
|
+
} else {
|
|
468
|
+
urlFormatted = `${urlColor}${urlUnderline}…${reset}`;
|
|
469
|
+
}
|
|
470
|
+
} else {
|
|
471
|
+
// Everything fits
|
|
472
|
+
if (tagData.length > 0) {
|
|
473
|
+
tagsFormatted = ' ' + tagData.map(t => t.formatted).join(' ');
|
|
474
|
+
}
|
|
475
|
+
urlFormatted = `${urlColor}${urlUnderline}${urlToDisplay}${reset}`;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Build final output line - MUST be exactly termWidth visible characters
|
|
479
|
+
// Layout: indent + name + tags + [spacing to push URL to right] + URL + rightMargin
|
|
480
|
+
const rightMargin = 2; // Space before right edge
|
|
481
|
+
const urlVisibleLen = getVisibleLength(urlFormatted);
|
|
482
|
+
|
|
483
|
+
// Calculate spacing to right-align the URL
|
|
484
|
+
const usedSpace = indentLength + nameWidth + tagsVisibleLength + urlVisibleLen + rightMargin;
|
|
485
|
+
const middleSpacing = ' '.repeat(Math.max(1, termWidth - usedSpace));
|
|
486
|
+
|
|
487
|
+
let outputLine;
|
|
488
|
+
if (isSelected) {
|
|
489
|
+
// Apply background only to name (before tags), tags and URL render normally
|
|
490
|
+
const bgHighlight = '\x1b[48;5;235m';
|
|
491
|
+
const bgReset = '\x1b[0m';
|
|
492
|
+
outputLine = `${line.indent}${bgHighlight}${namePadded}${bgReset}${tagsFormatted}${middleSpacing}${urlFormatted}`;
|
|
493
|
+
} else {
|
|
494
|
+
outputLine = `${line.indent}${namePadded}${tagsFormatted}${middleSpacing}${urlFormatted}`;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Pad to full width to ensure we clear old content
|
|
498
|
+
const actualVisibleLen = indentLength + nameWidth + tagsVisibleLength + middleSpacing.length + urlVisibleLen;
|
|
499
|
+
const finalPadding = ' '.repeat(Math.max(0, termWidth - actualVisibleLen));
|
|
500
|
+
outputLine += finalPadding;
|
|
501
|
+
|
|
502
|
+
// DEBUG: Log ALL sounds to see if any have tags
|
|
503
|
+
if (line.name === 'blocker') {
|
|
504
|
+
appendFileSync('/tmp/browse-debug.log',
|
|
505
|
+
`BLOCKER DEBUG:\n` +
|
|
506
|
+
` tags: ${JSON.stringify(line.soundTags)}\n` +
|
|
507
|
+
` urlVisibleLength: ${urlVisibleLength}\n` +
|
|
508
|
+
` tagsVisibleLength: ${tagsVisibleLength}\n` +
|
|
509
|
+
` remainingWidth: ${remainingWidth}\n` +
|
|
510
|
+
` tagsVisibleLength + urlVisibleLength = ${tagsVisibleLength + urlVisibleLength}\n` +
|
|
511
|
+
` Does it fit? ${tagsVisibleLength + urlVisibleLength <= remainingWidth}\n` +
|
|
512
|
+
` tagsFormatted: "${tagsFormatted}"\n`
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
console.log(outputLine);
|
|
517
|
+
} else if (line.type === 'tag-result') {
|
|
518
|
+
const tagIcon = '🏷 ';
|
|
519
|
+
const tagText = `${tagIcon}${line.tag}`;
|
|
520
|
+
const tagColor = tagColorMap[line.tag];
|
|
521
|
+
const color = tagColor ? tagColor.bright : 99;
|
|
522
|
+
const availableWidth = termWidth - pickerWidth;
|
|
523
|
+
|
|
524
|
+
if (isSelected) {
|
|
525
|
+
const outputLine = `${line.indent}\x1b[48;5;235m\x1b[38;5;${color}m${tagText.padEnd(25)}${reset} \x1b[38;5;240m(select to filter by this tag)${reset}`;
|
|
526
|
+
console.log(ensureSingleLine(outputLine, availableWidth));
|
|
527
|
+
} else {
|
|
528
|
+
const outputLine = `${line.indent}\x1b[38;5;${color}m${tagText}${reset}`;
|
|
529
|
+
console.log(ensureSingleLine(outputLine, availableWidth));
|
|
530
|
+
}
|
|
531
|
+
} else if (line.type === 'category') {
|
|
532
|
+
const countColor = '\x1b[38;5;240m';
|
|
533
|
+
const availableWidth = termWidth - pickerWidth;
|
|
534
|
+
const padding = ' '.repeat(Math.max(0, availableWidth - line.text.length - line.count.length - 1));
|
|
535
|
+
|
|
536
|
+
if (isSelected) {
|
|
537
|
+
console.log(`\x1b[48;5;235m${line.text}${padding}${countColor}${line.count}${reset}`);
|
|
538
|
+
} else {
|
|
539
|
+
console.log(`${line.text}${padding}${countColor}${line.count}${reset}`);
|
|
540
|
+
}
|
|
541
|
+
} else if (line.type === 'config') {
|
|
542
|
+
const valueColor = '\x1b[38;5;240m';
|
|
543
|
+
const availableWidth = termWidth - pickerWidth;
|
|
544
|
+
const padding = ' '.repeat(Math.max(0, availableWidth - getVisibleLength(line.text + ' ' + line.value)));
|
|
545
|
+
console.log(`${line.color}${line.text} ${valueColor}${line.value}${reset}${padding}`);
|
|
546
|
+
} else if (line.type === 'tag-bar') {
|
|
547
|
+
const bgHighlight = '\x1b[48;5;235m';
|
|
548
|
+
const availableWidth = termWidth - pickerWidth;
|
|
549
|
+
|
|
550
|
+
// Virtualize the tag bar - build all lines in memory first
|
|
551
|
+
// Target: each line is exactly availableWidth visible characters
|
|
552
|
+
// Format: leftMargin (2 spaces) + content + padding = availableWidth
|
|
553
|
+
const leftMargin = ' '; // 2 spaces
|
|
554
|
+
const minRightMargin = 1; // At least 1 space on the right
|
|
555
|
+
const maxContentWidth = availableWidth - leftMargin.length - minRightMargin;
|
|
556
|
+
|
|
557
|
+
const tagBarLines = [];
|
|
558
|
+
let currentLineContent = ''; // Content without margins
|
|
559
|
+
let currentLineVisible = 0;
|
|
560
|
+
|
|
561
|
+
// Helper to finish a line and start a new one
|
|
562
|
+
const finishLine = () => {
|
|
563
|
+
if (currentLineContent.length > 0) {
|
|
564
|
+
// Pad the full line to exactly availableWidth
|
|
565
|
+
const fullLineVisible = leftMargin.length + currentLineVisible;
|
|
566
|
+
const paddingNeeded = availableWidth - fullLineVisible;
|
|
567
|
+
const padding = ' '.repeat(Math.max(0, paddingNeeded));
|
|
568
|
+
tagBarLines.push(leftMargin + currentLineContent + padding);
|
|
569
|
+
}
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
// Start first line with "Tags: [*]" or "Tags: *"
|
|
573
|
+
const headerText = 'Tags: ';
|
|
574
|
+
currentLineContent += headerText;
|
|
575
|
+
currentLineVisible += headerText.length;
|
|
576
|
+
|
|
577
|
+
const allSelected = state.selectedTagIndex === -1;
|
|
578
|
+
const allHovered = state.hoveredNavbarTagIndex === -1;
|
|
579
|
+
|
|
580
|
+
let starText;
|
|
581
|
+
let starVisibleLength;
|
|
582
|
+
if (allSelected) {
|
|
583
|
+
starText = `${bgHighlight}\x1b[1m[*]${reset}`;
|
|
584
|
+
starVisibleLength = 3; // [*]
|
|
585
|
+
} else if (allHovered) {
|
|
586
|
+
starText = `${bgHighlight}\x1b[2m*${reset}`;
|
|
587
|
+
starVisibleLength = 1; // *
|
|
588
|
+
} else {
|
|
589
|
+
starText = `\x1b[2m*${reset}`;
|
|
590
|
+
starVisibleLength = 1; // *
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
currentLineContent += starText + ' ';
|
|
594
|
+
currentLineVisible += starVisibleLength + 2; // star + 2 spaces
|
|
595
|
+
|
|
596
|
+
// Add all tags, wrapping when needed
|
|
597
|
+
for (let i = 0; i < line.tagItems.length; i++) {
|
|
598
|
+
const tag = line.tagItems[i];
|
|
599
|
+
const colors = tagColorMap[tag];
|
|
600
|
+
const isSelectedFilter = state.selectedTagIndex === i;
|
|
601
|
+
const isHighlighted = state.selectedTags.includes(tag);
|
|
602
|
+
const isHovered = state.hoveredNavbarTagIndex === i;
|
|
603
|
+
|
|
604
|
+
const useBright = isSelectedFilter || isHighlighted;
|
|
605
|
+
const color = useBright ? colors.bright : colors.dim;
|
|
606
|
+
|
|
607
|
+
let tagText;
|
|
608
|
+
let tagVisibleLength;
|
|
609
|
+
if (isSelectedFilter) {
|
|
610
|
+
tagText = `${bgHighlight}\x1b[38;5;${color}m[${tag}]${reset}`;
|
|
611
|
+
tagVisibleLength = tag.length + 2; // [tag]
|
|
612
|
+
} else if (isHovered) {
|
|
613
|
+
tagText = `${bgHighlight}\x1b[38;5;${color}m${tag}${reset}`;
|
|
614
|
+
tagVisibleLength = tag.length;
|
|
615
|
+
} else {
|
|
616
|
+
tagText = `\x1b[38;5;${color}m${tag}${reset}`;
|
|
617
|
+
tagVisibleLength = tag.length;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const totalTagLength = tagVisibleLength + 2; // tag + 2 trailing spaces
|
|
621
|
+
|
|
622
|
+
// Check if adding this tag would exceed max content width
|
|
623
|
+
if (currentLineVisible + totalTagLength > maxContentWidth) {
|
|
624
|
+
// Current line is full, finish it and start a new one
|
|
625
|
+
finishLine();
|
|
626
|
+
|
|
627
|
+
// Start new line with just this tag
|
|
628
|
+
currentLineContent = tagText + ' ';
|
|
629
|
+
currentLineVisible = totalTagLength;
|
|
630
|
+
} else {
|
|
631
|
+
// Add to current line
|
|
632
|
+
currentLineContent += tagText + ' ';
|
|
633
|
+
currentLineVisible += totalTagLength;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// Finish the last line
|
|
638
|
+
finishLine();
|
|
639
|
+
|
|
640
|
+
// Output all lines - they are guaranteed to be exactly availableWidth wide
|
|
641
|
+
tagBarLines.forEach(l => console.log(l));
|
|
642
|
+
} else if (line.type === 'separator') {
|
|
643
|
+
process.stdout.write('\x1b[2K\n'); // Clear line for separator
|
|
644
|
+
} else {
|
|
645
|
+
const availableWidth = termWidth - pickerWidth;
|
|
646
|
+
const outputLine = `${line.color}${line.text}${reset}`;
|
|
647
|
+
// Pad to full width to clear any old content
|
|
648
|
+
const visibleLen = getVisibleLength(outputLine);
|
|
649
|
+
const padding = ' '.repeat(Math.max(0, availableWidth - visibleLen));
|
|
650
|
+
console.log(outputLine + padding);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// Build display lines
|
|
655
|
+
function buildLines() {
|
|
656
|
+
const lines = [];
|
|
657
|
+
const indent = ' '; // Left padding for all content
|
|
658
|
+
const termWidth = process.stdout.columns || 80;
|
|
659
|
+
const reset = '\x1b[0m';
|
|
660
|
+
|
|
661
|
+
// Get filtered sound list first (we need the count for the header)
|
|
662
|
+
let soundList = sounds;
|
|
663
|
+
let displayItems = []; // Will contain both sounds and tag results
|
|
664
|
+
|
|
665
|
+
// If we have a search query, search globally and include matching tags
|
|
666
|
+
if (state.searchQuery) {
|
|
667
|
+
const query = state.searchQuery.toLowerCase();
|
|
668
|
+
|
|
669
|
+
// Search all sounds by name only (ignore current tag filter during search)
|
|
670
|
+
const matchingSounds = sounds.filter(sound =>
|
|
671
|
+
sound.name.toLowerCase().includes(query) ||
|
|
672
|
+
sound.id.toLowerCase().includes(query)
|
|
673
|
+
);
|
|
674
|
+
|
|
675
|
+
// Search for matching tags by tag name
|
|
676
|
+
const matchingTags = allTags.filter(tag => tag.toLowerCase().includes(query));
|
|
677
|
+
|
|
678
|
+
// Combine sounds and tags, mark them as different types
|
|
679
|
+
const soundItems = matchingSounds.map(sound => ({ type: 'sound', data: sound }));
|
|
680
|
+
const tagItems = matchingTags.map(tag => ({ type: 'tag', data: tag }));
|
|
681
|
+
|
|
682
|
+
// Merge and sort alphabetically
|
|
683
|
+
displayItems = [...soundItems, ...tagItems].sort((a, b) => {
|
|
684
|
+
const aName = a.type === 'sound' ? a.data.name : a.data;
|
|
685
|
+
const bName = b.type === 'sound' ? b.data.name : b.data;
|
|
686
|
+
return aName.localeCompare(bName);
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
// Save for when exiting search mode
|
|
690
|
+
if (!state.searchMode) {
|
|
691
|
+
displayItems = state.searchResults;
|
|
692
|
+
} else {
|
|
693
|
+
state.searchResults = displayItems;
|
|
694
|
+
}
|
|
695
|
+
} else {
|
|
696
|
+
// No search - filter by selected tag normally
|
|
697
|
+
if (state.selectedTagIndex >= 0) {
|
|
698
|
+
const selectedTag = allTags[state.selectedTagIndex];
|
|
699
|
+
soundList = soundList.filter(s => s.tags && s.tags.includes(selectedTag));
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// Sort alphabetically
|
|
703
|
+
soundList = [...soundList].sort((a, b) => a.name.localeCompare(b.name));
|
|
704
|
+
displayItems = soundList.map(sound => ({ type: 'sound', data: sound }));
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// Display items (sounds and tags)
|
|
708
|
+
for (const item of displayItems) {
|
|
709
|
+
if (item.type === 'sound') {
|
|
710
|
+
const sound = item.data;
|
|
711
|
+
const playIcon = state.playingSound === sound.id ? '▶ ' : ' ';
|
|
712
|
+
const soundTags = sound.tags ? [...sound.tags] : []; // Keep original order (by confidence)
|
|
713
|
+
|
|
714
|
+
// Extract just the ID from notificationsounds.com URLs
|
|
715
|
+
// Format: https://notificationsounds.com/storage/sounds/file-sounds-775-alarma.ogg
|
|
716
|
+
// Display: 775-alarma.ogg
|
|
717
|
+
let displayUrl = sound.url;
|
|
718
|
+
const match = sound.url.match(/file-sounds-(.+\.ogg)$/);
|
|
719
|
+
if (match) {
|
|
720
|
+
displayUrl = match[1]; // Just the ID part
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
lines.push({
|
|
724
|
+
type: 'sound',
|
|
725
|
+
playIcon,
|
|
726
|
+
name: sound.name,
|
|
727
|
+
soundTags,
|
|
728
|
+
url: sound.url, // Full URL for opening
|
|
729
|
+
displayUrl, // Shortened URL for display
|
|
730
|
+
indent: `${indent}`,
|
|
731
|
+
color: '\x1b[0m',
|
|
732
|
+
sound
|
|
733
|
+
});
|
|
734
|
+
} else if (item.type === 'tag') {
|
|
735
|
+
const tag = item.data;
|
|
736
|
+
lines.push({
|
|
737
|
+
type: 'tag-result',
|
|
738
|
+
tag,
|
|
739
|
+
indent: `${indent}`,
|
|
740
|
+
color: '\x1b[0m'
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// Now build the header with sound count
|
|
746
|
+
// Count sounds in displayItems (exclude tag-result items)
|
|
747
|
+
const filteredSoundCount = displayItems.filter(item => item.type === 'sound').length;
|
|
748
|
+
const totalSoundCount = sounds.length;
|
|
749
|
+
const countText = `[${filteredSoundCount}/${totalSoundCount}]`;
|
|
750
|
+
const countColor = '\x1b[38;5;240m'; // dim gray
|
|
751
|
+
|
|
752
|
+
const headerTitleText = 'OpenCode Boops';
|
|
753
|
+
const headerTitleColor = '\x1b[38;5;110m'; // dark soft blue (steel blue)
|
|
754
|
+
const headerTitle = `${indent}${headerTitleColor}${headerTitleText}${reset}`;
|
|
755
|
+
const githubUrl = 'https://github.com/towc/opencode-plugin-boops';
|
|
756
|
+
const githubColor = '\x1b[38;5;237m'; // same gray as URLs
|
|
757
|
+
const rightMargin = 1; // Space before right edge
|
|
758
|
+
|
|
759
|
+
// Build header with count and optionally GitHub URL
|
|
760
|
+
// Format: " OpenCode Boops [N/M] ...spacing... github-url "
|
|
761
|
+
const headerTitleWithCount = `${headerTitle} ${countColor}${countText}${reset}`;
|
|
762
|
+
const headerTitleVisibleLen = indent.length + headerTitleText.length + 1 + countText.length; // indent + title + space + count
|
|
763
|
+
|
|
764
|
+
const minSpacing = 3; // minimum spaces between title+count and link
|
|
765
|
+
const headerWithLink = termWidth >= headerTitleVisibleLen + minSpacing + githubUrl.length + rightMargin;
|
|
766
|
+
|
|
767
|
+
let headerText;
|
|
768
|
+
if (headerWithLink) {
|
|
769
|
+
const spacing = ' '.repeat(termWidth - headerTitleVisibleLen - githubUrl.length - rightMargin);
|
|
770
|
+
const githubLink = makeHyperlink(githubUrl, `${githubColor}${githubUrl}${reset}`);
|
|
771
|
+
headerText = `${headerTitleWithCount}${spacing}${githubLink}`;
|
|
772
|
+
} else {
|
|
773
|
+
headerText = headerTitleWithCount;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// Insert header at the beginning
|
|
777
|
+
lines.unshift({ type: 'separator', text: '' }); // separator before tag bar
|
|
778
|
+
lines.unshift({
|
|
779
|
+
type: 'tag-bar',
|
|
780
|
+
tagItems: allTags
|
|
781
|
+
}); // tag bar
|
|
782
|
+
lines.unshift({ type: 'separator', text: '' }); // separator after header
|
|
783
|
+
lines.unshift({ type: 'header', text: headerText, color: '\x1b[0m' }); // header (color already in text)
|
|
784
|
+
|
|
785
|
+
return lines;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// Render event picker mode
|
|
789
|
+
function renderPicker() {
|
|
790
|
+
const termHeight = process.stdout.rows || 24;
|
|
791
|
+
const termWidth = process.stdout.columns || 80;
|
|
792
|
+
const config = loadCurrentConfig();
|
|
793
|
+
|
|
794
|
+
process.stdout.write('\x1b[H'); // Move cursor to home without clearing
|
|
795
|
+
process.stdout.write('\x1b[?25l'); // Hide cursor
|
|
796
|
+
|
|
797
|
+
// Header
|
|
798
|
+
console.log(' \x1b[1mSelect Event Hook for: ' + state.pickerSound.name + '\x1b[0m');
|
|
799
|
+
console.log('');
|
|
800
|
+
|
|
801
|
+
// Instructions
|
|
802
|
+
if (PLUGIN_INSTALLED) {
|
|
803
|
+
console.log(' \x1b[38;5;240m↑/↓ navigate Enter play current Ctrl+S save & exit ESC cancel\x1b[0m');
|
|
804
|
+
} else {
|
|
805
|
+
console.log(' \x1b[38;5;240m↑/↓ navigate Enter play current ESC cancel \x1b[38;5;208m(save disabled - plugin not installed)\x1b[0m');
|
|
806
|
+
}
|
|
807
|
+
console.log('');
|
|
808
|
+
|
|
809
|
+
// Event list with current assignments
|
|
810
|
+
// Layout: header(1) + blank(1) + instructions(1) + blank(1) + events(?) + separator(1) + footer(1)
|
|
811
|
+
// Fixed lines: 4 (top) + 2 (bottom) = 6
|
|
812
|
+
const fixedLines = 6;
|
|
813
|
+
const maxVisibleEvents = Math.max(1, termHeight - fixedLines);
|
|
814
|
+
const scrollOffset = Math.max(0, Math.min(state.pickerSelectedEvent - Math.floor(maxVisibleEvents / 2), availableEvents.length - maxVisibleEvents));
|
|
815
|
+
const endIndex = Math.min(scrollOffset + maxVisibleEvents, availableEvents.length);
|
|
816
|
+
|
|
817
|
+
for (let i = scrollOffset; i < endIndex; i++) {
|
|
818
|
+
const event = availableEvents[i];
|
|
819
|
+
const isSelected = i === state.pickerSelectedEvent;
|
|
820
|
+
const currentSound = config.sounds ? config.sounds[event] : null;
|
|
821
|
+
|
|
822
|
+
const selectionBg = isSelected ? '\x1b[48;5;235m' : '';
|
|
823
|
+
const reset = '\x1b[0m';
|
|
824
|
+
|
|
825
|
+
let line = ` ${selectionBg}${event.padEnd(30)}${reset}`;
|
|
826
|
+
|
|
827
|
+
if (currentSound) {
|
|
828
|
+
// Try to find the sound in our database
|
|
829
|
+
const soundEntry = sounds.find(s => s.id === currentSound || s.name === currentSound);
|
|
830
|
+
if (soundEntry && soundEntry.tags && soundEntry.tags.length > 0) {
|
|
831
|
+
const tagStr = soundEntry.tags.slice(0, 3).join(' ');
|
|
832
|
+
line += ` \x1b[38;5;240m${soundEntry.name} [${tagStr}]${reset}`;
|
|
833
|
+
} else {
|
|
834
|
+
line += ` \x1b[38;5;240m${currentSound}${reset}`;
|
|
835
|
+
}
|
|
836
|
+
} else {
|
|
837
|
+
line += ` \x1b[38;5;237m(not set)${reset}`;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
console.log(ensureSingleLine(line, termWidth));
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// Fill remaining space to push footer to bottom
|
|
844
|
+
const actualEventsShown = endIndex - scrollOffset;
|
|
845
|
+
// linesUsed: header(1) + blank(1) + instructions(1) + blank(1) + events + separator(1) + footer(1)
|
|
846
|
+
const linesUsed = 4 + actualEventsShown + 2;
|
|
847
|
+
const remaining = Math.max(0, termHeight - linesUsed);
|
|
848
|
+
|
|
849
|
+
for (let i = 0; i < remaining; i++) {
|
|
850
|
+
process.stdout.write('\x1b[2K\n'); // Clear line
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// Footer (2 lines, but last line should not have trailing newline)
|
|
854
|
+
process.stdout.write(' ' + '─'.repeat(termWidth - 4) + '\n');
|
|
855
|
+
process.stdout.write(' \x1b[38;5;240mPress Ctrl+S to assign "' + state.pickerSound.name + '" to selected event\x1b[0m');
|
|
856
|
+
|
|
857
|
+
// Clear from cursor to end of screen
|
|
858
|
+
process.stdout.write('\x1b[J');
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function render() {
|
|
862
|
+
const lines = buildLines();
|
|
863
|
+
const termHeight = process.stdout.rows || 24;
|
|
864
|
+
const termWidth = process.stdout.columns || 80;
|
|
865
|
+
|
|
866
|
+
// Calculate picker box width (0 if not in picker mode)
|
|
867
|
+
const pickerWidth = state.pickerMode ? Math.min(40, Math.floor(termWidth * 0.4)) : 0;
|
|
868
|
+
|
|
869
|
+
// Calculate how many lines the tag bar will take (matching render logic EXACTLY)
|
|
870
|
+
const tagBarLine = lines.find(l => l.type === 'tag-bar');
|
|
871
|
+
let tagBarLineCount = 1;
|
|
872
|
+
if (tagBarLine) {
|
|
873
|
+
// Simulate EXACTLY the same wrapping logic as renderLine for tag-bar
|
|
874
|
+
const maxContentWidth = termWidth - 2 - 1; // leftMargin(2) + minRightMargin(1)
|
|
875
|
+
|
|
876
|
+
// Start with "Tags: [*] " or "Tags: * "
|
|
877
|
+
const headerText = 'Tags: ';
|
|
878
|
+
let currentLineVisible = headerText.length;
|
|
879
|
+
|
|
880
|
+
const allSelected = state.selectedTagIndex === -1;
|
|
881
|
+
const starVisibleLength = allSelected ? 3 : 1; // [*] or *
|
|
882
|
+
currentLineVisible += starVisibleLength + 2; // star + 2 spaces
|
|
883
|
+
|
|
884
|
+
let lineCount = 1;
|
|
885
|
+
|
|
886
|
+
for (let i = 0; i < tagBarLine.tagItems.length; i++) {
|
|
887
|
+
const tag = tagBarLine.tagItems[i];
|
|
888
|
+
const isSelectedFilter = state.selectedTagIndex === i;
|
|
889
|
+
const isHighlighted = state.selectedTags.includes(tag);
|
|
890
|
+
const isHovered = state.hoveredNavbarTagIndex === i;
|
|
891
|
+
|
|
892
|
+
let tagVisibleLength;
|
|
893
|
+
if (isSelectedFilter) {
|
|
894
|
+
tagVisibleLength = tag.length + 2; // [tag]
|
|
895
|
+
} else {
|
|
896
|
+
tagVisibleLength = tag.length;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
const totalTagLength = tagVisibleLength + 2; // tag + 2 trailing spaces
|
|
900
|
+
|
|
901
|
+
if (currentLineVisible + totalTagLength > maxContentWidth) {
|
|
902
|
+
// Would wrap to new line
|
|
903
|
+
lineCount++;
|
|
904
|
+
currentLineVisible = totalTagLength;
|
|
905
|
+
} else {
|
|
906
|
+
currentLineVisible += totalTagLength;
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
tagBarLineCount = lineCount;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
// Sticky header/tag bar for tall terminals
|
|
914
|
+
const stickyNavbar = termHeight >= 40;
|
|
915
|
+
const contentStartIndex = 4; // Header, sep, tagbar, sep are first 4 items
|
|
916
|
+
|
|
917
|
+
// Calculate available lines for sound items
|
|
918
|
+
// Footer section always takes: separator line(1) + controls(1) + blank line(1) = 3 lines
|
|
919
|
+
// Top blank line: 1 line
|
|
920
|
+
const fixedFooterLines = 3;
|
|
921
|
+
const topBlankLine = 1;
|
|
922
|
+
|
|
923
|
+
let availableContentLines;
|
|
924
|
+
if (stickyNavbar) {
|
|
925
|
+
// In sticky mode: header/tagbar are always visible (not part of scrollable area)
|
|
926
|
+
// Fixed header takes: blank(1) + header(1) + sep(1) + tag-bar(tagBarLineCount) + sep(1)
|
|
927
|
+
const fixedHeaderLines = topBlankLine + 2 + tagBarLineCount + 1;
|
|
928
|
+
availableContentLines = termHeight - fixedHeaderLines - fixedFooterLines;
|
|
929
|
+
} else {
|
|
930
|
+
// In normal mode: everything scrolls together, so header counts against content
|
|
931
|
+
const fixedHeaderLines = 2 + tagBarLineCount + 1;
|
|
932
|
+
availableContentLines = termHeight - topBlankLine - fixedFooterLines;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
const visibleLines = availableContentLines;
|
|
936
|
+
|
|
937
|
+
// Update selected tags for highlighting
|
|
938
|
+
const selectedLine = lines[state.selectedLine];
|
|
939
|
+
if (selectedLine?.type === 'sound' && selectedLine.soundTags) {
|
|
940
|
+
state.selectedTags = selectedLine.soundTags;
|
|
941
|
+
} else {
|
|
942
|
+
state.selectedTags = [];
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// Adjust scroll to keep selection visible
|
|
946
|
+
if (state.selectedLine < state.scrollOffset) {
|
|
947
|
+
state.scrollOffset = state.selectedLine;
|
|
948
|
+
}
|
|
949
|
+
if (state.selectedLine >= state.scrollOffset + visibleLines) {
|
|
950
|
+
state.scrollOffset = state.selectedLine - visibleLines + 1;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// In sticky mode, never scroll above the content start
|
|
954
|
+
if (stickyNavbar && state.scrollOffset < contentStartIndex) {
|
|
955
|
+
state.scrollOffset = contentStartIndex;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
// Move cursor to home position (top-left) without clearing
|
|
961
|
+
// (clearing causes flicker; we'll overwrite old content)
|
|
962
|
+
process.stdout.write('\x1b[H');
|
|
963
|
+
|
|
964
|
+
// DEBUG: Count lines as we output
|
|
965
|
+
let actualLinesOutput = 0;
|
|
966
|
+
let debugLog = '';
|
|
967
|
+
const origConsoleLog = console.log;
|
|
968
|
+
console.log = function(...args) {
|
|
969
|
+
actualLinesOutput++;
|
|
970
|
+
debugLog += `console.log #${actualLinesOutput}\n`;
|
|
971
|
+
return origConsoleLog.apply(console, args);
|
|
972
|
+
};
|
|
973
|
+
|
|
974
|
+
// Hide cursor during render, will show it at the end if in search mode
|
|
975
|
+
process.stdout.write('\x1b[?25l');
|
|
976
|
+
|
|
977
|
+
let start, end;
|
|
978
|
+
|
|
979
|
+
// Add blank line at the top
|
|
980
|
+
process.stdout.write('\x1b[2K\n');
|
|
981
|
+
|
|
982
|
+
if (stickyNavbar) {
|
|
983
|
+
// Always show header/tagbar, scroll only content
|
|
984
|
+
// Render header (lines 0-3)
|
|
985
|
+
for (let i = 0; i < contentStartIndex; i++) {
|
|
986
|
+
if (i < lines.length) {
|
|
987
|
+
renderLine(lines[i], i === state.selectedLine, termWidth, i, pickerWidth);
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// Check if we need scroll indicators
|
|
992
|
+
const needsTopIndicator = state.scrollOffset > contentStartIndex;
|
|
993
|
+
const potentialEnd = Math.max(contentStartIndex, state.scrollOffset) + visibleLines;
|
|
994
|
+
const needsBottomIndicator = potentialEnd < lines.length;
|
|
995
|
+
|
|
996
|
+
// Calculate how many content lines we can show (reserve space for indicators)
|
|
997
|
+
let availableForContent = visibleLines;
|
|
998
|
+
if (needsTopIndicator) availableForContent--;
|
|
999
|
+
if (needsBottomIndicator) availableForContent--;
|
|
1000
|
+
|
|
1001
|
+
// Show top scroll indicator if needed
|
|
1002
|
+
if (needsTopIndicator) {
|
|
1003
|
+
const indicator = '\x1b[38;5;240m ...\x1b[0m';
|
|
1004
|
+
const padding = ' '.repeat(Math.max(0, termWidth - 5)); // 5 = 2 spaces + 3 dots
|
|
1005
|
+
console.log(indicator + padding);
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
// Render scrollable content
|
|
1009
|
+
start = Math.max(contentStartIndex, state.scrollOffset);
|
|
1010
|
+
end = Math.min(start + availableForContent, lines.length);
|
|
1011
|
+
} else {
|
|
1012
|
+
// Normal mode: scroll everything including header/tagbar
|
|
1013
|
+
// Check if we need scroll indicators
|
|
1014
|
+
const needsTopIndicator = state.scrollOffset > 0;
|
|
1015
|
+
const potentialEnd = state.scrollOffset + visibleLines;
|
|
1016
|
+
const needsBottomIndicator = potentialEnd < lines.length;
|
|
1017
|
+
|
|
1018
|
+
// Calculate how many content lines we can show (reserve space for indicators)
|
|
1019
|
+
let availableForContent = visibleLines;
|
|
1020
|
+
if (needsTopIndicator) availableForContent--;
|
|
1021
|
+
if (needsBottomIndicator) availableForContent--;
|
|
1022
|
+
|
|
1023
|
+
// Show top scroll indicator if needed
|
|
1024
|
+
if (needsTopIndicator) {
|
|
1025
|
+
const indicator = '\x1b[38;5;240m ...\x1b[0m';
|
|
1026
|
+
const padding = ' '.repeat(Math.max(0, termWidth - 5)); // 5 = 2 spaces + 3 dots
|
|
1027
|
+
console.log(indicator + padding);
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
start = state.scrollOffset;
|
|
1031
|
+
end = Math.min(start + availableForContent, lines.length);
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
for (let i = start; i < end; i++) {
|
|
1035
|
+
const line = lines[i];
|
|
1036
|
+
const isSelected = i === state.selectedLine && (line.type === 'category' || line.type === 'sound' || line.type === 'tag-result');
|
|
1037
|
+
renderLine(line, isSelected, termWidth, i, pickerWidth);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// Show scroll indicator if there's content below
|
|
1041
|
+
if (end < lines.length) {
|
|
1042
|
+
const indicator = '\x1b[38;5;240m ...\x1b[0m';
|
|
1043
|
+
const padding = ' '.repeat(Math.max(0, termWidth - 5)); // 5 = 2 spaces + 3 dots
|
|
1044
|
+
console.log(indicator + padding);
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
// Fill remaining vertical space to push controls to bottom
|
|
1048
|
+
// Count what we actually rendered
|
|
1049
|
+
const hasTopIndicator = (stickyNavbar && state.scrollOffset > contentStartIndex) ||
|
|
1050
|
+
(!stickyNavbar && state.scrollOffset > 0);
|
|
1051
|
+
const hasBottomIndicator = end < lines.length;
|
|
1052
|
+
const contentLinesRendered = end - start;
|
|
1053
|
+
const indicatorsRendered = (hasTopIndicator ? 1 : 0) + (hasBottomIndicator ? 1 : 0);
|
|
1054
|
+
|
|
1055
|
+
let totalRendered;
|
|
1056
|
+
if (stickyNavbar) {
|
|
1057
|
+
// In sticky mode, we rendered: header(4 lines) + content + indicators
|
|
1058
|
+
// But visibleLines only counts content area, so don't include header
|
|
1059
|
+
totalRendered = contentLinesRendered + indicatorsRendered;
|
|
1060
|
+
} else {
|
|
1061
|
+
// In normal mode, content includes everything
|
|
1062
|
+
totalRendered = contentLinesRendered + indicatorsRendered;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// Fill the rest to keep footer at bottom
|
|
1066
|
+
const remainingLines = visibleLines - totalRendered;
|
|
1067
|
+
const clearLine = '\x1b[2K'; // Clear entire line
|
|
1068
|
+
|
|
1069
|
+
for (let i = 0; i < remainingLines; i++) {
|
|
1070
|
+
process.stdout.write(clearLine + '\n');
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
// Draw picker box overlay if in picker mode
|
|
1074
|
+
if (state.pickerMode && state.pickerSound) {
|
|
1075
|
+
const config = loadCurrentConfig();
|
|
1076
|
+
const boxX = termWidth - pickerWidth;
|
|
1077
|
+
const boxStartY = stickyNavbar ? (topBlankLine + 4) : (topBlankLine + 1); // Start below header in sticky mode, else near top
|
|
1078
|
+
const boxHeight = Math.min(availableEvents.length + 4, termHeight - boxStartY - fixedFooterLines);
|
|
1079
|
+
|
|
1080
|
+
// Calculate scroll offset for event list
|
|
1081
|
+
const maxVisibleEvents = boxHeight - 4; // Reserve space for header(2) + footer(2)
|
|
1082
|
+
const eventScrollOffset = Math.max(0, Math.min(state.pickerSelectedEvent - Math.floor(maxVisibleEvents / 2), availableEvents.length - maxVisibleEvents));
|
|
1083
|
+
const eventEndIndex = Math.min(eventScrollOffset + maxVisibleEvents, availableEvents.length);
|
|
1084
|
+
|
|
1085
|
+
// Draw each line of the picker box
|
|
1086
|
+
for (let row = 0; row < boxHeight; row++) {
|
|
1087
|
+
const screenY = boxStartY + row;
|
|
1088
|
+
process.stdout.write(`\x1b[${screenY};${boxX}H`); // Position cursor
|
|
1089
|
+
|
|
1090
|
+
if (row === 0) {
|
|
1091
|
+
// Top border with title
|
|
1092
|
+
const title = ` Assign to Event `;
|
|
1093
|
+
const titleStart = Math.floor((pickerWidth - title.length) / 2);
|
|
1094
|
+
const leftBorder = '┌' + '─'.repeat(titleStart - 1);
|
|
1095
|
+
const rightBorder = '─'.repeat(pickerWidth - titleStart - title.length - 1) + '┐';
|
|
1096
|
+
process.stdout.write(`\x1b[38;5;240m${leftBorder}\x1b[0m${title}\x1b[38;5;240m${rightBorder}\x1b[0m`);
|
|
1097
|
+
} else if (row === 1) {
|
|
1098
|
+
// Sound name
|
|
1099
|
+
const soundName = state.pickerSound.name.length > pickerWidth - 4
|
|
1100
|
+
? state.pickerSound.name.slice(0, pickerWidth - 7) + '...'
|
|
1101
|
+
: state.pickerSound.name;
|
|
1102
|
+
const padding = ' '.repeat(Math.max(0, pickerWidth - soundName.length - 4));
|
|
1103
|
+
process.stdout.write(`\x1b[38;5;240m│\x1b[0m \x1b[1m${soundName}\x1b[0m${padding} \x1b[38;5;240m│\x1b[0m`);
|
|
1104
|
+
} else if (row === 2) {
|
|
1105
|
+
// Separator
|
|
1106
|
+
process.stdout.write(`\x1b[38;5;240m├${'─'.repeat(pickerWidth - 2)}┤\x1b[0m`);
|
|
1107
|
+
} else if (row === boxHeight - 1) {
|
|
1108
|
+
// Bottom border
|
|
1109
|
+
process.stdout.write(`\x1b[38;5;240m└${'─'.repeat(pickerWidth - 2)}┘\x1b[0m`);
|
|
1110
|
+
} else {
|
|
1111
|
+
// Event list item
|
|
1112
|
+
const eventIndex = eventScrollOffset + (row - 3);
|
|
1113
|
+
if (eventIndex < eventEndIndex) {
|
|
1114
|
+
const event = availableEvents[eventIndex];
|
|
1115
|
+
const isSelected = eventIndex === state.pickerSelectedEvent;
|
|
1116
|
+
const currentSound = config.sounds ? config.sounds[event] : null;
|
|
1117
|
+
|
|
1118
|
+
const bg = isSelected ? '\x1b[48;5;235m' : '';
|
|
1119
|
+
const eventText = event.length > pickerWidth - 6
|
|
1120
|
+
? event.slice(0, pickerWidth - 9) + '...'
|
|
1121
|
+
: event.padEnd(pickerWidth - 6);
|
|
1122
|
+
|
|
1123
|
+
const indicator = currentSound ? '•' : ' ';
|
|
1124
|
+
const indicatorColor = currentSound ? '\x1b[38;5;76m' : '';
|
|
1125
|
+
|
|
1126
|
+
process.stdout.write(`\x1b[38;5;240m│\x1b[0m${bg}${indicatorColor}${indicator}\x1b[0m${bg} ${eventText} \x1b[0m\x1b[38;5;240m│\x1b[0m`);
|
|
1127
|
+
} else {
|
|
1128
|
+
// Empty line
|
|
1129
|
+
process.stdout.write(`\x1b[38;5;240m│${' '.repeat(pickerWidth - 2)}│\x1b[0m`);
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
// Move cursor back to the start of footer line (after all content + fill)
|
|
1135
|
+
// The cursor should be at the line where we want to draw the footer
|
|
1136
|
+
// We don't need to reposition since we drew the picker with absolute positioning
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
// Controls footer (2 lines, no trailing newline)
|
|
1140
|
+
const sep = '─'.repeat(termWidth);
|
|
1141
|
+
process.stdout.write(`\x1b[38;5;240m${sep}\x1b[0m\n`);
|
|
1142
|
+
|
|
1143
|
+
if (state.searchQuery || state.searchMode) {
|
|
1144
|
+
// Search mode: show search box on left, hints on right
|
|
1145
|
+
const searchBox = ` /${state.searchQuery}`;
|
|
1146
|
+
const searchHint = '\x1b[38;5;240m/ to refine esc to clear\x1b[0m';
|
|
1147
|
+
const searchBoxVisible = 2 + state.searchQuery.length; // " /" + query
|
|
1148
|
+
const hintVisible = getVisibleLength(searchHint);
|
|
1149
|
+
|
|
1150
|
+
// Calculate spacing to push hint to the right
|
|
1151
|
+
const spacing = ' '.repeat(Math.max(1, termWidth - searchBoxVisible - hintVisible - 1));
|
|
1152
|
+
|
|
1153
|
+
if (state.searchMode) {
|
|
1154
|
+
// Actively typing: write the line and position cursor
|
|
1155
|
+
process.stdout.write(searchBox);
|
|
1156
|
+
const cursorPos = searchBoxVisible; // Current cursor position after searchBox
|
|
1157
|
+
process.stdout.write(spacing + searchHint);
|
|
1158
|
+
|
|
1159
|
+
// Save cursor position
|
|
1160
|
+
process.stdout.write('\x1b[s');
|
|
1161
|
+
|
|
1162
|
+
// Write blank line and clear rest of screen
|
|
1163
|
+
process.stdout.write('\n\x1b[2K');
|
|
1164
|
+
process.stdout.write('\x1b[J');
|
|
1165
|
+
|
|
1166
|
+
// Restore cursor position and move to after search query
|
|
1167
|
+
process.stdout.write('\x1b[u');
|
|
1168
|
+
const moveBack = spacing.length + hintVisible;
|
|
1169
|
+
process.stdout.write(`\x1b[${moveBack}D`); // Move cursor left
|
|
1170
|
+
process.stdout.write('\x1b[?25h'); // Show cursor
|
|
1171
|
+
} else {
|
|
1172
|
+
// Viewing results: just write the line, no cursor
|
|
1173
|
+
process.stdout.write(searchBox + spacing + searchHint);
|
|
1174
|
+
|
|
1175
|
+
// Add blank line at the bottom
|
|
1176
|
+
process.stdout.write('\n\x1b[2K');
|
|
1177
|
+
|
|
1178
|
+
// Clear from cursor to end of screen to remove any leftover content
|
|
1179
|
+
process.stdout.write('\x1b[J');
|
|
1180
|
+
}
|
|
1181
|
+
} else {
|
|
1182
|
+
// Normal mode or picker mode: show controls with attribution
|
|
1183
|
+
let controls;
|
|
1184
|
+
if (state.pickerMode) {
|
|
1185
|
+
controls = PLUGIN_INSTALLED
|
|
1186
|
+
? ' \x1b[38;5;240m↑/↓ select event enter play ctrl+s save ←/esc close\x1b[0m'
|
|
1187
|
+
: ' \x1b[38;5;240m↑/↓ select event enter play ←/esc close \x1b[38;5;208m(save disabled)\x1b[0m';
|
|
1188
|
+
} else {
|
|
1189
|
+
controls = ' \x1b[38;5;240m↑/↓ navigate ←/→ tags / search s assign enter play q quit\x1b[0m';
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
// Attribution with manual hover/click
|
|
1193
|
+
const attributionText = 'Sounds from notificationsounds.com';
|
|
1194
|
+
const attributionUrl = 'https://notificationsounds.com';
|
|
1195
|
+
const attributionColor = state.hoveredAttribution ? '\x1b[38;5;75m' : '\x1b[38;5;237m'; // bright blue on hover, dim gray normally
|
|
1196
|
+
const attributionUnderline = state.hoveredAttribution ? '\x1b[4m' : ''; // Underline only on hover
|
|
1197
|
+
const attribution = `${attributionColor}${attributionUnderline}${attributionText}\x1b[0m`;
|
|
1198
|
+
|
|
1199
|
+
const controlsVisible = getVisibleLength(controls);
|
|
1200
|
+
const attributionVisible = attributionText.length;
|
|
1201
|
+
|
|
1202
|
+
// Check if we have space for attribution
|
|
1203
|
+
if (termWidth >= controlsVisible + attributionVisible + 3) {
|
|
1204
|
+
const spacing = ' '.repeat(termWidth - controlsVisible - attributionVisible - 1);
|
|
1205
|
+
process.stdout.write(controls + spacing + attribution);
|
|
1206
|
+
} else {
|
|
1207
|
+
process.stdout.write(controls);
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
// Add blank line at the bottom
|
|
1211
|
+
process.stdout.write('\n\x1b[2K');
|
|
1212
|
+
|
|
1213
|
+
// Clear from cursor to end of screen to remove any leftover content
|
|
1214
|
+
process.stdout.write('\x1b[J');
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
// DEBUG: Restore and report
|
|
1218
|
+
console.log = origConsoleLog;
|
|
1219
|
+
appendFileSync('/tmp/browse-debug.log',
|
|
1220
|
+
`console.log calls: ${actualLinesOutput}, termHeight: ${termHeight}, difference: ${actualLinesOutput - termHeight}\n` +
|
|
1221
|
+
`Expected: header(1) + sep(1) + tagbar(${tagBarLineCount}) + sep(1) + content + indicator + fill + footer(1) = ${termHeight}\n\n` +
|
|
1222
|
+
debugLog
|
|
1223
|
+
);
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
// Set up raw mode for keyboard input
|
|
1227
|
+
process.stdin.setRawMode(true);
|
|
1228
|
+
process.stdin.resume();
|
|
1229
|
+
process.stdin.setEncoding('utf8');
|
|
1230
|
+
|
|
1231
|
+
// Use alternate screen buffer to preserve user's terminal
|
|
1232
|
+
process.stdout.write('\x1b[?1049h'); // Enable alternate screen buffer
|
|
1233
|
+
process.stdout.write('\x1b[2J'); // Clear alternate screen
|
|
1234
|
+
process.stdout.write('\x1b[H'); // Move cursor to home
|
|
1235
|
+
|
|
1236
|
+
// Enable mouse reporting
|
|
1237
|
+
process.stdout.write('\x1b[?1000h'); // Enable mouse click events
|
|
1238
|
+
process.stdout.write('\x1b[?1002h'); // Enable mouse drag events
|
|
1239
|
+
process.stdout.write('\x1b[?1003h'); // Enable mouse movement tracking (hover)
|
|
1240
|
+
process.stdout.write('\x1b[?1015h'); // Enable extended mouse mode
|
|
1241
|
+
process.stdout.write('\x1b[?1006h'); // Enable SGR mouse mode
|
|
1242
|
+
|
|
1243
|
+
render();
|
|
1244
|
+
|
|
1245
|
+
// Cleanup function to disable mouse and stop sound on exit
|
|
1246
|
+
const cleanup = () => {
|
|
1247
|
+
// Stop any currently playing sound
|
|
1248
|
+
if (currentPlayingProcess) {
|
|
1249
|
+
try {
|
|
1250
|
+
currentPlayingProcess.kill();
|
|
1251
|
+
} catch (e) {
|
|
1252
|
+
// Ignore errors if process already stopped
|
|
1253
|
+
}
|
|
1254
|
+
currentPlayingProcess = null;
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
// Disable mouse reporting
|
|
1258
|
+
process.stdout.write('\x1b[?1000l');
|
|
1259
|
+
process.stdout.write('\x1b[?1002l');
|
|
1260
|
+
process.stdout.write('\x1b[?1003l');
|
|
1261
|
+
process.stdout.write('\x1b[?1015l');
|
|
1262
|
+
process.stdout.write('\x1b[?1006l');
|
|
1263
|
+
|
|
1264
|
+
// Restore normal screen buffer
|
|
1265
|
+
process.stdout.write('\x1b[?1049l');
|
|
1266
|
+
|
|
1267
|
+
// Show cursor
|
|
1268
|
+
process.stdout.write('\x1b[?25h');
|
|
1269
|
+
};
|
|
1270
|
+
|
|
1271
|
+
process.on('exit', cleanup);
|
|
1272
|
+
process.on('SIGINT', () => {
|
|
1273
|
+
cleanup();
|
|
1274
|
+
process.exit(0);
|
|
1275
|
+
});
|
|
1276
|
+
|
|
1277
|
+
// Handle keyboard input
|
|
1278
|
+
process.stdin.on('data', async (key) => {
|
|
1279
|
+
// Handle Ctrl+C always
|
|
1280
|
+
if (key === '\u0003') {
|
|
1281
|
+
process.exit(0);
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
// Handle mouse events (SGR format: \x1b[<button;x;y;M/m)
|
|
1285
|
+
const mouseMatch = key.match(/\x1b\[<(\d+);(\d+);(\d+)([Mm])/);
|
|
1286
|
+
if (mouseMatch) {
|
|
1287
|
+
const [, button, x, y, action] = mouseMatch;
|
|
1288
|
+
const mouseX = parseInt(x) - 1; // Convert to 0-based
|
|
1289
|
+
const mouseY = parseInt(y) - 1;
|
|
1290
|
+
const isPress = action === 'M';
|
|
1291
|
+
const buttonNum = parseInt(button);
|
|
1292
|
+
|
|
1293
|
+
const lines = buildLines();
|
|
1294
|
+
const termHeight = process.stdout.rows || 24;
|
|
1295
|
+
const termWidth = process.stdout.columns || 80;
|
|
1296
|
+
|
|
1297
|
+
// Calculate tag bar height (matching render logic exactly)
|
|
1298
|
+
const tagBarLine = lines.find(l => l.type === 'tag-bar');
|
|
1299
|
+
let tagBarLineCount = 1;
|
|
1300
|
+
if (tagBarLine) {
|
|
1301
|
+
let currentLineLength = getVisibleLength(' Tags: ') + (state.selectedTagIndex === -1 ? 4 : 3);
|
|
1302
|
+
let lineCount = 1;
|
|
1303
|
+
|
|
1304
|
+
for (let i = 0; i < tagBarLine.tagItems.length; i++) {
|
|
1305
|
+
const tag = tagBarLine.tagItems[i];
|
|
1306
|
+
const isSelectedFilter = state.selectedTagIndex === i;
|
|
1307
|
+
const tagLength = isSelectedFilter ? tag.length + 4 : tag.length + 2;
|
|
1308
|
+
|
|
1309
|
+
if (currentLineLength + tagLength > termWidth - 2) {
|
|
1310
|
+
lineCount++;
|
|
1311
|
+
currentLineLength = 2 + tagLength;
|
|
1312
|
+
} else {
|
|
1313
|
+
currentLineLength += tagLength;
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
tagBarLineCount = lineCount;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
// Check if we're in sticky navbar mode
|
|
1321
|
+
const stickyNavbar = termHeight >= 40;
|
|
1322
|
+
const contentStartIndexInLines = 4; // Header, sep, tagbar, sep are first 4 items
|
|
1323
|
+
|
|
1324
|
+
// Actual screen layout from render() (0-indexed Y positions):
|
|
1325
|
+
// In normal mode:
|
|
1326
|
+
// - Y=0: blank line (padding)
|
|
1327
|
+
// - Y=1: optional top scroll indicator "..." (if scrollOffset > 0)
|
|
1328
|
+
// - Then: lines from scrollOffset onwards (header, tagbar, content all scroll together)
|
|
1329
|
+
// In sticky navbar mode:
|
|
1330
|
+
// - Y=0: blank line (padding)
|
|
1331
|
+
// - Y=1-4: header, sep, tagbar (N lines), sep (always visible)
|
|
1332
|
+
// - Then: optional top scroll indicator "..." (if scrollOffset > contentStartIndexInLines)
|
|
1333
|
+
// - Then: content lines from scrollOffset onwards
|
|
1334
|
+
|
|
1335
|
+
const topBlankLineOffset = 1; // Account for blank line at top
|
|
1336
|
+
let currentY = topBlankLineOffset;
|
|
1337
|
+
let contentStartY;
|
|
1338
|
+
|
|
1339
|
+
if (stickyNavbar) {
|
|
1340
|
+
// Sticky mode: header/tagbar always visible at top
|
|
1341
|
+
// blank(1) + Header(1) + sep(1) + tagbar(N) + sep(1) = 1 + 2 + tagBarLineCount + 1
|
|
1342
|
+
const headerHeight = 2 + tagBarLineCount + 1;
|
|
1343
|
+
currentY = topBlankLineOffset + headerHeight;
|
|
1344
|
+
|
|
1345
|
+
// Top indicator if scrolled past header
|
|
1346
|
+
const hasTopIndicator = state.scrollOffset > contentStartIndexInLines;
|
|
1347
|
+
if (hasTopIndicator) currentY++;
|
|
1348
|
+
|
|
1349
|
+
contentStartY = currentY;
|
|
1350
|
+
} else {
|
|
1351
|
+
// Normal mode: everything scrolls together
|
|
1352
|
+
const hasTopIndicator = state.scrollOffset > 0;
|
|
1353
|
+
if (hasTopIndicator) currentY++;
|
|
1354
|
+
|
|
1355
|
+
// On screen, content starts after: blank + top indicator + header + sep + tagbar + sep
|
|
1356
|
+
contentStartY = currentY + 2 + tagBarLineCount + 1;
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
if (mouseY < contentStartY) {
|
|
1360
|
+
// Click/hover is in header/tag bar area
|
|
1361
|
+
// Calculate tag bar position based on mode
|
|
1362
|
+
let tagBarStartY, tagBarEndY;
|
|
1363
|
+
if (stickyNavbar) {
|
|
1364
|
+
// In sticky mode: Y=0 is blank, Y=1 is header, Y=2 is sep, Y=3+ is tag bar
|
|
1365
|
+
tagBarStartY = topBlankLineOffset + 2;
|
|
1366
|
+
tagBarEndY = topBlankLineOffset + 2 + tagBarLineCount;
|
|
1367
|
+
} else {
|
|
1368
|
+
// In normal mode: after blank line + optional top indicator, then header + sep
|
|
1369
|
+
const topIndicatorLines = state.scrollOffset > 0 ? 1 : 0;
|
|
1370
|
+
tagBarStartY = topBlankLineOffset + topIndicatorLines + 2; // blank + indicator + header + sep
|
|
1371
|
+
tagBarEndY = tagBarStartY + tagBarLineCount;
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
if (mouseY >= tagBarStartY && mouseY < tagBarEndY) {
|
|
1375
|
+
// In tag bar - detect which tag was clicked/hovered
|
|
1376
|
+
// Reconstruct tag positions
|
|
1377
|
+
const tagBarY = mouseY - tagBarStartY;
|
|
1378
|
+
let currentLine = 0;
|
|
1379
|
+
let currentX = getVisibleLength(' Tags: ') + (state.selectedTagIndex === -1 ? 4 : 3);
|
|
1380
|
+
let foundHover = false;
|
|
1381
|
+
|
|
1382
|
+
for (let i = -1; i < allTags.length; i++) {
|
|
1383
|
+
let tag, tagLength;
|
|
1384
|
+
|
|
1385
|
+
if (i === -1) {
|
|
1386
|
+
// The * tag
|
|
1387
|
+
tag = null;
|
|
1388
|
+
tagLength = 0; // Already counted in initial currentX
|
|
1389
|
+
|
|
1390
|
+
if (currentLine === tagBarY) {
|
|
1391
|
+
const startX = getVisibleLength(' Tags: ');
|
|
1392
|
+
const endX = startX + (state.selectedTagIndex === -1 ? 4 : 3);
|
|
1393
|
+
|
|
1394
|
+
if (mouseX >= startX && mouseX < endX) {
|
|
1395
|
+
// Clicked/hovered on * tag
|
|
1396
|
+
if (isPress && buttonNum === 0) {
|
|
1397
|
+
state.selectedTagIndex = -1;
|
|
1398
|
+
state.searchMode = false;
|
|
1399
|
+
state.searchQuery = '';
|
|
1400
|
+
state.searchResults = [];
|
|
1401
|
+
state.selectedLine = 0;
|
|
1402
|
+
state.targetSoundIndex = 0;
|
|
1403
|
+
render();
|
|
1404
|
+
return;
|
|
1405
|
+
} else if (buttonNum >= 32 && buttonNum <= 35) {
|
|
1406
|
+
// Hover
|
|
1407
|
+
if (state.hoveredNavbarTagIndex !== -1) {
|
|
1408
|
+
state.hoveredNavbarTagIndex = -1;
|
|
1409
|
+
render();
|
|
1410
|
+
}
|
|
1411
|
+
foundHover = true;
|
|
1412
|
+
return;
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
continue;
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
tag = allTags[i];
|
|
1420
|
+
const isSelectedFilter = state.selectedTagIndex === i;
|
|
1421
|
+
// Assume all tags take the same space (tag.length + 2) regardless of selection
|
|
1422
|
+
// The brackets seem to be rendered within the allocated space, not adding to it
|
|
1423
|
+
tagLength = tag.length + 2; // tag + 2 trailing spaces
|
|
1424
|
+
|
|
1425
|
+
// Check if need to wrap
|
|
1426
|
+
if (currentX + tagLength > termWidth - 2) {
|
|
1427
|
+
currentLine++;
|
|
1428
|
+
currentX = 2 + tagLength;
|
|
1429
|
+
} else {
|
|
1430
|
+
currentX += tagLength;
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
// Check if this is the right line and position
|
|
1434
|
+
if (currentLine === tagBarY) {
|
|
1435
|
+
const tagStartX = currentX - tagLength;
|
|
1436
|
+
const tagEndX = currentX;
|
|
1437
|
+
|
|
1438
|
+
if (mouseX >= tagStartX && mouseX < tagEndX) {
|
|
1439
|
+
// Clicked/hovered on this tag
|
|
1440
|
+
if (isPress && buttonNum === 0) {
|
|
1441
|
+
// Click - switch to this tag
|
|
1442
|
+
state.selectedTagIndex = i;
|
|
1443
|
+
state.searchMode = false;
|
|
1444
|
+
state.searchQuery = '';
|
|
1445
|
+
state.searchResults = [];
|
|
1446
|
+
state.selectedLine = 0;
|
|
1447
|
+
state.targetSoundIndex = 0;
|
|
1448
|
+
render();
|
|
1449
|
+
return;
|
|
1450
|
+
} else if (buttonNum >= 32 && buttonNum <= 35) {
|
|
1451
|
+
// Hover
|
|
1452
|
+
if (state.hoveredNavbarTagIndex !== i) {
|
|
1453
|
+
state.hoveredNavbarTagIndex = i;
|
|
1454
|
+
render();
|
|
1455
|
+
}
|
|
1456
|
+
foundHover = true;
|
|
1457
|
+
return;
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
// If we're in the tag bar but not over any tag, clear hover
|
|
1464
|
+
if (!foundHover && state.hoveredNavbarTagIndex !== null) {
|
|
1465
|
+
state.hoveredNavbarTagIndex = null;
|
|
1466
|
+
render();
|
|
1467
|
+
}
|
|
1468
|
+
} else {
|
|
1469
|
+
// Not in tag bar area, clear navbar hover
|
|
1470
|
+
if (state.hoveredNavbarTagIndex !== null) {
|
|
1471
|
+
state.hoveredNavbarTagIndex = null;
|
|
1472
|
+
render();
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
return;
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
// Check if mouse is in footer area (last 2 lines: separator + controls)
|
|
1479
|
+
// Footer is at: termHeight - 3 (separator line) and termHeight - 2 (controls line)
|
|
1480
|
+
// Account for bottom blank line
|
|
1481
|
+
const footerSeparatorY = termHeight - 3;
|
|
1482
|
+
const footerControlsY = termHeight - 2;
|
|
1483
|
+
|
|
1484
|
+
if (mouseY === footerControlsY && !state.searchMode) {
|
|
1485
|
+
// Mouse is on the controls/attribution line
|
|
1486
|
+
// Check if hovering over attribution link (on the right)
|
|
1487
|
+
const controls = ' ↑/↓ navigate ←/→ tags / search enter play q quit';
|
|
1488
|
+
const attributionText = 'Sounds from notificationsounds.com';
|
|
1489
|
+
const controlsVisible = controls.length;
|
|
1490
|
+
const attributionVisible = attributionText.length;
|
|
1491
|
+
|
|
1492
|
+
if (termWidth >= controlsVisible + attributionVisible + 3) {
|
|
1493
|
+
// Attribution is visible
|
|
1494
|
+
const attributionStartX = termWidth - attributionVisible - 1;
|
|
1495
|
+
const attributionEndX = termWidth - 1;
|
|
1496
|
+
|
|
1497
|
+
if (mouseX >= attributionStartX && mouseX < attributionEndX) {
|
|
1498
|
+
// Hovering/clicking on attribution
|
|
1499
|
+
if (buttonNum >= 32 && buttonNum <= 35) {
|
|
1500
|
+
// Mouse movement - hover
|
|
1501
|
+
if (!state.hoveredAttribution) {
|
|
1502
|
+
state.hoveredAttribution = true;
|
|
1503
|
+
render();
|
|
1504
|
+
}
|
|
1505
|
+
return;
|
|
1506
|
+
} else if (isPress && buttonNum === 0) {
|
|
1507
|
+
// Left click - open link
|
|
1508
|
+
const url = 'https://notificationsounds.com';
|
|
1509
|
+
const openCmd = process.platform === 'darwin' ? 'open' :
|
|
1510
|
+
process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
1511
|
+
exec(`${openCmd} ${url}`);
|
|
1512
|
+
return;
|
|
1513
|
+
}
|
|
1514
|
+
} else {
|
|
1515
|
+
// Not hovering over attribution
|
|
1516
|
+
if (state.hoveredAttribution) {
|
|
1517
|
+
state.hoveredAttribution = false;
|
|
1518
|
+
render();
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
} else {
|
|
1523
|
+
// Not on footer controls line
|
|
1524
|
+
if (state.hoveredAttribution) {
|
|
1525
|
+
state.hoveredAttribution = false;
|
|
1526
|
+
render();
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
// Mouse is in content area
|
|
1531
|
+
// Calculate which line in the lines array is at this screen position
|
|
1532
|
+
const contentRelativeY = mouseY - contentStartY;
|
|
1533
|
+
|
|
1534
|
+
let hoveredLineIndex;
|
|
1535
|
+
if (stickyNavbar) {
|
|
1536
|
+
// In sticky mode, the first visible content line is at Math.max(contentStartIndexInLines, scrollOffset)
|
|
1537
|
+
const firstVisibleContentIndex = Math.max(contentStartIndexInLines, state.scrollOffset);
|
|
1538
|
+
hoveredLineIndex = firstVisibleContentIndex + contentRelativeY;
|
|
1539
|
+
} else {
|
|
1540
|
+
// In normal mode, scrollOffset tells us the first visible line (including header)
|
|
1541
|
+
hoveredLineIndex = state.scrollOffset + contentRelativeY;
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
// Mouse move/hover (button 32+modifier means movement)
|
|
1545
|
+
if (buttonNum >= 32 && buttonNum <= 35) {
|
|
1546
|
+
if (hoveredLineIndex >= 0 && hoveredLineIndex < lines.length) {
|
|
1547
|
+
const hoveredLine = lines[hoveredLineIndex];
|
|
1548
|
+
|
|
1549
|
+
// Only highlight interactive lines
|
|
1550
|
+
if (hoveredLine.type === 'sound' || hoveredLine.type === 'tag-result') {
|
|
1551
|
+
let needsRender = false;
|
|
1552
|
+
|
|
1553
|
+
if (state.selectedLine !== hoveredLineIndex) {
|
|
1554
|
+
state.selectedLine = hoveredLineIndex;
|
|
1555
|
+
needsRender = true;
|
|
1556
|
+
|
|
1557
|
+
// Update target index if it's a sound
|
|
1558
|
+
if (hoveredLine.type === 'sound') {
|
|
1559
|
+
const soundLines = lines.filter(l => l.type === 'sound');
|
|
1560
|
+
const currentSoundIndex = soundLines.findIndex((_, idx) => {
|
|
1561
|
+
const soundLineIndex = lines.findIndex(l => l === soundLines[idx]);
|
|
1562
|
+
return soundLineIndex === state.selectedLine;
|
|
1563
|
+
});
|
|
1564
|
+
if (currentSoundIndex >= 0) {
|
|
1565
|
+
state.targetSoundIndex = currentSoundIndex;
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
// Detect which tag is hovered (if any) on sound lines
|
|
1571
|
+
if (hoveredLine.type === 'sound') {
|
|
1572
|
+
const indent = 2;
|
|
1573
|
+
const nameWidth = 25;
|
|
1574
|
+
const tagsStartX = indent + nameWidth + 1; // +1 for leading space before tags
|
|
1575
|
+
|
|
1576
|
+
let hoveredTag = null;
|
|
1577
|
+
if (mouseX >= tagsStartX && hoveredLine.soundTags && hoveredLine.soundTags.length > 0) {
|
|
1578
|
+
let tagX = tagsStartX;
|
|
1579
|
+
for (const tag of hoveredLine.soundTags) {
|
|
1580
|
+
const tagLength = tag.length;
|
|
1581
|
+
if (mouseX >= tagX && mouseX < tagX + tagLength) {
|
|
1582
|
+
hoveredTag = tag;
|
|
1583
|
+
break;
|
|
1584
|
+
}
|
|
1585
|
+
tagX += tagLength + 1; // +1 for space between tags
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
if (state.hoveredTag !== hoveredTag) {
|
|
1590
|
+
state.hoveredTag = hoveredTag;
|
|
1591
|
+
needsRender = true;
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
// Detect if hovering over URL (on the right side)
|
|
1595
|
+
const displayUrl = hoveredLine.displayUrl || hoveredLine.url;
|
|
1596
|
+
const urlStartX = termWidth - displayUrl.length - 2; // -2 for right margin
|
|
1597
|
+
const urlEndX = termWidth - 2;
|
|
1598
|
+
|
|
1599
|
+
let hoveredUrl = null;
|
|
1600
|
+
if (mouseX >= urlStartX && mouseX < urlEndX) {
|
|
1601
|
+
hoveredUrl = hoveredLineIndex;
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
if (state.hoveredUrl !== hoveredUrl) {
|
|
1605
|
+
state.hoveredUrl = hoveredUrl;
|
|
1606
|
+
needsRender = true;
|
|
1607
|
+
}
|
|
1608
|
+
} else {
|
|
1609
|
+
if (state.hoveredTag !== null) {
|
|
1610
|
+
state.hoveredTag = null;
|
|
1611
|
+
needsRender = true;
|
|
1612
|
+
}
|
|
1613
|
+
if (state.hoveredUrl !== null) {
|
|
1614
|
+
state.hoveredUrl = null;
|
|
1615
|
+
needsRender = true;
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
if (needsRender) {
|
|
1620
|
+
render();
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
return;
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
if (isPress) {
|
|
1628
|
+
// Left click (button 0) - select and play sound, or activate tag
|
|
1629
|
+
if (buttonNum === 0) {
|
|
1630
|
+
const clickedLineIndex = hoveredLineIndex; // Use the same calculation as hover
|
|
1631
|
+
if (clickedLineIndex >= 0 && clickedLineIndex < lines.length) {
|
|
1632
|
+
const clickedLine = lines[clickedLineIndex];
|
|
1633
|
+
|
|
1634
|
+
// Sound line - check if clicking on a tag or the sound itself
|
|
1635
|
+
if (clickedLine.type === 'sound') {
|
|
1636
|
+
state.selectedLine = clickedLineIndex;
|
|
1637
|
+
|
|
1638
|
+
// Update target index
|
|
1639
|
+
const soundLines = lines.filter(l => l.type === 'sound');
|
|
1640
|
+
const currentSoundIndex = soundLines.findIndex((_, idx) => {
|
|
1641
|
+
const soundLineIndex = lines.findIndex(l => l === soundLines[idx]);
|
|
1642
|
+
return soundLineIndex === state.selectedLine;
|
|
1643
|
+
});
|
|
1644
|
+
if (currentSoundIndex >= 0) {
|
|
1645
|
+
state.targetSoundIndex = currentSoundIndex;
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
// Check if click is on a tag (approximate - tags start after name at position ~27)
|
|
1649
|
+
const indent = 2;
|
|
1650
|
+
const nameWidth = 25;
|
|
1651
|
+
const tagsStartX = indent + nameWidth;
|
|
1652
|
+
|
|
1653
|
+
// Check if click is on URL (on the right side)
|
|
1654
|
+
const displayUrl = clickedLine.displayUrl || clickedLine.url;
|
|
1655
|
+
const urlStartX = termWidth - displayUrl.length - 2; // -2 for right margin
|
|
1656
|
+
const urlEndX = termWidth - 2;
|
|
1657
|
+
|
|
1658
|
+
if (mouseX >= urlStartX && mouseX < urlEndX) {
|
|
1659
|
+
// Clicked on URL - open it in browser
|
|
1660
|
+
const url = clickedLine.url; // Use full URL, not displayUrl
|
|
1661
|
+
const openCmd = process.platform === 'darwin' ? 'open' :
|
|
1662
|
+
process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
1663
|
+
exec(`${openCmd} "${url}"`);
|
|
1664
|
+
return;
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
if (mouseX >= tagsStartX && clickedLine.soundTags && clickedLine.soundTags.length > 0) {
|
|
1668
|
+
// Clicked in tag area - calculate which tag
|
|
1669
|
+
let tagX = tagsStartX + 1; // +1 for leading space
|
|
1670
|
+
for (const tag of clickedLine.soundTags) {
|
|
1671
|
+
const tagLength = tag.length + 1; // tag + space
|
|
1672
|
+
if (mouseX >= tagX && mouseX < tagX + tagLength) {
|
|
1673
|
+
// Clicked on this tag - switch to it
|
|
1674
|
+
const tagIndex = allTags.indexOf(tag);
|
|
1675
|
+
if (tagIndex >= 0) {
|
|
1676
|
+
state.selectedTagIndex = tagIndex;
|
|
1677
|
+
state.searchMode = false;
|
|
1678
|
+
state.searchQuery = '';
|
|
1679
|
+
state.searchResults = [];
|
|
1680
|
+
state.selectedLine = 0;
|
|
1681
|
+
state.targetSoundIndex = 0;
|
|
1682
|
+
render();
|
|
1683
|
+
return;
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
tagX += tagLength + 1; // +1 for space between tags
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
// Not on a tag or URL, play the sound
|
|
1691
|
+
state.playingSound = clickedLine.sound.id;
|
|
1692
|
+
render();
|
|
1693
|
+
playSound(clickedLine.sound.url, clickedLine.sound.id, () => {
|
|
1694
|
+
if (state.playingSound === clickedLine.sound.id) {
|
|
1695
|
+
state.playingSound = null;
|
|
1696
|
+
render();
|
|
1697
|
+
}
|
|
1698
|
+
});
|
|
1699
|
+
}
|
|
1700
|
+
// Tag result - switch to that tag filter
|
|
1701
|
+
else if (clickedLine.type === 'tag-result') {
|
|
1702
|
+
const tagIndex = allTags.indexOf(clickedLine.tag);
|
|
1703
|
+
if (tagIndex >= 0) {
|
|
1704
|
+
state.selectedTagIndex = tagIndex;
|
|
1705
|
+
state.searchMode = false;
|
|
1706
|
+
state.searchQuery = '';
|
|
1707
|
+
state.searchResults = [];
|
|
1708
|
+
state.selectedLine = 0;
|
|
1709
|
+
state.targetSoundIndex = 0;
|
|
1710
|
+
render();
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
// Scroll up (button 64) - scroll viewport up (like Vim ^y)
|
|
1716
|
+
else if (buttonNum === 64) {
|
|
1717
|
+
const minScroll = stickyNavbar ? contentStartIndexInLines : 0;
|
|
1718
|
+
if (state.scrollOffset > minScroll) {
|
|
1719
|
+
// Scroll by 3 lines for faster scrolling
|
|
1720
|
+
state.scrollOffset = Math.max(minScroll, state.scrollOffset - 3);
|
|
1721
|
+
|
|
1722
|
+
// Update selection to first visible interactive line
|
|
1723
|
+
const firstVisibleIndex = state.scrollOffset;
|
|
1724
|
+
let newSelection = state.selectedLine;
|
|
1725
|
+
|
|
1726
|
+
// Find first interactive line in viewport
|
|
1727
|
+
for (let i = firstVisibleIndex; i < lines.length; i++) {
|
|
1728
|
+
const line = lines[i];
|
|
1729
|
+
if (line.type === 'sound' || line.type === 'tag-result') {
|
|
1730
|
+
newSelection = i;
|
|
1731
|
+
break;
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
state.selectedLine = newSelection;
|
|
1736
|
+
|
|
1737
|
+
// Update target sound index
|
|
1738
|
+
if (lines[newSelection] && lines[newSelection].type === 'sound') {
|
|
1739
|
+
const soundLines = lines.filter(l => l.type === 'sound');
|
|
1740
|
+
const soundIndex = soundLines.findIndex((_, idx) => {
|
|
1741
|
+
const soundLineIndex = lines.findIndex(l => l === soundLines[idx]);
|
|
1742
|
+
return soundLineIndex === newSelection;
|
|
1743
|
+
});
|
|
1744
|
+
if (soundIndex >= 0) {
|
|
1745
|
+
state.targetSoundIndex = soundIndex;
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
render();
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
// Scroll down (button 65) - scroll viewport down (like Vim ^e)
|
|
1753
|
+
else if (buttonNum === 65) {
|
|
1754
|
+
// Calculate available lines for content
|
|
1755
|
+
const fixedHeaderLines = stickyNavbar ? (2 + tagBarLineCount + 1) : 0;
|
|
1756
|
+
const fixedFooterLines = 2;
|
|
1757
|
+
const availableContentLines = termHeight - fixedHeaderLines - fixedFooterLines;
|
|
1758
|
+
const maxScroll = Math.max(0, lines.length - availableContentLines);
|
|
1759
|
+
|
|
1760
|
+
if (state.scrollOffset < maxScroll) {
|
|
1761
|
+
// Scroll by 3 lines for faster scrolling
|
|
1762
|
+
state.scrollOffset = Math.min(maxScroll, state.scrollOffset + 3);
|
|
1763
|
+
|
|
1764
|
+
// Update selection to first visible interactive line
|
|
1765
|
+
const firstVisibleIndex = state.scrollOffset;
|
|
1766
|
+
let newSelection = state.selectedLine;
|
|
1767
|
+
|
|
1768
|
+
// Find first interactive line in viewport
|
|
1769
|
+
for (let i = firstVisibleIndex; i < lines.length; i++) {
|
|
1770
|
+
const line = lines[i];
|
|
1771
|
+
if (line.type === 'sound' || line.type === 'tag-result') {
|
|
1772
|
+
newSelection = i;
|
|
1773
|
+
break;
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
state.selectedLine = newSelection;
|
|
1778
|
+
|
|
1779
|
+
// Update target sound index
|
|
1780
|
+
if (lines[newSelection] && lines[newSelection].type === 'sound') {
|
|
1781
|
+
const soundLines = lines.filter(l => l.type === 'sound');
|
|
1782
|
+
const soundIndex = soundLines.findIndex((_, idx) => {
|
|
1783
|
+
const soundLineIndex = lines.findIndex(l => l === soundLines[idx]);
|
|
1784
|
+
return soundLineIndex === newSelection;
|
|
1785
|
+
});
|
|
1786
|
+
if (soundIndex >= 0) {
|
|
1787
|
+
state.targetSoundIndex = soundIndex;
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
render();
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
return;
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
// Picker mode: handle event selection
|
|
1799
|
+
if (state.pickerMode) {
|
|
1800
|
+
if (key === '\u001b' || key === '\u001b[D' || key === 'h') {
|
|
1801
|
+
// ESC or Left arrow or 'h' - exit picker
|
|
1802
|
+
state.pickerMode = false;
|
|
1803
|
+
state.pickerSound = null;
|
|
1804
|
+
render();
|
|
1805
|
+
return;
|
|
1806
|
+
} else if (key === '\u001b[A' || key === 'k') {
|
|
1807
|
+
// Up arrow
|
|
1808
|
+
if (state.pickerSelectedEvent > 0) {
|
|
1809
|
+
state.pickerSelectedEvent--;
|
|
1810
|
+
}
|
|
1811
|
+
render();
|
|
1812
|
+
return;
|
|
1813
|
+
} else if (key === '\u001b[B' || key === 'j') {
|
|
1814
|
+
// Down arrow
|
|
1815
|
+
if (state.pickerSelectedEvent < availableEvents.length - 1) {
|
|
1816
|
+
state.pickerSelectedEvent++;
|
|
1817
|
+
}
|
|
1818
|
+
render();
|
|
1819
|
+
return;
|
|
1820
|
+
} else if (key === '\r' || key === ' ') {
|
|
1821
|
+
// Enter/Space - play current sound for selected event
|
|
1822
|
+
const config = loadCurrentConfig();
|
|
1823
|
+
const event = availableEvents[state.pickerSelectedEvent];
|
|
1824
|
+
const currentSound = config.sounds ? config.sounds[event] : null;
|
|
1825
|
+
if (currentSound) {
|
|
1826
|
+
const soundEntry = sounds.find(s => s.id === currentSound || s.name === currentSound);
|
|
1827
|
+
if (soundEntry) {
|
|
1828
|
+
state.playingSound = soundEntry.id;
|
|
1829
|
+
render();
|
|
1830
|
+
playSound(soundEntry.url, soundEntry.id, () => {
|
|
1831
|
+
state.playingSound = null;
|
|
1832
|
+
render();
|
|
1833
|
+
});
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1836
|
+
return;
|
|
1837
|
+
} else if (key === '\u0013') {
|
|
1838
|
+
// Ctrl+S - save assignment
|
|
1839
|
+
const config = loadCurrentConfig();
|
|
1840
|
+
const event = availableEvents[state.pickerSelectedEvent];
|
|
1841
|
+
|
|
1842
|
+
if (!config.sounds) config.sounds = {};
|
|
1843
|
+
config.sounds[event] = state.pickerSound.id;
|
|
1844
|
+
|
|
1845
|
+
// Save to config file
|
|
1846
|
+
const saved = saveConfig(config);
|
|
1847
|
+
|
|
1848
|
+
if (saved) {
|
|
1849
|
+
state.pickerMode = false;
|
|
1850
|
+
state.pickerSound = null;
|
|
1851
|
+
}
|
|
1852
|
+
render();
|
|
1853
|
+
return;
|
|
1854
|
+
}
|
|
1855
|
+
return; // Ignore other keys in picker mode
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
const lines = buildLines();
|
|
1859
|
+
|
|
1860
|
+
// Search mode: capture everything except ESC, Enter, and arrows
|
|
1861
|
+
if (state.searchMode) {
|
|
1862
|
+
if (key === '\u001b') {
|
|
1863
|
+
// ESC - cancel search completely
|
|
1864
|
+
state.searchMode = false;
|
|
1865
|
+
state.searchQuery = '';
|
|
1866
|
+
state.searchResults = [];
|
|
1867
|
+
state.selectedLine = 0;
|
|
1868
|
+
render();
|
|
1869
|
+
return;
|
|
1870
|
+
} else if (key === '\r' || key === '\u001b[A' || key === '\u001b[B') {
|
|
1871
|
+
// Enter or Up/Down arrows - exit search box, keep results
|
|
1872
|
+
const query = state.searchQuery.toLowerCase();
|
|
1873
|
+
|
|
1874
|
+
// Get global search results (sounds + tags)
|
|
1875
|
+
const matchingSounds = sounds.filter(sound =>
|
|
1876
|
+
sound.name.toLowerCase().includes(query) ||
|
|
1877
|
+
sound.id.toLowerCase().includes(query)
|
|
1878
|
+
);
|
|
1879
|
+
|
|
1880
|
+
const matchingTags = allTags.filter(tag => tag.toLowerCase().includes(query));
|
|
1881
|
+
|
|
1882
|
+
// Combine and sort
|
|
1883
|
+
const soundItems = matchingSounds.map(sound => ({ type: 'sound', data: sound }));
|
|
1884
|
+
const tagItems = matchingTags.map(tag => ({ type: 'tag', data: tag }));
|
|
1885
|
+
const displayItems = [...soundItems, ...tagItems].sort((a, b) => {
|
|
1886
|
+
const aName = a.type === 'sound' ? a.data.name : a.data;
|
|
1887
|
+
const bName = b.type === 'sound' ? b.data.name : b.data;
|
|
1888
|
+
return aName.localeCompare(bName);
|
|
1889
|
+
});
|
|
1890
|
+
|
|
1891
|
+
state.searchResults = displayItems;
|
|
1892
|
+
state.searchMode = false;
|
|
1893
|
+
state.selectedLine = 0;
|
|
1894
|
+
render();
|
|
1895
|
+
|
|
1896
|
+
// If it was an arrow key, handle the navigation
|
|
1897
|
+
if (key === '\u001b[A') {
|
|
1898
|
+
// Will be handled in next iteration
|
|
1899
|
+
process.stdin.emit('data', key);
|
|
1900
|
+
} else if (key === '\u001b[B') {
|
|
1901
|
+
process.stdin.emit('data', key);
|
|
1902
|
+
}
|
|
1903
|
+
return;
|
|
1904
|
+
} else if (key === '\u007f') {
|
|
1905
|
+
// Backspace
|
|
1906
|
+
if (state.searchQuery.length === 0) {
|
|
1907
|
+
// Exit search mode if query is empty
|
|
1908
|
+
state.searchMode = false;
|
|
1909
|
+
state.searchQuery = '';
|
|
1910
|
+
state.searchResults = [];
|
|
1911
|
+
render();
|
|
1912
|
+
return;
|
|
1913
|
+
}
|
|
1914
|
+
state.searchQuery = state.searchQuery.slice(0, -1);
|
|
1915
|
+
render();
|
|
1916
|
+
return;
|
|
1917
|
+
} else if (key.length === 1 && key >= ' ' && key <= '~') {
|
|
1918
|
+
// Add printable character to search
|
|
1919
|
+
state.searchQuery += key;
|
|
1920
|
+
render();
|
|
1921
|
+
return;
|
|
1922
|
+
}
|
|
1923
|
+
return; // Ignore other keys in search mode
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
// Normal mode key handling
|
|
1927
|
+
switch (key) {
|
|
1928
|
+
case '\u001b': // ESC key
|
|
1929
|
+
// Clear search if there's an active search
|
|
1930
|
+
if (state.searchQuery) {
|
|
1931
|
+
state.searchMode = false;
|
|
1932
|
+
state.searchQuery = '';
|
|
1933
|
+
state.searchResults = [];
|
|
1934
|
+
state.selectedLine = 0;
|
|
1935
|
+
render();
|
|
1936
|
+
}
|
|
1937
|
+
break;
|
|
1938
|
+
|
|
1939
|
+
case 'q':
|
|
1940
|
+
process.exit(0);
|
|
1941
|
+
break;
|
|
1942
|
+
|
|
1943
|
+
case '\u001b[A': // Up arrow
|
|
1944
|
+
case 'k':
|
|
1945
|
+
if (state.selectedLine > 0) {
|
|
1946
|
+
state.selectedLine--;
|
|
1947
|
+
// Skip non-interactive lines
|
|
1948
|
+
while (state.selectedLine > 0 &&
|
|
1949
|
+
lines[state.selectedLine].type !== 'category' &&
|
|
1950
|
+
lines[state.selectedLine].type !== 'sound' &&
|
|
1951
|
+
lines[state.selectedLine].type !== 'tag-result') {
|
|
1952
|
+
state.selectedLine--;
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
// Ensure we're on an interactive line, if not find the first one
|
|
1956
|
+
const currentLine = lines[state.selectedLine];
|
|
1957
|
+
if (currentLine &&
|
|
1958
|
+
currentLine.type !== 'category' &&
|
|
1959
|
+
currentLine.type !== 'sound' &&
|
|
1960
|
+
currentLine.type !== 'tag-result') {
|
|
1961
|
+
// Find first interactive line
|
|
1962
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1963
|
+
const line = lines[i];
|
|
1964
|
+
if (line.type === 'sound' || line.type === 'tag-result' || line.type === 'category') {
|
|
1965
|
+
state.selectedLine = i;
|
|
1966
|
+
break;
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
// Update target index when manually navigating
|
|
1972
|
+
const soundLines = lines.filter(l => l.type === 'sound');
|
|
1973
|
+
const currentSoundIndex = soundLines.findIndex((_, idx) => {
|
|
1974
|
+
const soundLineIndex = lines.findIndex(l => l === soundLines[idx]);
|
|
1975
|
+
return soundLineIndex === state.selectedLine;
|
|
1976
|
+
});
|
|
1977
|
+
if (currentSoundIndex >= 0) {
|
|
1978
|
+
state.targetSoundIndex = currentSoundIndex;
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
render();
|
|
1982
|
+
break;
|
|
1983
|
+
|
|
1984
|
+
case '\u001b[B': // Down arrow
|
|
1985
|
+
case 'j':
|
|
1986
|
+
if (state.selectedLine < lines.length - 1) {
|
|
1987
|
+
state.selectedLine++;
|
|
1988
|
+
// Skip non-interactive lines
|
|
1989
|
+
while (state.selectedLine < lines.length - 1 &&
|
|
1990
|
+
lines[state.selectedLine].type !== 'category' &&
|
|
1991
|
+
lines[state.selectedLine].type !== 'sound' &&
|
|
1992
|
+
lines[state.selectedLine].type !== 'tag-result') {
|
|
1993
|
+
state.selectedLine++;
|
|
1994
|
+
}
|
|
1995
|
+
// Update target index when manually navigating
|
|
1996
|
+
const soundLines = lines.filter(l => l.type === 'sound');
|
|
1997
|
+
const currentSoundIndex = soundLines.findIndex((_, idx) => {
|
|
1998
|
+
const soundLineIndex = lines.findIndex(l => l === soundLines[idx]);
|
|
1999
|
+
return soundLineIndex === state.selectedLine;
|
|
2000
|
+
});
|
|
2001
|
+
if (currentSoundIndex >= 0) {
|
|
2002
|
+
state.targetSoundIndex = currentSoundIndex;
|
|
2003
|
+
}
|
|
2004
|
+
}
|
|
2005
|
+
render();
|
|
2006
|
+
break;
|
|
2007
|
+
|
|
2008
|
+
case '\u001b[C': // Right arrow
|
|
2009
|
+
case 'l':
|
|
2010
|
+
// Navigate tags right, maintaining sound position
|
|
2011
|
+
if (state.selectedTagIndex < allTags.length - 1) {
|
|
2012
|
+
state.selectedTagIndex++;
|
|
2013
|
+
|
|
2014
|
+
// Find the sound at targetSoundIndex in new view
|
|
2015
|
+
const newLines = buildLines();
|
|
2016
|
+
const soundLines = newLines.filter(l => l.type === 'sound');
|
|
2017
|
+
|
|
2018
|
+
if (soundLines.length > 0) {
|
|
2019
|
+
// Try to go to targetSoundIndex, or last sound if not enough
|
|
2020
|
+
const targetIndex = Math.min(state.targetSoundIndex, soundLines.length - 1);
|
|
2021
|
+
const targetSoundLine = soundLines[targetIndex];
|
|
2022
|
+
state.selectedLine = newLines.indexOf(targetSoundLine);
|
|
2023
|
+
} else {
|
|
2024
|
+
state.selectedLine = 0;
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
render();
|
|
2028
|
+
break;
|
|
2029
|
+
|
|
2030
|
+
case '\u001b[D': // Left arrow
|
|
2031
|
+
case 'h':
|
|
2032
|
+
// Navigate tags left, maintaining sound position
|
|
2033
|
+
if (state.selectedTagIndex > -1) {
|
|
2034
|
+
state.selectedTagIndex--;
|
|
2035
|
+
|
|
2036
|
+
// Find the sound at targetSoundIndex in new view
|
|
2037
|
+
const newLines = buildLines();
|
|
2038
|
+
const soundLines = newLines.filter(l => l.type === 'sound');
|
|
2039
|
+
|
|
2040
|
+
if (soundLines.length > 0) {
|
|
2041
|
+
// Try to go to targetSoundIndex, or last sound if not enough
|
|
2042
|
+
const targetIndex = Math.min(state.targetSoundIndex, soundLines.length - 1);
|
|
2043
|
+
const targetSoundLine = soundLines[targetIndex];
|
|
2044
|
+
state.selectedLine = newLines.indexOf(targetSoundLine);
|
|
2045
|
+
} else {
|
|
2046
|
+
state.selectedLine = 0;
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
render();
|
|
2050
|
+
break;
|
|
2051
|
+
|
|
2052
|
+
case '/':
|
|
2053
|
+
// Enter search mode (or re-enter if results are showing)
|
|
2054
|
+
state.searchMode = true;
|
|
2055
|
+
if (!state.searchQuery) {
|
|
2056
|
+
state.searchQuery = '';
|
|
2057
|
+
state.searchResults = [];
|
|
2058
|
+
}
|
|
2059
|
+
// Keep current search query to refine it
|
|
2060
|
+
render();
|
|
2061
|
+
break;
|
|
2062
|
+
|
|
2063
|
+
case 's':
|
|
2064
|
+
// Open event picker for selected sound
|
|
2065
|
+
{
|
|
2066
|
+
const selectedLine = lines[state.selectedLine];
|
|
2067
|
+
if (selectedLine && selectedLine.type === 'sound') {
|
|
2068
|
+
state.pickerMode = true;
|
|
2069
|
+
state.pickerSelectedEvent = 0;
|
|
2070
|
+
state.pickerSound = selectedLine.sound;
|
|
2071
|
+
render();
|
|
2072
|
+
}
|
|
2073
|
+
}
|
|
2074
|
+
break;
|
|
2075
|
+
|
|
2076
|
+
case 'p':
|
|
2077
|
+
case '\r': // Enter key
|
|
2078
|
+
case ' ': // Space key
|
|
2079
|
+
{
|
|
2080
|
+
const selectedLine = lines[state.selectedLine];
|
|
2081
|
+
|
|
2082
|
+
if (selectedLine.type === 'category') {
|
|
2083
|
+
// Toggle collapse for categories
|
|
2084
|
+
state.collapsed[selectedLine.category] = !state.collapsed[selectedLine.category];
|
|
2085
|
+
render();
|
|
2086
|
+
} else if (selectedLine.type === 'tag-result') {
|
|
2087
|
+
// Switch to the selected tag filter
|
|
2088
|
+
const tagIndex = allTags.indexOf(selectedLine.tag);
|
|
2089
|
+
if (tagIndex >= 0) {
|
|
2090
|
+
state.selectedTagIndex = tagIndex;
|
|
2091
|
+
state.searchMode = false;
|
|
2092
|
+
state.searchQuery = '';
|
|
2093
|
+
state.searchResults = [];
|
|
2094
|
+
state.selectedLine = 0;
|
|
2095
|
+
state.targetSoundIndex = 0;
|
|
2096
|
+
render();
|
|
2097
|
+
}
|
|
2098
|
+
} else if (selectedLine.type === 'sound') {
|
|
2099
|
+
// Play sound
|
|
2100
|
+
state.playingSound = selectedLine.sound.id;
|
|
2101
|
+
render();
|
|
2102
|
+
|
|
2103
|
+
// Play sound with callback to clear indicator when done
|
|
2104
|
+
playSound(selectedLine.sound.url, selectedLine.sound.id, () => {
|
|
2105
|
+
// Clear playing indicator when sound finishes
|
|
2106
|
+
if (state.playingSound === selectedLine.sound.id) {
|
|
2107
|
+
state.playingSound = null;
|
|
2108
|
+
render();
|
|
2109
|
+
}
|
|
2110
|
+
});
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
2113
|
+
break;
|
|
2114
|
+
}
|
|
2115
|
+
});
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2118
|
+
// Run the browser
|
|
2119
|
+
browse().catch((err) => {
|
|
2120
|
+
console.error("\x1b[31mError:\x1b[0m", err.message);
|
|
2121
|
+
process.exit(1);
|
|
2122
|
+
});
|