fix: polish output layout and auto-stop handling
+470 −247 · 8 files changed
-
src/core_parts/processing.py+3 −0 -
src/debug.py+15 −0 -
src/gui.py+2 −0 -
src/settings.py+13 −1 -
src/whispering_ui/bridge.py+88 −33 -
src/whispering_ui/components/output.py+71 −64 -
src/whispering_ui/components/sidebar.py+192 −136 -
src/whispering_ui/main.py+86 −13
@@ -175,6 +175,9 @@ def proc(index, model, vad, memory, patience, timeout, prompt, source, target, t while ts2tl := ts2tl_queue.get(): done_src, curr_src = ts2tl + if curr_src and curr_src.strip(): + last_activity_time = time.time() + # Track last curr_src for exit processing last_curr_src = curr_src @@ -28,3 +28,18 @@ def debug_print(*args, **kwargs): """Print debug message only if debug mode is enabled in settings.""" if DEBUG_ENABLED: print(*args, **kwargs) + + +def set_debug_enabled(enabled: bool, *, persist: bool = False) -> bool: + """Update debug flag at runtime and optionally persist to settings.""" + global DEBUG_ENABLED, _settings + DEBUG_ENABLED = bool(enabled) + + if _settings is None: + _settings = Settings() + + if persist: + _settings.set("debug_enabled", DEBUG_ENABLED) + _settings.save() + + return DEBUG_ENABLED @@ -8,6 +8,7 @@ from tkinter import filedialog, messagebox import core from settings import Settings +from debug import set_debug_enabled from gui_parts.help_dialog import HelpDialog, MODEL_VRAM from gui_parts.text_widget import Text @@ -26,6 +27,7 @@ class App(tk.Tk): # Load debug mode from settings (default: False) self.debug_enabled = self.settings.get("debug_enabled", False) + set_debug_enabled(self.debug_enabled) # Load text visibility state from settings (default: False - start in minimal mode) self.text_visible = self.settings.get("text_visible", False) @@ -13,7 +13,18 @@ class Settings: """Manages application settings persistence.""" def __init__(self, settings_file: str = "whispering_settings.json"): - self.settings_file = Path(settings_file) + settings_path = Path(settings_file) + if not settings_path.is_absolute(): + base_dir = Path(__file__).resolve().parent + project_root = base_dir.parent + primary = (project_root / settings_path).resolve() + legacy = (base_dir / settings_path).resolve() + if primary.exists() or not legacy.exists(): + settings_path = primary + else: + settings_path = legacy + + self.settings_file = settings_path self.defaults = { "mic_index": 0, "model": "large-v3", @@ -38,6 +49,7 @@ class Settings: "ai_trigger_mode": "time", # "time" or "words" "ai_process_words": 150, # Words per processing batch "text_visible": True, # Text windows visible by default + "debug_enabled": False, "auto_stop_enabled": False, # Auto-stop disabled by default "auto_stop_minutes": 5 # Auto-stop after N minutes of inactivity } @@ -45,6 +45,14 @@ class ProcessingBridge: self.tts_session_text = "" self.tts_session_id = None + # Track last committed text for incremental updates + self._whisper_committed = "" + self._translation_committed = "" + self._ai_committed = "" + self._stream_live = False + self._stop_requested = False + self._auto_stopped = False + def start_recording(self): """Start the recording and processing thread.""" if self.state.is_recording: @@ -60,6 +68,15 @@ class ProcessingBridge: self.state.error_message = None self.state.status_message = "Starting..." self.level[0] = 0 + self.state.whisper_text = "" + self.state.translation_text = "" + self.state.ai_text = "" + self._whisper_committed = "" + self._translation_committed = "" + self._ai_committed = "" + self._stream_live = False + self._stop_requested = False + self._auto_stopped = False # Start new TTS session self.tts_session_id = time.strftime("%Y%m%d_%H%M%S") @@ -140,6 +157,7 @@ class ProcessingBridge: return # Signal stop + self._stop_requested = True self.ready[0] = False self.state.status_message = "Stopping..." @@ -161,8 +179,12 @@ class ProcessingBridge: # Update state self.state.is_recording = False - self.state.status_message = "" + if not self._auto_stopped: + self.state.status_message = "" self.state.audio_level = 0 + self._stream_live = False + self._auto_stopped = False + self._stop_requested = False def _start_polling(self): """Start polling queues for new data.""" @@ -189,59 +211,92 @@ class ProcessingBridge: # Update audio level self.state.audio_level = min(100, self.level[0]) + # Update status once audio stream is live + if self.ready[0] is True and not self._stream_live: + self._stream_live = True + self.state.status_message = "Listening..." + elif self.ready[0] is False and self._stream_live and not self._stop_requested and self.state.auto_stop_enabled and not self._auto_stopped: + self._auto_stopped = True + minutes = self.state.auto_stop_minutes + self.state.status_message = f"Auto-stop after {minutes}m of silence" + # Poll whisper queue (Queue wraps PairDeque) while self.ts_queue: res = self.ts_queue.get() if res: done, curr = res - # Append done text (new complete text) - if done and len(done) > len(self.state.whisper_text): - new_text = done[len(self.state.whisper_text):] - self.state.whisper_text = done + done_text = done or "" + curr_text = curr or "" + new_text = "" + + if done_text: + prev_committed = self._whisper_committed if len(done_text) >= len(self._whisper_committed) else "" + if len(done_text) > len(prev_committed): + new_text = done_text[len(prev_committed):] + self._whisper_committed = done_text - # Handle TTS for Whisper - if self.state.tts_enabled and self.state.tts_source == "whisper": - self.tts_session_text += new_text + " " + if new_text: + if self.state.tts_enabled and self.state.tts_source == "whisper": + self.tts_session_text += new_text + " " - # Handle autotype for Whisper - if self.state.autotype_mode == "Whisper": - self._autotype_text(new_text) + if self.state.autotype_mode == "Whisper" and new_text.strip(): + self._autotype_text(new_text) + + preview = self._whisper_committed + curr_text + if preview != self.state.whisper_text: + self.state.whisper_text = preview # Poll translation queue (Queue wraps PairDeque) while self.tl_queue: res = self.tl_queue.get() if res: done, curr = res - # Append done text (new complete text) - if done and len(done) > len(self.state.translation_text): - new_text = done[len(self.state.translation_text):] - self.state.translation_text = done + done_text = done or "" + curr_text = curr or "" + new_text = "" + + if done_text: + prev_committed = self._translation_committed if len(done_text) >= len(self._translation_committed) else "" + if len(done_text) > len(prev_committed): + new_text = done_text[len(prev_committed):] + self._translation_committed = done_text + + if new_text: + if self.state.tts_enabled and self.state.tts_source == "translation": + self.tts_session_text += new_text + " " - # Handle TTS for Translation - if self.state.tts_enabled and self.state.tts_source == "translation": - self.tts_session_text += new_text + " " + if self.state.autotype_mode == "Translation" and new_text.strip(): + self._autotype_text(new_text) - # Handle autotype for Translation - if self.state.autotype_mode == "Translation": - self._autotype_text(new_text) + preview = self._translation_committed + curr_text + if preview != self.state.translation_text: + self.state.translation_text = preview # Poll AI proofread queue (Queue wraps PairDeque) while self.pr_queue: res = self.pr_queue.get() if res: done, curr = res - # Append done text (new complete text) - if done and len(done) > len(self.state.ai_text): - new_text = done[len(self.state.ai_text):] - self.state.ai_text = done - - # Handle TTS for AI - if self.state.tts_enabled and self.state.tts_source == "ai": - self.tts_session_text += new_text + " " - - # Handle autotype for AI - if self.state.autotype_mode == "AI": - self._autotype_text(new_text) + done_text = done or "" + curr_text = curr or "" + new_text = "" + + if done_text: + prev_committed = self._ai_committed if len(done_text) >= len(self._ai_committed) else "" + if len(done_text) > len(prev_committed): + new_text = done_text[len(prev_committed):] + self._ai_committed = done_text + + if new_text: + if self.state.tts_enabled and self.state.tts_source == "ai": + self.tts_session_text += new_text + " " + + if self.state.autotype_mode == "AI" and new_text.strip(): + self._autotype_text(new_text) + + preview = self._ai_committed + curr_text + if preview != self.state.ai_text: + self.state.ai_text = preview def _validate_settings(self) -> bool: """Validate settings before starting. Returns True if valid.""" @@ -19,73 +19,80 @@ def create_output_panels(state: AppState): The output container element for visibility control """ - output_container = ui.column().classes('flex-grow p-4 gap-3') + output_container = ui.column().classes('flex-grow w-full h-full gap-0').style('height: 100%; min-height: 0; padding: 0;') + + panel_defs = [ + { + 'key': 'whisper', + 'title': 'Whisper Output', + 'placeholder': 'Whisper transcription will appear here...', + 'state_attr': 'whisper_text', + 'count_fn': state.get_whisper_count, + 'cut_type': 'whisper', + 'copy_label': 'Whisper', + }, + { + 'key': 'ai', + 'title': 'AI Output', + 'placeholder': 'AI processed text will appear here...', + 'state_attr': 'ai_text', + 'count_fn': state.get_ai_count, + 'cut_type': 'ai', + 'copy_label': 'AI', + }, + { + 'key': 'translation', + 'title': 'Translation Output', + 'placeholder': 'Translation will appear here...', + 'state_attr': 'translation_text', + 'count_fn': state.get_translation_count, + 'cut_type': 'translation', + 'copy_label': 'Translation', + }, + ] + + textareas = {} + count_labels = {} with output_container: - # === WHISPER OUTPUT === - with ui.column().classes('flex-grow'): - with ui.row().classes('items-center justify-between w-full mb-1'): - ui.label('Whisper Output').classes('font-bold') - - # Count and buttons - with ui.row().classes('items-center gap-2'): - whisper_count = ui.label('0 chars, 0 words').classes('text-xs text-gray-500') - ui.button('Copy', on_click=lambda: _copy_text(state.whisper_text, 'Whisper')).props('dense flat').classes('text-xs') - ui.button('Cut', on_click=lambda: _cut_text(state, 'whisper', whisper_area)).props('dense flat').classes('text-xs') - - whisper_area = ui.textarea( - placeholder='Whisper transcription will appear here...' - ).classes('w-full').style('min-height: 200px; font-family: monospace;').props('outlined readonly') - - # === AI OUTPUT === - with ui.column().classes('flex-grow'): - with ui.row().classes('items-center justify-between w-full mb-1'): - ui.label('AI Output').classes('font-bold') - - # Count and buttons - with ui.row().classes('items-center gap-2'): - ai_count = ui.label('0 chars, 0 words').classes('text-xs text-gray-500') - ui.button('Copy', on_click=lambda: _copy_text(state.ai_text, 'AI')).props('dense flat').classes('text-xs') - ui.button('Cut', on_click=lambda: _cut_text(state, 'ai', ai_area)).props('dense flat').classes('text-xs') - - ai_area = ui.textarea( - placeholder='AI processed text will appear here...' - ).classes('w-full').style('min-height: 200px; font-family: monospace;').props('outlined readonly') - - # === TRANSLATION OUTPUT === - with ui.column().classes('flex-grow'): - with ui.row().classes('items-center justify-between w-full mb-1'): - ui.label('Translation Output').classes('font-bold') - - # Count and buttons - with ui.row().classes('items-center gap-2'): - trans_count = ui.label('0 chars, 0 words').classes('text-xs text-gray-500') - ui.button('Copy', on_click=lambda: _copy_text(state.translation_text, 'Translation')).props('dense flat').classes('text-xs') - ui.button('Cut', on_click=lambda: _cut_text(state, 'translation', trans_area)).props('dense flat').classes('text-xs') - - trans_area = ui.textarea( - placeholder='Translation will appear here...' - ).classes('w-full').style('min-height: 200px; font-family: monospace;').props('outlined readonly') - - # Update text areas from state + stack = ui.column().classes('output-stack w-full h-full flex-1') + for config in panel_defs: + with stack: + panel = ui.element('div').classes('output-panel').style('flex: 1; min-height: 0;') + with panel: + with ui.row().classes('panel-header w-full'): + ui.label(config['title']).classes('font-bold') + with ui.row().classes('items-center gap-2'): + count_labels[config['key']] = ui.label('0 chars, 0 words').classes('text-xs text-gray-500') + ui.button( + 'Copy', + on_click=lambda cfg=config: _copy_text( + getattr(state, cfg['state_attr']), + cfg['copy_label'] + ) + ).props('dense flat').classes('text-xs') + ui.button( + 'Cut', + on_click=lambda cfg=config: _cut_text( + state, + cfg['cut_type'], + textareas[cfg['key']] + ) + ).props('dense flat').classes('text-xs') + + textareas[config['key']] = ui.textarea( + placeholder=config['placeholder'] + ).classes('w-full h-full flex-1 output-textarea').style('font-family: monospace; min-height: 0; height: 100%;').props('outlined readonly') + def update_outputs(): - # Update whisper - if whisper_area.value != state.whisper_text: - whisper_area.value = state.whisper_text - wc, ww = state.get_whisper_count() - whisper_count.text = f'{wc} chars, {ww} words' - - # Update AI - if ai_area.value != state.ai_text: - ai_area.value = state.ai_text - ac, aw = state.get_ai_count() - ai_count.text = f'{ac} chars, {aw} words' - - # Update translation - if trans_area.value != state.translation_text: - trans_area.value = state.translation_text - tc, tw = state.get_translation_count() - trans_count.text = f'{tc} chars, {tw} words' + for cfg in panel_defs: + text_value = getattr(state, cfg['state_attr']) + area = textareas[cfg['key']] + if area.value != text_value: + area.value = text_value + + chars, words = cfg['count_fn']() + count_labels[cfg['key']].text = f'{chars} chars, {words} words' ui.timer(0.2, update_outputs) @@ -4,7 +4,7 @@ Complete Sidebar Component with Help and File Upload Control panel with all settings and controls """ -from nicegui import ui +from nicegui import ui, app import core from whispering_ui.state import AppState from whispering_ui.bridge import ProcessingBridge @@ -115,102 +115,113 @@ def create_sidebar(state: AppState, bridge: ProcessingBridge, output_container=N # Enable AI checkbox ai_cb = ui.checkbox('Enable AI', value=state.ai_enabled).props('dense') - ai_cb.on_value_change(lambda e: setattr(state, 'ai_enabled', e.value)) + ai_section = ui.column().classes('w-full gap-1') + + def on_ai_toggle(e): + state.ai_enabled = e.value + _set_section_visual_state(ai_section, e.value) + + ai_cb.on_value_change(on_ai_toggle) if not state.ai_available: ai_cb.disable() # AI controls if state.ai_available: - try: - from ai_config import load_ai_config - ai_config = load_ai_config() - if ai_config: - # Task selection - personas = ai_config.get_personas() - persona_names = [p['name'] for p in personas] - - with ui.row().classes('items-center w-full gap-1'): - ui.label('Task:').classes('text-xs w-12') - task_select = ui.select( - options=persona_names, - value=persona_names[min(state.ai_persona_index, len(persona_names)-1)] - ).classes('flex-grow').props('dense') - task_select.on_value_change(lambda e: setattr(state, 'ai_persona_index', - persona_names.index(e.value) if e.value in persona_names else 0)) - - # Translate checkboxes - compact - with ui.row().classes('items-center w-full gap-2'): - ai_trans_cb = ui.checkbox('Translate', value=state.ai_translate).props('dense') - ai_trans_cb.on_value_change(lambda e: setattr(state, 'ai_translate', e.value)) - - ai_trans_only_cb = ui.checkbox('Only (1:1)', value=state.ai_translate_only).props('dense') - ai_trans_only_cb.on_value_change(lambda e: setattr(state, 'ai_translate_only', e.value)) - - # Model selection - models = ai_config.get_models() - model_names = [m['name'] for m in models] - - with ui.row().classes('items-center w-full gap-1'): - ui.label('Model:').classes('text-xs w-12') - ai_model_combo = ui.select( - options=model_names, - value=model_names[min(state.ai_model_index, len(model_names)-1)] - ).classes('flex-grow').props('dense') - ai_model_combo.on_value_change(lambda e: setattr(state, 'ai_model_index', - model_names.index(e.value) if e.value in model_names else 0)) - - # Trigger controls - compact layout - ai_manual_cb = ui.checkbox('Manual mode', value=state.ai_manual_mode).props('dense') - - ai_process_btn = ui.button('⚡ Process Now', on_click=lambda: bridge.manual_ai_trigger()).classes('w-full').props('dense') - ai_process_btn.set_enabled(state.ai_manual_mode) - - # Trigger mode and settings - with ui.row().classes('items-center w-full gap-1'): - ui.label('Trigger:').classes('text-xs') - ai_trigger_select = ui.select( - options=["Time", "Words"], - value=state.ai_trigger_mode.capitalize() - ).classes('w-16').props('dense') - ai_trigger_select.set_enabled(not state.ai_manual_mode) - - # Interval control - interval_labels = ["5s", "10s", "15s", "20s", "25s", "30s", "45s", "1m", "1.5m", "2m"] - interval_values = [5, 10, 15, 20, 25, 30, 45, 60, 90, 120] - interval_map = dict(zip(interval_labels, interval_values)) - - current_label = "20s" - for lbl, val in interval_map.items(): - if val == state.ai_process_interval: - current_label = lbl - break - - ui.label('Int:').classes('text-xs') - ai_interval_select = ui.select( - options=interval_labels, - value=current_label - ).classes('w-14').props('dense') - def on_interval_change(e): - state.ai_process_interval = interval_map.get(e.value, 20) - ai_interval_select.on_value_change(on_interval_change) - ai_interval_select.set_enabled(not state.ai_manual_mode) - ai_interval_select.set_visibility(state.ai_trigger_mode == "time") - - ui.label('W:').classes('text-xs') - ai_words_num = ui.number(value=state.ai_process_words, min=50, max=500, step=50).classes('w-16').props('dense') - ai_words_num.on_value_change(lambda e: setattr(state, 'ai_process_words', int(e.value or 150))) - ai_words_num.set_enabled(not state.ai_manual_mode) - ai_words_num.set_visibility(state.ai_trigger_mode == "words") - - # Wire up event handlers - ai_manual_cb.on_value_change(lambda e: _on_manual_mode_changed( - state, e.value, ai_process_btn, ai_trigger_select, ai_interval_select, ai_words_num)) - - ai_trigger_select.on_value_change(lambda e: _on_trigger_changed( - state, e.value, ai_interval_select, ai_words_num)) - - except Exception as e: - print(f"Error loading AI config: {e}") + with ai_section: + try: + from ai_config import load_ai_config + ai_config = load_ai_config() + if ai_config: + # Task selection + personas = ai_config.get_personas() + persona_names = [p['name'] for p in personas] + + with ui.row().classes('items-center w-full gap-1'): + ui.label('Task:').classes('text-xs w-12') + task_select = ui.select( + options=persona_names, + value=persona_names[min(state.ai_persona_index, len(persona_names)-1)] + ).classes('flex-grow').props('dense') + task_select.on_value_change(lambda e: setattr(state, 'ai_persona_index', + persona_names.index(e.value) if e.value in persona_names else 0)) + + # Translate checkboxes - compact + with ui.row().classes('items-center w-full gap-2'): + ai_trans_cb = ui.checkbox('Translate', value=state.ai_translate).props('dense') + ai_trans_cb.on_value_change(lambda e: setattr(state, 'ai_translate', e.value)) + + ai_trans_only_cb = ui.checkbox('Only (1:1)', value=state.ai_translate_only).props('dense') + ai_trans_only_cb.on_value_change(lambda e: setattr(state, 'ai_translate_only', e.value)) + + # Model selection + models = ai_config.get_models() + model_names = [m['name'] for m in models] + + with ui.row().classes('items-center w-full gap-1'): + ui.label('Model:').classes('text-xs w-12') + ai_model_combo = ui.select( + options=model_names, + value=model_names[min(state.ai_model_index, len(model_names)-1)] + ).classes('flex-grow').props('dense') + ai_model_combo.on_value_change(lambda e: setattr(state, 'ai_model_index', + model_names.index(e.value) if e.value in model_names else 0)) + + # Trigger controls - compact layout + ai_manual_cb = ui.checkbox('Manual mode', value=state.ai_manual_mode).props('dense') + + ai_process_btn = ui.button('⚡ Process Now', on_click=lambda: bridge.manual_ai_trigger()).classes('w-full').props('dense') + ai_process_btn.set_enabled(state.ai_manual_mode) + + # Trigger mode and settings + with ui.row().classes('items-center w-full gap-1'): + ui.label('Trigger:').classes('text-xs') + ai_trigger_select = ui.select( + options=["Time", "Words"], + value=state.ai_trigger_mode.capitalize() + ).classes('w-16').props('dense') + ai_trigger_select.set_enabled(not state.ai_manual_mode) + + # Interval control + interval_labels = ["5s", "10s", "15s", "20s", "25s", "30s", "45s", "1m", "1.5m", "2m"] + interval_values = [5, 10, 15, 20, 25, 30, 45, 60, 90, 120] + interval_map = dict(zip(interval_labels, interval_values)) + + current_label = "20s" + for lbl, val in interval_map.items(): + if val == state.ai_process_interval: + current_label = lbl + break + + ui.label('Int:').classes('text-xs') + ai_interval_select = ui.select( + options=interval_labels, + value=current_label + ).classes('w-14').props('dense') + + def on_interval_change(e): + state.ai_process_interval = interval_map.get(e.value, 20) + + ai_interval_select.on_value_change(on_interval_change) + ai_interval_select.set_enabled(not state.ai_manual_mode) + ai_interval_select.set_visibility(state.ai_trigger_mode == "time") + + ui.label('W:').classes('text-xs') + ai_words_num = ui.number(value=state.ai_process_words, min=50, max=500, step=50).classes('w-16').props('dense') + ai_words_num.on_value_change(lambda e: setattr(state, 'ai_process_words', int(e.value or 150))) + ai_words_num.set_enabled(not state.ai_manual_mode) + ai_words_num.set_visibility(state.ai_trigger_mode == "words") + + # Wire up event handlers + ai_manual_cb.on_value_change(lambda e: _on_manual_mode_changed( + state, e.value, ai_process_btn, ai_trigger_select, ai_interval_select, ai_words_num)) + + ai_trigger_select.on_value_change(lambda e: _on_trigger_changed( + state, e.value, ai_interval_select, ai_words_num)) + + except Exception as e: + print(f"Error loading AI config: {e}") + + _set_section_visual_state(ai_section, state.ai_enabled and state.ai_available) ui.separator().classes('my-1') @@ -222,58 +233,66 @@ def create_sidebar(state: AppState, bridge: ProcessingBridge, output_container=N # Enable TTS tts_cb = ui.checkbox('Enable TTS', value=state.tts_enabled).props('dense') - tts_cb.on_value_change(lambda e: setattr(state, 'tts_enabled', e.value)) + tts_section = ui.column().classes('w-full gap-1') + + def on_tts_toggle(e): + state.tts_enabled = e.value + _set_section_visual_state(tts_section, e.value) + + tts_cb.on_value_change(on_tts_toggle) if not state.tts_available: tts_cb.disable() if state.tts_available: - # Source selection - compact - with ui.row().classes('items-center w-full gap-1'): - ui.label('Src:').classes('text-xs w-10') + with tts_section: + # Source selection - compact + with ui.row().classes('items-center w-full gap-1'): + ui.label('Src:').classes('text-xs w-10') - tts_w_cb = ui.checkbox('W', value=(state.tts_source == "whisper")).props('dense') - tts_w_cb.on_value_change(lambda e: _on_tts_source_changed(state, "whisper", e.value, tts_w_cb, tts_a_cb, tts_t_cb)) + tts_w_cb = ui.checkbox('W', value=(state.tts_source == "whisper")).props('dense') + tts_a_cb = ui.checkbox('A', value=(state.tts_source == "ai")).props('dense') + tts_t_cb = ui.checkbox('T', value=(state.tts_source == "translation")).props('dense') - tts_a_cb = ui.checkbox('A', value=(state.tts_source == "ai")).props('dense') - tts_a_cb.on_value_change(lambda e: _on_tts_source_changed(state, "ai", e.value, tts_w_cb, tts_a_cb, tts_t_cb)) + tts_w_cb.on_value_change(lambda e: _on_tts_source_changed(state, "whisper", e.value, tts_w_cb, tts_a_cb, tts_t_cb)) + tts_a_cb.on_value_change(lambda e: _on_tts_source_changed(state, "ai", e.value, tts_w_cb, tts_a_cb, tts_t_cb)) + tts_t_cb.on_value_change(lambda e: _on_tts_source_changed(state, "translation", e.value, tts_w_cb, tts_a_cb, tts_t_cb)) - tts_t_cb = ui.checkbox('T', value=(state.tts_source == "translation")).props('dense') - tts_t_cb.on_value_change(lambda e: _on_tts_source_changed(state, "translation", e.value, tts_w_cb, tts_a_cb, tts_t_cb)) + # Voice selection with streamlined upload + with ui.row().classes('items-center w-full gap-1'): + ui.label('Voice:').classes('text-xs w-10') + tts_voice_label = ui.label(state.tts_voice_display_name).classes('flex-grow text-xs text-gray-400 truncate') - # Voice selection with file upload - with ui.row().classes('items-center w-full gap-1'): - ui.label('Voice:').classes('text-xs w-10') - tts_voice_label = ui.label(state.tts_voice_display_name).classes('flex-grow text-xs text-gray-400 truncate') + upload = ui.upload( + on_upload=lambda e: _on_voice_upload(e, state, bridge, tts_voice_label), + auto_upload=True, + max_file_size=50_000_000, + max_files=1 + ).props('accept=audio/*').classes('hidden') - # File upload button - upload = ui.upload( - on_upload=lambda e: _on_voice_upload(e, state, bridge, tts_voice_label), - auto_upload=True, - max_file_size=50_000_000 # 50 MB max - ).props('flat dense icon=folder_open accept=audio/*').classes('w-8').style('max-width: 32px') - upload.set_visibility(True) + ui.button(icon='folder_open', on_click=lambda u=upload: u.run_method('pickFiles')).props('flat dense round size=sm') + ui.button(icon='clear', on_click=lambda: _clear_voice(state, bridge, tts_voice_label)).props('flat dense round size=sm') - tts_clear_btn = ui.button(icon='clear', on_click=lambda: _clear_voice(state, bridge, tts_voice_label)).props('flat dense round size=sm') + # Output options - compact + with ui.row().classes('items-center w-full gap-1'): + ui.label('Out:').classes('text-xs w-10') + tts_save_cb = ui.checkbox('Save', value=state.tts_save_file).props('dense') + tts_save_cb.on_value_change(lambda e: setattr(state, 'tts_save_file', e.value)) - # Output options - compact - with ui.row().classes('items-center w-full gap-1'): - ui.label('Out:').classes('text-xs w-10') - tts_save_cb = ui.checkbox('Save', value=state.tts_save_file).props('dense') - tts_save_cb.on_value_change(lambda e: setattr(state, 'tts_save_file', e.value)) + tts_format_select = ui.select(options=["wav", "ogg"], value=state.tts_format).classes('w-16').props('dense') + tts_format_select.on_value_change(lambda e: setattr(state, 'tts_format', e.value)) - tts_format_select = ui.select(options=["wav", "ogg"], value=state.tts_format).classes('w-16').props('dense') - tts_format_select.on_value_change(lambda e: setattr(state, 'tts_format', e.value)) + # TTS status - compact + tts_status_label = ui.label('').classes('text-xs text-blue-400') - # TTS status - compact - tts_status_label = ui.label('').classes('text-xs text-blue-400') + def update_tts_status(): + if state.tts_status_message: + tts_status_label.text = state.tts_status_message[:50] + else: + tts_status_label.text = '' - def update_tts_status(): - if state.tts_status_message: - tts_status_label.text = state.tts_status_message[:50] - else: - tts_status_label.text = '' + ui.timer(0.2, update_tts_status) - ui.timer(0.2, update_tts_status) + _set_section_visual_state(tts_section, state.tts_enabled and state.tts_available) ui.separator().classes('my-1') @@ -332,13 +351,7 @@ def _toggle_text(state: AppState, btn, sidebar_container): state.text_visible = not state.text_visible btn.text = 'Hide Text ◀' if state.text_visible else 'Show Text ▶' - # Get output container from sidebar - if hasattr(sidebar_container, '_output_container'): - output_container = sidebar_container._output_container - if output_container: - output_container.set_visibility(state.text_visible) - - ui.notify(f'Text panels {"shown" if state.text_visible else "hidden"}') + sync_text_layout(state, sidebar_container, notify=True) def _toggle_recording(state: AppState, bridge: ProcessingBridge, btn, level, status): @@ -426,3 +439,46 @@ def _clear_voice(state: AppState, bridge: ProcessingBridge, voice_label): bridge.tts_controller.set_reference_voice(None) ui.notify("Voice cleared - using default") + + +def sync_text_layout(state: AppState, sidebar_container, *, notify: bool = False): + """Apply sidebar/output layout updates when text panes toggle.""" + output_container = getattr(sidebar_container, '_output_container', None) + if output_container: + output_container.set_visibility(state.text_visible) + + layout_row = getattr(sidebar_container, '_layout_row', None) + if layout_row: + if state.text_visible: + layout_row.classes(remove='workspace-collapsed') + else: + layout_row.classes(add='workspace-collapsed') + + _resize_native_window(state.text_visible) + + if notify: + ui.notify(f'Text panels {"shown" if state.text_visible else "hidden"}') + + +def _resize_native_window(text_visible: bool): + """Resize native pywebview window to match sidebar/text mode.""" + try: + window = getattr(app.native, 'main_window', None) + if not window: + return + target_width = 1200 if text_visible else 420 + current_height = getattr(window, 'height', 800) or 800 + window.resize(target_width, current_height) + except Exception: + # Ignore resize errors in browser mode or unsupported backends + return + + +def _set_section_visual_state(section, enabled: bool): + """Dim or undim a configuration section without disabling inputs.""" + if not section: + return + if enabled: + section.classes(remove='section-muted') + else: + section.classes(add='section-muted') @@ -4,6 +4,7 @@ Whispering NiceGUI Application Main entry point for the NiceGUI-based UI """ +import os import sys from pathlib import Path @@ -15,8 +16,9 @@ import core from settings import Settings from whispering_ui.state import AppState from whispering_ui.bridge import ProcessingBridge -from whispering_ui.components.sidebar import create_sidebar +from whispering_ui.components.sidebar import create_sidebar, sync_text_layout from whispering_ui.components.output import create_output_panels +from debug import set_debug_enabled def main(): @@ -31,6 +33,7 @@ def main(): # Apply loaded settings to state state.debug_enabled = settings.get("debug_enabled", False) + set_debug_enabled(state.debug_enabled) state.text_visible = settings.get("text_visible", False) state.model = settings.get("model", "large-v3") state.vad_enabled = settings.get("vad", True) @@ -116,6 +119,15 @@ def main(): padding: 0 !important; } + .workspace-row { + transition: max-width 0.25s ease; + } + + .workspace-row.workspace-collapsed { + max-width: 420px; + margin: 0 auto; + } + /* Sidebar - fixed width, dark background */ .sidebar-container { background: #1e1e1e; @@ -129,7 +141,55 @@ def main(): .output-container { background: #121212; flex: 1; - overflow-y: auto; + display: flex; + flex-direction: column; + min-height: 0; + height: 100%; + overflow: hidden; + padding: 0; + } + + .output-stack { + display: flex; + flex-direction: column; + gap: 0.75rem; + flex: 1; + min-height: 0; + height: 100%; + width: 100%; + padding: 0.75rem; + } + + .output-panel { + background: #181818; + border: 1px solid #2a2a2a; + border-radius: 8px; + padding: 0.5rem 0.75rem; + display: flex; + flex-direction: column; + min-height: 0; + flex: 1 1 0; + width: 100%; + } + + .output-panel .panel-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.25rem; + } + + .output-textarea { + flex: 1 1 0; + min-height: 0; + width: 100%; + height: 100%; + resize: none; + font-family: Menlo, Consolas, 'Liberation Mono', monospace; + } + + .section-muted { + opacity: 0.65; } /* Compact controls */ @@ -164,7 +224,7 @@ def main(): # === UI LAYOUT === # Horizontal split: sidebar (left) | output panels (right) - with ui.row().classes('w-full h-screen').style('margin: 0; padding: 0; gap: 0;'): + with ui.row().classes('workspace-row w-full h-screen').style('margin: 0; padding: 0; gap: 0;') as workspace_row: # Sidebar on left - fixed width sidebar_container = create_sidebar(state, bridge, None).classes('sidebar-container') @@ -173,10 +233,10 @@ def main(): # Connect sidebar to output for show/hide sidebar_container._output_container = output_container + sidebar_container._layout_row = workspace_row # Set initial visibility - if not state.text_visible: - output_container.set_visibility(False) + sync_text_layout(state, sidebar_container, notify=False) # === SAVE SETTINGS ON EXIT === def save_settings_on_exit(): @@ -238,14 +298,27 @@ def main(): print(" For native window, install: pip install PyQt6 PyQt6-WebEngine") print(" Starting web interface at http://127.0.0.1:8000\n") - ui.run( - title='Whispering', - native=native_mode, - port=8000, - window_size=(400 if not state.text_visible else 1200, 800), - reload=False, - show=not native_mode # Only auto-open browser in browser mode - ) + if native_mode: + os.environ.setdefault('PYWEBVIEW_GUI', 'qt') + + def launch_ui(use_native: bool): + ui.run( + title='Whispering', + native=use_native, + port=8000, + window_size=(400 if not state.text_visible else 1200, 800), + reload=False, + show=not use_native # Only auto-open browser in browser mode + ) + + try: + launch_ui(native_mode) + except ModuleNotFoundError as exc: + if native_mode and exc.name == 'gi': + print("\n⚠️ GTK bindings (gi) are missing; falling back to browser mode.\n") + launch_ui(False) + else: + raise if __name__ == "__main__":