yum/FastTextPager

Compressed text paging over OSC.

git clone https://git.yummers.dev/yum/FastTextPager

yumbugfixes790c91d

master
19.0 KiB531 linesraw
1// Import configuration schema
2const CONFIG_FIELDS = window.CONFIG_SCHEMA;
3
4// Process state tracking
5let isProcessRunning = false;
6let buttonManager;
7let loadingOverlay;
8
9// Auto-save functionality with debouncing
10let saveTimeout;
11const SAVE_DELAY = 500;
12let isSettingValues = false;
13
14// Console management
15const consoleContent = document.getElementById('console-content');
16const MAX_CONSOLE_LINES = 512;
17let consoleLineCount = 0;
18
19// Button management system
20class ButtonManager {
21    constructor() {
22        this.buttons = {
23            start: document.getElementById('start-process'),
24            stop: document.getElementById('stop-process'),
25            setupVenv: document.getElementById('setup-venv'),
26            resetVenv: document.getElementById('reset-venv'),
27            refreshMicrophones: document.getElementById('refresh-microphones')
28        };
29
30        // Initialize button states - process is not running at startup
31        this.setProcessStopped();
32    }
33
34    setState(buttonName, disabled) {
35        const button = this.buttons[buttonName];
36        if (!button) return;
37
38        button.disabled = disabled;
39    }
40
41    setProcessRunning() {
42        this.setState('start', true);
43        this.setState('stop', false);
44        isProcessRunning = true;
45    }
46
47    setProcessStopped() {
48        this.setState('start', false);
49        this.setState('stop', true);
50        isProcessRunning = false;
51    }
52
53    async withButtonLoading(buttonName, asyncFn) {
54        this.setState(buttonName, true);
55        try {
56            return await asyncFn();
57        } finally {
58            this.setState(buttonName, false);
59        }
60    }
61}
62
63// Add loading overlay management
64class LoadingOverlay {
65    constructor() {
66        this.overlay = document.getElementById('loading-overlay');
67        this.form = document.getElementById('config-form');
68        this.messageElement = this.overlay.querySelector('p');
69        this.defaultMessage = 'Environment setup underway - please wait.';
70        this.originalStates = new Map(); // Track original disabled states
71    }
72
73    show(message = null) {
74        this.messageElement.textContent = message || this.defaultMessage;
75        this.overlay.classList.remove('hidden');
76        // Disable all form inputs and buttons in the entire left panel
77        const leftPanel = this.overlay.parentElement;
78        const inputs = leftPanel.querySelectorAll('input, select, textarea, button');
79        inputs.forEach(input => {
80            // Store original disabled state before disabling
81            this.originalStates.set(input, input.disabled);
82            input.disabled = true;
83            input.classList.add('opacity-50');
84        });
85    }
86
87    hide() {
88        this.overlay.classList.add('hidden');
89        // Restore original states of form inputs and buttons
90        const leftPanel = this.overlay.parentElement;
91        const inputs = leftPanel.querySelectorAll('input, select, textarea, button');
92        inputs.forEach(input => {
93            // Restore original disabled state
94            input.disabled = this.originalStates.get(input) || false;
95            input.classList.remove('opacity-50');
96        });
97        // Clear the stored states
98        this.originalStates.clear();
99        // Reset to default message
100        this.messageElement.textContent = this.defaultMessage;
101    }
102}
103
104// Handle status messages with better color management
105function showStatus(message, type = 'info') {
106    const statusEl = document.getElementById('status-message');
107    statusEl.textContent = message;
108
109    // Remove all status classes
110    const statusClasses = ['hidden', 'bg-green-100', 'bg-red-100', 'bg-blue-100', 'text-green-800', 'text-red-800', 'text-blue-800'];
111    statusEl.classList.remove(...statusClasses);
112
113    // Add appropriate classes based on type
114    const typeMap = {
115        success: ['bg-green-100', 'text-green-800'],
116        error: ['bg-red-100', 'text-red-800'],
117        info: ['bg-blue-100', 'text-blue-800']
118    };
119
120    statusEl.classList.add(...(typeMap[type] || typeMap.info));
121
122    // Also log to console
123    appendToConsole(message, type === 'error' ? 'stderr' : 'info');
124
125    setTimeout(() => statusEl.classList.add('hidden'), 5000);
126}
127
128// Get form values using field mappings
129function getFormValues() {
130    const config = {};
131
132    for (const [fieldName, fieldConfig] of Object.entries(CONFIG_FIELDS)) {
133        const element = document.getElementById(fieldName);
134        if (!element) continue;
135
136        switch (fieldConfig.type) {
137            case 'boolean':
138                config[fieldName] = element.checked ? 1 : 0;
139                break;
140            case 'number':
141                const numValue = parseInt(element.value);
142                config[fieldName] = isNaN(numValue) ? fieldConfig.default : numValue;
143                break;
144            case 'text':
145                config[fieldName] = element.value || fieldConfig.default;
146                break;
147            default:
148                config[fieldName] = element.value || fieldConfig.default;
149        }
150    }
151
152    return config;
153}
154
155// Set form values using field mappings
156function setFormValues(config) {
157    isSettingValues = true; // Disable auto-save temporarily
158
159    for (const [fieldName, fieldConfig] of Object.entries(CONFIG_FIELDS)) {
160        const element = document.getElementById(fieldName);
161        if (!element) continue;
162
163        const value = config[fieldName] ?? fieldConfig.default;
164
165        switch (fieldConfig.type) {
166            case 'boolean':
167                element.checked = value === 1;
168                break;
169            case 'text':
170                element.value = value || '';
171                break;
172            default:
173                element.value = value;
174        }
175    }
176
177    // Handle use_builtin toggle state
178    const useBuiltin = config.use_builtin === 1;
179    const customChatboxInputs = ['block_width', 'num_blocks', 'rows', 'cols'];
180    customChatboxInputs.forEach(inputId => {
181        const input = document.getElementById(inputId);
182        if (input) {
183            input.disabled = useBuiltin;
184            if (useBuiltin) {
185                input.classList.add('opacity-50', 'cursor-not-allowed');
186            } else {
187                input.classList.remove('opacity-50', 'cursor-not-allowed');
188            }
189        }
190    });
191
192    // Update volume display
193    if (config.volume !== undefined) {
194        const volumePercent = Math.round(config.volume);
195        document.getElementById('volume-display').textContent = `${volumePercent}%`;
196    }
197
198    isSettingValues = false; // Re-enable auto-save
199}
200
201function appendToConsole(message, type = 'stdout') {
202    const timestamp = new Date().toLocaleTimeString();
203    const timestampSpan = document.createElement('span');
204    timestampSpan.className = 'console-timestamp';
205    timestampSpan.textContent = `[${timestamp}] `;
206
207    const messageSpan = document.createElement('span');
208    messageSpan.className = `console-${type}`;
209    messageSpan.textContent = message;
210
211    const lineDiv = document.createElement('div');
212    lineDiv.appendChild(timestampSpan);
213    lineDiv.appendChild(messageSpan);
214
215    consoleContent.appendChild(lineDiv);
216    consoleLineCount++;
217
218    // Remove old lines if we exceed the limit
219    if (consoleLineCount > MAX_CONSOLE_LINES) {
220        // Calculate how many lines to remove (remove 10% to avoid frequent trimming)
221        const linesToRemove = Math.floor(MAX_CONSOLE_LINES * 0.1);
222
223        // Remove the oldest lines
224        for (let i = 0; i < linesToRemove; i++) {
225            if (consoleContent.firstChild) {
226                consoleContent.removeChild(consoleContent.firstChild);
227            }
228        }
229
230        consoleLineCount -= linesToRemove;
231
232        // Add a notice that lines were trimmed
233        const trimNotice = document.createElement('div');
234        trimNotice.className = 'console-info';
235        trimNotice.innerHTML = '<span class="console-timestamp">[System] </span><span class="console-info">... older lines removed to maintain performance ...</span>';
236        consoleContent.insertBefore(trimNotice, consoleContent.firstChild);
237    }
238
239    // Auto-scroll to bottom
240    const pythonConsole = document.getElementById('python-console');
241    pythonConsole.scrollTop = pythonConsole.scrollHeight;
242}
243
244// Async action handler with better error handling
245async function handleAsyncAction(actionName, actionFn) {
246    try {
247        const result = await actionFn();
248        if (result?.message) {
249            showStatus(result.message, 'success');
250        }
251        return result;
252    } catch (error) {
253        showStatus(`${actionName} failed: ${error.message}`, 'error');
254        throw error;
255    }
256}
257
258async function autoSaveConfig() {
259    if (isSettingValues) return;
260
261    clearTimeout(saveTimeout);
262    saveTimeout = setTimeout(async () => {
263        try {
264            const config = getFormValues();
265            await window.electronAPI.saveConfig(config);
266            showStatus('Configuration saved', 'success');
267
268            // Restart process if running
269            if (isProcessRunning) {
270                appendToConsole('Restarting process with new configuration...', 'info');
271
272                try {
273                    await window.electronAPI.stopProcess();
274                    await new Promise(resolve => setTimeout(resolve, 1000));
275                    await window.electronAPI.startProcess();
276                    buttonManager.setProcessRunning();
277                    appendToConsole('Process restarted with new configuration', 'info');
278                } catch (error) {
279                    appendToConsole(`Failed to restart process: ${error.message}`, 'stderr');
280                    buttonManager.setProcessStopped();
281                }
282            }
283        } catch (error) {
284            showStatus(`Failed to save configuration: ${error.message}`, 'error');
285        }
286    }, SAVE_DELAY);
287}
288
289// Auto-save setup
290function setupAutoSave() {
291    const form = document.getElementById('config-form');
292    const inputs = form.querySelectorAll('input, select, textarea');
293
294    inputs.forEach(input => {
295        const eventType = input.type === 'checkbox' ? 'change' :
296                         (input.type === 'number' || input.type === 'text' || input.tagName === 'TEXTAREA') ? 'input' : 'change';
297        input.addEventListener(eventType, autoSaveConfig);
298    });
299}
300
301// Microphone loading
302async function loadMicrophones() {
303    const microphoneSelect = document.getElementById('microphone');
304
305    try {
306        // Check/install requirements during startup
307        appendToConsole('Checking virtual environment and requirements...', 'info');
308        loadingOverlay.show('Setting up environment - this can take several minutes.');
309        try {
310            await handleAsyncAction('Install requirements', () => window.electronAPI.installRequirements());
311        } finally {
312            loadingOverlay.hide(); // Always hide overlay when done
313        }
314
315        appendToConsole('Loading available microphones...', 'info');
316        const microphones = await window.electronAPI.getMicrophones();
317
318        microphoneSelect.innerHTML = '';
319
320        if (microphones.length === 0) {
321            microphoneSelect.innerHTML = '<option value="" disabled>No microphones found</option>';
322            appendToConsole('No microphones found', 'stderr');
323            return;
324        }
325
326        appendToConsole(`Found ${microphones.length} microphone(s)`, 'info');
327        microphones.forEach(mic => {
328            const option = document.createElement('option');
329            option.value = mic.index.toString();
330            option.textContent = mic.name;
331            microphoneSelect.appendChild(option);
332            appendToConsole(`  - ${mic.name} (Device ${mic.index})`, 'stdout');
333        });
334
335        // Restore previously selected microphone
336        try {
337            const config = await window.electronAPI.loadConfig();
338            if (config.microphone) {
339                microphoneSelect.value = config.microphone;
340            }
341        } catch (error) {
342            // Ignore config load errors here
343        }
344
345    } catch (error) {
346        appendToConsole(`Failed to load microphones: ${error.message}`, 'stderr');
347        microphoneSelect.innerHTML = '<option value="" disabled>Error loading microphones</option>';
348    }
349}
350
351// Event handlers setup
352function setupEventHandlers() {
353    // Advanced settings toggle
354    document.getElementById('toggle-advanced').addEventListener('click', () => {
355        const advancedSettings = document.getElementById('advanced-settings');
356        const chevron = document.getElementById('chevron');
357
358        if (advancedSettings.classList.contains('hidden')) {
359            advancedSettings.classList.remove('hidden');
360            chevron.classList.add('rotate-90');
361        } else {
362            advancedSettings.classList.add('hidden');
363            chevron.classList.remove('rotate-90');
364        }
365    });
366
367    // Use builtin chatbox toggle
368    document.getElementById('use_builtin').addEventListener('change', (e) => {
369        const customChatboxInputs = ['block_width', 'num_blocks', 'rows', 'cols'];
370        const isBuiltin = e.target.checked;
371
372        customChatboxInputs.forEach(inputId => {
373            const input = document.getElementById(inputId);
374            if (input) {
375                input.disabled = isBuiltin;
376                if (isBuiltin) {
377                    input.classList.add('opacity-50', 'cursor-not-allowed');
378                } else {
379                    input.classList.remove('opacity-50', 'cursor-not-allowed');
380                }
381            }
382        });
383    });
384
385    // Volume slider update
386    document.getElementById('volume').addEventListener('input', (e) => {
387        const volumePercent = Math.round(e.target.value);
388        document.getElementById('volume-display').textContent = `${volumePercent}%`;
389    });
390
391    // Setup virtual environment
392    document.getElementById('setup-venv').addEventListener('click', async () => {
393        loadingOverlay.show('Setting up virtual environment - please wait...'); // Show overlay with custom message
394        try {
395            await buttonManager.withButtonLoading('setupVenv', async () => {
396                await window.electronAPI.deleteVenvIndicatorFile();
397                await handleAsyncAction('Install requirements', () => window.electronAPI.installRequirements());
398            });
399        } finally {
400            loadingOverlay.hide(); // Always hide overlay when done
401        }
402    });
403
404    // Reset virtual environment
405    document.getElementById('reset-venv').addEventListener('click', async () => {
406        loadingOverlay.show('Resetting virtual environment - please wait...'); // Show overlay with custom message
407        try {
408            await buttonManager.withButtonLoading('resetVenv', async () => {
409                await handleAsyncAction('Reset virtual environment', () => window.electronAPI.resetVenv());
410            });
411        } finally {
412            loadingOverlay.hide(); // Always hide overlay when done
413        }
414    });
415
416    // Reset configuration
417    document.getElementById('reset-config').addEventListener('click', async () => {
418        const confirmReset = confirm('Are you sure you want to reset all settings to defaults? This cannot be undone.');
419        if (!confirmReset) return;
420
421        try {
422            // Stop process if running
423            const wasRunning = isProcessRunning;
424            if (wasRunning) {
425                appendToConsole('Stopping process before resetting configuration...', 'info');
426                await window.electronAPI.stopProcess();
427                buttonManager.setProcessStopped();
428                await new Promise(resolve => setTimeout(resolve, 500));
429            }
430
431            // Reset configuration
432            appendToConsole('Resetting configuration to defaults...', 'info');
433            const result = await window.electronAPI.resetConfig();
434
435            // Reload configuration with defaults
436            const config = await window.electronAPI.loadConfig();
437            setFormValues(config);
438
439            showStatus(result.message, 'success');
440            appendToConsole('Configuration reset successfully', 'info');
441
442            // Restart process if it was running
443            if (wasRunning) {
444                appendToConsole('Restarting process with default configuration...', 'info');
445                await window.electronAPI.startProcess();
446                buttonManager.setProcessRunning();
447                appendToConsole('Process restarted with default configuration', 'info');
448            }
449        } catch (error) {
450            showStatus(`Failed to reset configuration: ${error.message}`, 'error');
451            appendToConsole(`Failed to reset configuration: ${error.message}`, 'stderr');
452        }
453    });
454
455    // Refresh microphones
456    document.getElementById('refresh-microphones').addEventListener('click', async () => {
457        await buttonManager.withButtonLoading('refreshMicrophones', async () => {
458            await loadMicrophones();
459        });
460    });
461
462    // Start process
463    document.getElementById('start-process').addEventListener('click', async () => {
464        buttonManager.setState('start', true);
465
466        try {
467            // The installRequirements function will now check if venv is set up.
468            loadingOverlay.show('Verifying environment setup - please wait...'); // Show overlay with custom message
469            try {
470                await window.electronAPI.installRequirements();
471                appendToConsole('Virtual environment setup checked/completed', 'info');
472            } finally {
473                loadingOverlay.hide(); // Always hide overlay when done
474            }
475
476            await window.electronAPI.startProcess();
477            buttonManager.setProcessRunning();
478            appendToConsole('Process started successfully', 'info');
479        } catch (error) {
480            appendToConsole(`Failed to start process: ${error.message}`, 'stderr');
481            buttonManager.setState('start', false);
482        }
483    });
484
485    // Stop process
486    document.getElementById('stop-process').addEventListener('click', async () => {
487        buttonManager.setState('stop', true);
488
489        try {
490            await window.electronAPI.stopProcess();
491            appendToConsole('Process stop initiated', 'info');
492        } catch (error) {
493            appendToConsole(`Failed to stop process: ${error.message}`, 'stderr');
494            buttonManager.setState('stop', false);
495        }
496    });
497
498    // Listen for process stopped event
499    window.electronAPI.onProcessStopped(() => {
500        buttonManager.setProcessStopped();
501    });
502}
503
504// Initialize application
505window.addEventListener('load', async () => {
506    appendToConsole('TaSTT Configuration UI initialized', 'info');
507
508    loadingOverlay = new LoadingOverlay();
509    buttonManager = new ButtonManager();
510
511    // Set up Python output listener first so we capture all output
512    window.electronAPI.onPythonOutput((data) => {
513        appendToConsole(data.message, data.type);
514    });
515
516    // Load configuration
517    try {
518        const config = await window.electronAPI.loadConfig();
519        setFormValues(config);
520        appendToConsole('Configuration loaded', 'info');
521    } catch (error) {
522        appendToConsole(`Failed to load configuration: ${error.message}`, 'stderr');
523    }
524
525    // Load microphones
526    await loadMicrophones();
527
528    // Setup event handlers and auto-save
529    setupEventHandlers();
530    setupAutoSave();
531});