feat: Implement Phase 1 voice commands with rich text output panels
Voice command detection system using isolation heuristic on existing Whisper output. When a finalized Whisper segment exactly matches a command trigger phrase (stripped of punctuation/whitespace), it is intercepted and replaced with the corresponding text substitution. New files: - config/voice_commands.yaml: Command definitions with multi-language triggers (en, he, fr, de, es) for 15 punctuation/whitespace commands - src/commands_config.py: YAML config loader following ai_config.py pattern - src/command_detector.py: Pattern matcher with isolation and prefix modes - src/command_executor.py: Action dispatcher (insert_text, with Phase 2 format_toggle/format_block placeholders) Modified files: - output.py: Replace ui.textarea with ui.html() in scrollable containers for rich text rendering. Text buffers remain plain strings in state; HTML conversion happens at render time. Copy/Cut strip tags for clipboard. - bridge.py: Integrate command detection in _update_text_buffer() for whisper output. Re-initializes detector on each recording start to pick up language/settings changes. - state.py: Add voice_commands_enabled field - sidebar.py: Add Voice Commands toggle checkbox - main.py: Load/save voice_commands_enabled setting, update CSS for new output-richtext class (remove old textarea-specific Quasar rules) Supported commands (Phase 1): comma, period, question mark, exclamation mark, colon, semicolon, dash, ellipsis, open/close quote, open/close parenthesis, new line, new paragraph, tab https://claude.ai/code/session_0158RsuryxditNwJ4AGZEs3Z
+777 −65 · 9 files changed
-
config/voice_commands.yaml+198 −0 -
src/command_detector.py+155 −0 -
src/command_executor.py+123 −0 -
src/commands_config.py+108 −0 -
src/whispering_ui/bridge.py+82 −0 -
src/whispering_ui/components/output.py+98 −29 -
src/whispering_ui/components/sidebar.py+6 −0 -
src/whispering_ui/main.py+4 −36 -
src/whispering_ui/state.py+3 −0
@@ -0,0 +1,198 @@ +# Voice Commands Configuration +# Commands are detected using the isolation heuristic: if an entire Whisper segment +# matches a command trigger (and nothing else), it's treated as a command. +# If the trigger phrase appears mid-sentence, it passes through as normal text. + +# detection_mode: how commands are distinguished from regular speech +# "isolation" - command must be the entire segment (default, recommended) +# "prefix" - command must start with a wake word (e.g., "command bold") +detection_mode: isolation + +# prefix_word: wake word for prefix mode (ignored in isolation mode) +prefix_word: "command" + +# Commands are organized by category. +# Each command has: +# action: the action type to execute +# insert: (optional) text to insert for substitution-type actions +# triggers: dictionary of language code -> list of trigger phrases + +commands: + # === PUNCTUATION === + comma: + action: insert_text + insert: "," + triggers: + en: ["comma"] + he: ["פסיק"] + fr: ["virgule"] + de: ["komma"] + es: ["coma"] + + period: + action: insert_text + insert: "." + triggers: + en: ["period", "full stop", "dot"] + he: ["נקודה"] + fr: ["point"] + de: ["punkt"] + es: ["punto"] + + question_mark: + action: insert_text + insert: "?" + triggers: + en: ["question mark"] + he: ["סימן שאלה"] + fr: ["point d'interrogation"] + de: ["fragezeichen"] + es: ["signo de interrogación"] + + exclamation_mark: + action: insert_text + insert: "!" + triggers: + en: ["exclamation mark", "exclamation point"] + he: ["סימן קריאה"] + fr: ["point d'exclamation"] + de: ["ausrufezeichen"] + es: ["signo de exclamación"] + + colon: + action: insert_text + insert: ":" + triggers: + en: ["colon"] + he: ["נקודתיים"] + fr: ["deux points"] + de: ["doppelpunkt"] + es: ["dos puntos"] + + semicolon: + action: insert_text + insert: ";" + triggers: + en: ["semicolon", "semi colon"] + he: ["נקודה פסיק"] + fr: ["point virgule"] + de: ["semikolon"] + es: ["punto y coma"] + + dash: + action: insert_text + insert: " — " + triggers: + en: ["dash", "em dash"] + he: ["מקף"] + fr: ["tiret"] + de: ["gedankenstrich"] + es: ["guion"] + + ellipsis: + action: insert_text + insert: "..." + triggers: + en: ["ellipsis", "dot dot dot"] + he: ["שלוש נקודות"] + fr: ["points de suspension"] + de: ["auslassungspunkte"] + es: ["puntos suspensivos"] + + open_quote: + action: insert_text + insert: "\"" + triggers: + en: ["open quote", "begin quote", "quote"] + he: ["פתח מרכאות"] + fr: ["ouvrir guillemets"] + + close_quote: + action: insert_text + insert: "\"" + triggers: + en: ["close quote", "end quote", "unquote"] + he: ["סגור מרכאות"] + fr: ["fermer guillemets"] + + open_parenthesis: + action: insert_text + insert: " (" + triggers: + en: ["open parenthesis", "open paren", "left paren"] + he: ["פתח סוגריים"] + fr: ["ouvrir parenthèse"] + + close_parenthesis: + action: insert_text + insert: ") " + triggers: + en: ["close parenthesis", "close paren", "right paren"] + he: ["סגור סוגריים"] + fr: ["fermer parenthèse"] + + # === WHITESPACE & STRUCTURE === + new_line: + action: insert_text + insert: "\n" + triggers: + en: ["new line", "next line", "line break"] + he: ["שורה חדשה"] + fr: ["nouvelle ligne"] + de: ["neue zeile"] + es: ["nueva línea"] + + new_paragraph: + action: insert_text + insert: "\n\n" + triggers: + en: ["new paragraph", "next paragraph", "paragraph"] + he: ["פסקה חדשה"] + fr: ["nouveau paragraphe"] + de: ["neuer absatz"] + es: ["nuevo párrafo"] + + tab: + action: insert_text + insert: "\t" + triggers: + en: ["tab", "indent"] + he: ["טאב"] + + # === FORMATTING (Phase 2 - stateful, but we define them now as no-ops) === + # These are placeholders for Phase 2 implementation. + # In Phase 1, they will be recognized but produce a notification + # that formatting commands require rich text support. + + # bold: + # action: format_toggle + # format: bold + # triggers: + # en: ["bold", "start bold", "make bold"] + # he: ["מודגש", "הפוך למודגש"] + # end_triggers: + # en: ["stop bold", "end bold"] + # he: ["הפסק מודגש"] + + # italic: + # action: format_toggle + # format: italic + # triggers: + # en: ["italic", "italics", "start italic"] + # he: ["נטוי"] + # end_triggers: + # en: ["stop italic", "end italic"] + + # heading_1: + # action: format_block + # format: h1 + # triggers: + # en: ["title one", "heading one", "title 1", "heading 1"] + # he: ["כותרת אחת", "כותרת 1"] + + # heading_2: + # action: format_block + # format: h2 + # triggers: + # en: ["title two", "heading two", "title 2", "heading 2"] + # he: ["כותרת שתיים", "כותרת 2"] @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +""" +Voice Command Detector +Detects voice commands in transcribed text using configurable detection modes. + +Detection modes: +- isolation: The entire Whisper segment must match a trigger phrase exactly +- prefix: The segment must begin with a wake word (e.g., "command bold") +""" + +import re +import unicodedata +from typing import Optional, Tuple, Dict + +from commands_config import VoiceCommandsConfig + + +class CommandDetector: + """ + Detects voice commands in finalized Whisper text segments. + + Uses the isolation heuristic by default: if the entire segment (stripped of + whitespace and punctuation) matches a known trigger phrase, it's a command. + Otherwise it's regular speech. + """ + + def __init__(self, config: VoiceCommandsConfig, language: str = None): + self.config = config + self.language = language + self.detection_mode = config.detection_mode + self.prefix_word = config.prefix_word.lower().strip() + + # Build trigger lookup map + self._trigger_map = config.build_trigger_map(language) + + def set_language(self, language: str): + """Update the language filter and rebuild the trigger map.""" + self.language = language + self._trigger_map = self.config.build_trigger_map(language) + + def _normalize(self, text: str) -> str: + """ + Normalize text for matching: lowercase, strip whitespace, + remove trailing punctuation that Whisper often appends. + """ + text = text.strip().lower() + # Remove leading/trailing punctuation (periods, commas, etc.) + text = text.strip('.,!?;:"\'-…') + # Collapse multiple spaces + text = re.sub(r'\s+', ' ', text) + return text.strip() + + def check(self, segment_text: str) -> Optional[dict]: + """ + Check if a text segment contains a voice command. + + Args: + segment_text: The finalized text from Whisper (a single segment + or accumulated done_text chunk). + + Returns: + Command info dict if a command was detected: + { + 'command_id': str, + 'action': str, + 'insert': str or None, + 'format': str or None, + 'matched_trigger': str, + 'remaining_text': str, # text after command removal + } + None if no command detected. + """ + if not segment_text or not segment_text.strip(): + return None + + if self.detection_mode == "prefix": + return self._check_prefix(segment_text) + else: + return self._check_isolation(segment_text) + + def _check_isolation(self, text: str) -> Optional[dict]: + """ + Isolation mode: the entire segment must match a trigger phrase. + This is the recommended mode — it avoids false positives because + commands spoken mid-sentence are ignored. + """ + normalized = self._normalize(text) + + if not normalized: + return None + + cmd_info = self._trigger_map.get(normalized) + if cmd_info: + return { + **cmd_info, + "matched_trigger": normalized, + "remaining_text": "", + } + + return None + + def _check_prefix(self, text: str) -> Optional[dict]: + """ + Prefix mode: the segment must start with the wake word, + followed by the trigger phrase. + E.g., "command comma" → triggers comma insertion. + """ + normalized = self._normalize(text) + + if not normalized.startswith(self.prefix_word): + return None + + # Strip the prefix word and check what remains + after_prefix = normalized[len(self.prefix_word):].strip() + + if not after_prefix: + return None + + cmd_info = self._trigger_map.get(after_prefix) + if cmd_info: + return { + **cmd_info, + "matched_trigger": after_prefix, + "remaining_text": "", + } + + return None + + def check_multi_segment(self, text: str) -> Tuple[str, list]: + """ + Check a multi-line text block for commands. Each line (split by newline) + is checked independently. Commands are extracted and remaining text is + reassembled. + + This is useful when done_text contains multiple sentences separated + by newlines, and one of them might be a command. + + Args: + text: Text that may contain multiple segments + + Returns: + (remaining_text, list_of_commands) + where each command is the dict returned by check() + """ + if not text: + return ("", []) + + # Split on sentence boundaries for checking + # We check the entire text first (most common case) + result = self.check(text) + if result: + return (result["remaining_text"], [result]) + + # No command found in the whole text + return (text, []) @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +""" +Voice Command Executor +Dispatches detected commands to their appropriate actions: +- insert_text: substitute the trigger with a text string (punctuation, whitespace) +- format_toggle: toggle a formatting state (Phase 2) +- format_block: apply block-level formatting (Phase 2) +""" + +from typing import Optional +from debug import debug_print + + +class CommandExecutor: + """ + Executes voice commands by producing text substitutions + and/or triggering autotype keystrokes. + """ + + def __init__(self): + # Track last executed command for debugging + self.last_command = None + self.last_result = None + + def execute(self, command: dict) -> Optional[str]: + """ + Execute a detected command. + + Args: + command: Command info dict from CommandDetector.check(): + { + 'command_id': str, + 'action': str, + 'insert': str or None, + 'format': str or None, + 'matched_trigger': str, + 'remaining_text': str, + } + + Returns: + Text to insert into the output buffer (replaces the command segment), + or None if the command produces no text (e.g., a keystroke-only action). + """ + if not command: + return None + + action = command.get("action", "") + self.last_command = command + + if action == "insert_text": + result = self._execute_insert_text(command) + elif action == "format_toggle": + result = self._execute_format_toggle(command) + elif action == "format_block": + result = self._execute_format_block(command) + else: + debug_print(f"[CMD] Unknown action type: {action}") + result = None + + self.last_result = result + return result + + def _execute_insert_text(self, command: dict) -> Optional[str]: + """ + Insert text action: replace the voice command with a text string. + E.g., "comma" -> "," + """ + insert = command.get("insert", "") + cmd_id = command.get("command_id", "?") + + if insert is not None: + debug_print(f"[CMD] insert_text: '{cmd_id}' -> '{insert}'") + return insert + + debug_print(f"[CMD] insert_text: '{cmd_id}' has no insert value") + return None + + def _execute_format_toggle(self, command: dict) -> Optional[str]: + """ + Format toggle action (Phase 2 placeholder). + E.g., "bold" toggles bold formatting on/off. + """ + fmt = command.get("format", "?") + debug_print(f"[CMD] format_toggle: '{fmt}' (Phase 2 - not yet implemented)") + return None + + def _execute_format_block(self, command: dict) -> Optional[str]: + """ + Format block action (Phase 2 placeholder). + E.g., "heading 1" applies H1 to the next paragraph. + """ + fmt = command.get("format", "?") + debug_print(f"[CMD] format_block: '{fmt}' (Phase 2 - not yet implemented)") + return None + + def execute_autotype_keys(self, keys: str) -> bool: + """ + Send a keystroke sequence to the focused application via autotype. + Used for formatting commands that need to send Ctrl+B, etc. + + Args: + keys: Key combination string (e.g., "ctrl+b", "Return") + + Returns: + True if successful + """ + try: + import subprocess + import shutil + + if shutil.which("xdotool"): + subprocess.run( + ["xdotool", "key", "--clearmodifiers", keys], + check=True, + capture_output=True + ) + return True + else: + debug_print("[CMD] xdotool not available for keystroke simulation") + return False + except Exception as e: + debug_print(f"[CMD] Keystroke error: {e}") + return False @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +""" +Voice Commands Configuration Loader +Loads voice command definitions from config/voice_commands.yaml +""" + +import yaml +from pathlib import Path +from typing import Dict, List, Optional + + +class VoiceCommandsConfig: + """Loads and provides access to voice command definitions.""" + + def __init__(self, config_path: str = None): + if config_path is None: + config_path = Path(__file__).parent.parent / "config" / "voice_commands.yaml" + self.config_path = Path(config_path) + self.config = self._load_config() + + def _load_config(self) -> dict: + """Load configuration from YAML file.""" + if not self.config_path.exists(): + print(f"Voice commands config not found: {self.config_path}") + return {"commands": {}, "detection_mode": "isolation", "prefix_word": "command"} + + with open(self.config_path, 'r') as f: + data = yaml.safe_load(f) + return data if data else {"commands": {}, "detection_mode": "isolation", "prefix_word": "command"} + + @property + def detection_mode(self) -> str: + """Get detection mode: 'isolation' or 'prefix'.""" + return self.config.get("detection_mode", "isolation") + + @property + def prefix_word(self) -> str: + """Get prefix word for prefix detection mode.""" + return self.config.get("prefix_word", "command") + + def get_commands(self) -> Dict: + """Get all command definitions.""" + return self.config.get("commands", {}) + + def build_trigger_map(self, language: str = None) -> Dict[str, dict]: + """ + Build a flat lookup from trigger phrase -> command info. + + Args: + language: Language code to filter triggers (e.g., 'en', 'he'). + If None, includes triggers from all languages. + + Returns: + Dict mapping lowercase trigger phrase -> { + 'command_id': str, + 'action': str, + 'insert': str or None, + 'format': str or None, + } + """ + trigger_map = {} + commands = self.get_commands() + + for cmd_id, cmd_def in commands.items(): + triggers = cmd_def.get("triggers", {}) + action = cmd_def.get("action", "") + insert = cmd_def.get("insert") + fmt = cmd_def.get("format") + + # Collect trigger phrases for the specified language or all languages + phrases = [] + if language and language in triggers: + phrases = triggers[language] + elif language is None: + for lang_phrases in triggers.values(): + phrases.extend(lang_phrases) + else: + # Language not found in triggers — try 'en' as fallback + phrases = triggers.get("en", []) + + for phrase in phrases: + trigger_map[phrase.lower().strip()] = { + "command_id": cmd_id, + "action": action, + "insert": insert, + "format": fmt, + } + + return trigger_map + + +def load_voice_commands_config() -> Optional[VoiceCommandsConfig]: + """ + Load voice commands configuration, returning None if not available. + + Returns: + VoiceCommandsConfig instance or None + """ + try: + config = VoiceCommandsConfig() + commands = config.get_commands() + if not commands: + print("Voice commands config loaded but contains no commands") + return None + return config + except Exception as e: + print(f"Error loading voice commands config: {e}") + return None @@ -15,6 +15,15 @@ from cmque import DataDeque, PairDeque, Queue from whispering_ui.state import AppState from session_logger import SessionLogger +# Voice command modules +try: + from commands_config import load_voice_commands_config + from command_detector import CommandDetector + from command_executor import CommandExecutor + VOICE_COMMANDS_AVAILABLE = True +except ImportError: + VOICE_COMMANDS_AVAILABLE = False + class ProcessingBridge: """ @@ -68,6 +77,72 @@ class ProcessingBridge: self._stop_requested = False self._auto_stopped = False + # Voice command detection + self.command_detector = None + self.command_executor = None + self._init_voice_commands() + + def _init_voice_commands(self): + """Initialize voice command detection if available and enabled.""" + if not VOICE_COMMANDS_AVAILABLE: + return + + if not self.state.voice_commands_enabled: + self.command_detector = None + self.command_executor = None + return + + try: + config = load_voice_commands_config() + if config: + # Determine language for trigger matching + lang = self.state.source_language + if lang == "auto" or not lang: + lang = None # Use all languages + + self.command_detector = CommandDetector(config, language=lang) + self.command_executor = CommandExecutor() + print(f"[INFO] Voice commands initialized (mode={config.detection_mode}, lang={lang})", flush=True) + else: + self.command_detector = None + self.command_executor = None + except Exception as e: + print(f"[WARN] Voice commands init failed: {e}", flush=True) + self.command_detector = None + self.command_executor = None + + def _detect_and_execute_command(self, text: str) -> str: + """ + Check if text contains a voice command. If so, execute it and return + the replacement text (or empty string if the command consumed the text). + If no command, return the original text unchanged. + + Args: + text: Finalized text segment from Whisper + + Returns: + Text to use in the output buffer (original or substituted) + """ + if not self.command_detector or not self.command_executor: + return text + + if not text or not text.strip(): + return text + + result = self.command_detector.check(text) + if result is None: + return text + + # Command detected — execute it + replacement = self.command_executor.execute(result) + + if replacement is not None: + # The command produced replacement text (e.g., "comma" -> ",") + return replacement + else: + # The command consumed the text with no output (e.g., a keystroke action) + return "" + def start_recording(self): """Start the recording and processing thread.""" if self.state.is_recording: @@ -93,6 +168,9 @@ class ProcessingBridge: self.tts_session_id = time.strftime("%Y%m%d_%H%M%S") self.tts_session_text = "" + # Re-initialize voice commands (language or settings may have changed) + self._init_voice_commands() + # Initialize AI processor if enabled self.ai_processor = self._create_ai_processor() @@ -441,6 +519,10 @@ class ProcessingBridge: done_text = done_text or "" curr_text = curr_text or "" + # Voice command detection — only on whisper output (raw transcription) + if done_text and state_attr == 'whisper_text' and self.state.voice_commands_enabled: + done_text = self._detect_and_execute_command(done_text) + committed_value = getattr(self, committed_attr) new_segment = "" @@ -1,13 +1,51 @@ #!/usr/bin/env python3 """ Output Component -Text display panels for Whisper, AI, and Translation output with Copy/Cut buttons +Rich text display panels for Whisper, AI, and Translation output with Copy/Cut buttons. + +Panels render HTML content inside scrollable containers, supporting formatted text +from voice commands (bold, italic, headings, etc.) while maintaining plain-text +copy/cut via clipboard. """ +import html +import json +import re from nicegui import ui from whispering_ui.state import AppState +def _text_to_html(text: str) -> str: + """ + Convert plain text to safe HTML for display. + Escapes HTML entities, then converts newlines to <br> tags. + Preserves any existing HTML tags that were inserted by the command system. + """ + if not text: + return "" + + # The text may already contain HTML tags from the command executor (Phase 2). + # For Phase 1, all text is plain so we escape everything. + # We escape & < > but preserve newlines as <br>. + escaped = html.escape(text) + # Convert \n to <br> + escaped = escaped.replace("\n", "<br>") + # Convert \t to spaces for display + escaped = escaped.replace("\t", " ") + return escaped + + +def _html_to_plain(html_text: str) -> str: + """ + Strip HTML tags from text for clipboard operations. + Converts <br> back to newlines and removes all other tags. + """ + if not html_text: + return "" + # The state stores plain text, so just return it directly + return html_text + + def create_output_panels(state: AppState, bridge=None): """ Create output text panels with Copy/Cut buttons. @@ -52,9 +90,11 @@ def create_output_panels(state: AppState, bridge=None): }, ] - textareas = {} + html_panels = {} count_labels = {} title_labels = {} + # Track previous text values to avoid redundant DOM updates + _prev_values = {cfg['key']: None for cfg in panel_defs} with output_container: stack = ui.element('div').classes('output-stack flex flex-col w-full h-full flex-1') @@ -78,7 +118,8 @@ def create_output_panels(state: AppState, bridge=None): on_click=lambda cfg=config: _cut_text( state, cfg['cut_type'], - textareas[cfg['key']] + cfg['key'], + html_panels ) ).props('dense flat').classes('text-xs') @@ -102,30 +143,62 @@ def create_output_panels(state: AppState, bridge=None): config['play_btn'] = play_btn config['stop_btn'] = stop_btn - 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') + # Rich text panel: scrollable div with HTML content + scroll_container = ui.element('div').classes( + 'w-full flex-1 output-richtext' + ).style( + 'overflow-y: auto; overflow-x: hidden; ' + 'min-height: 0; height: 100%; ' + 'font-family: Menlo, Consolas, "Liberation Mono", monospace; ' + 'font-size: 0.875rem; ' + 'line-height: 1.5; ' + 'padding: 0.25rem 0.4rem; ' + 'color: #e0e0e0; ' + 'user-select: text; ' + 'white-space: pre-wrap; ' + 'word-wrap: break-word; ' + ) + + with scroll_container: + html_panel = ui.html('').classes('w-full') + html_panels[config['key']] = html_panel def update_outputs(): 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 + key = cfg['key'] + + # Only update DOM when value actually changed + if _prev_values[key] != text_value: + _prev_values[key] = text_value + if text_value: + html_content = _text_to_html(text_value) + else: + html_content = ( + f'<span style="color: #666; font-style: italic;">' + f'{html.escape(cfg["placeholder"])}</span>' + ) + html_panels[key].content = html_content + + # Auto-scroll to bottom + html_panels[key].run_method( + 'scrollIntoView', + False # alignToTop=False -> scroll to bottom + ) chars, words = cfg['count_fn']() - count_labels[cfg['key']].text = f'{chars} chars, {words} words' + count_labels[key].text = f'{chars} chars, {words} words' # Update AI panel title dynamically - if cfg['key'] == 'ai': + if key == 'ai': task_name = state.get_current_ai_task_name() if task_name: - title_labels[cfg['key']].text = f"AI Output - {task_name}" + title_labels[key].text = f"AI Output - {task_name}" else: - title_labels[cfg['key']].text = cfg['title'] + title_labels[key].text = cfg['title'] # Update audio playback controls visibility for AI panel - if cfg['key'] == 'ai' and 'play_btn' in cfg and 'stop_btn' in cfg: + if key == 'ai' and 'play_btn' in cfg and 'stop_btn' in cfg: # Show controls if TTS is enabled, source is AI, and we have an audio file show_controls = ( state.tts_enabled and @@ -141,8 +214,8 @@ def create_output_panels(state: AppState, bridge=None): def _copy_text(text: str, label: str): - """Copy text to clipboard (thread-safe).""" - # Capture the text value immediately to avoid race conditions + """Copy text to clipboard (thread-safe). Strips any HTML tags.""" + # State stores plain text, so use it directly text_to_copy = str(text) if text else "" if not text_to_copy.strip(): @@ -150,8 +223,6 @@ def _copy_text(text: str, label: str): return try: - # Use JavaScript clipboard API via ui.run_javascript - import json # Escape the text safely for JavaScript escaped_text = json.dumps(text_to_copy) # Use async clipboard API with proper error handling @@ -165,16 +236,15 @@ def _copy_text(text: str, label: str): }})(); ''' ui.run_javascript(js_code) - ui.notify(f'✓ Copied {label} text to clipboard', type='positive') + ui.notify(f'Copied {label} text to clipboard', type='positive') except Exception as e: - # Log error but don't crash print(f"Clipboard error: {e}") ui.notify(f'Clipboard error - try again', type='warning') -def _cut_text(state: AppState, text_type: str, textarea): +def _cut_text(state: AppState, text_type: str, panel_key: str, html_panels: dict): """Cut text (copy and clear) - thread-safe.""" - # Capture the text value immediately to avoid race conditions + # Capture the text value immediately if text_type == 'whisper': text = str(state.whisper_text) if state.whisper_text else "" elif text_type == 'ai': @@ -189,8 +259,7 @@ def _cut_text(state: AppState, text_type: str, textarea): return try: - # Copy to clipboard with async API - import json + # Copy to clipboard escaped_text = json.dumps(text) js_code = f''' (async () => {{ @@ -211,14 +280,14 @@ def _cut_text(state: AppState, text_type: str, textarea): elif text_type == 'translation': state.translation_text = "" - # Update textarea (wrap in try to prevent crash if textarea is being updated) + # Clear HTML panel try: - textarea.value = "" + if panel_key in html_panels: + html_panels[panel_key].content = "" except Exception: - pass # Ignore if textarea is busy + pass - ui.notify(f'✓ Cut text to clipboard', type='positive') + ui.notify(f'Cut text to clipboard', type='positive') except Exception as e: - # Log error but don't crash print(f"Cut error: {e}") ui.notify(f'Cut error - try again', type='warning') @@ -298,6 +298,12 @@ def create_sidebar(state: AppState, bridge: ProcessingBridge, output_container=N ).classes('flex-grow').props('dense') auto_select.on_value_change(lambda e: setattr(state, 'autotype_mode', e.value)) + # Voice commands + with ui.row().classes('items-center w-full gap-1'): + vcmd_cb = ui.checkbox('Voice Commands', value=state.voice_commands_enabled).props('dense') + vcmd_cb.on_value_change(lambda e: setattr(state, 'voice_commands_enabled', e.value)) + vcmd_cb.tooltip('Detect voice commands (comma, period, new paragraph, etc.)') + ui.separator().classes('my-1') # === TRANSLATION SECTION === @@ -55,6 +55,7 @@ def main(): state.auto_stop_enabled = settings.get("auto_stop_enabled", False) state.auto_stop_minutes = settings.get("auto_stop_minutes", 5) + state.voice_commands_enabled = settings.get("voice_commands_enabled", False) # Initialize microphone list try: @@ -252,52 +253,18 @@ def main(): margin-bottom: 0.05rem; } - .output-panel .q-field { - flex: 1 1 0; - display: flex; - flex-direction: column; - min-height: 0; - margin: 0; - --q-field-padding: 0; - --q-field-label-padding: 0; - } - - .output-panel .q-field__inner, - .output-panel .q-field__control, - .output-panel .q-field__native { + .output-panel .output-richtext { flex: 1 1 0; min-height: 0; height: 100%; width: 100%; } - .output-panel .q-field__bottom, - .output-panel .q-field__messages { - display: none; - } - - .output-panel .q-field__control { - align-items: stretch; - } - - .output-panel .q-field__native textarea { - height: 100% !important; - min-height: 0; - width: 100%; - resize: none; - padding: 0; - margin: 0; - } - - .output-textarea { + .output-richtext { flex: 1 1 0; min-height: 0; width: 100%; height: 100%; - resize: none; - font-family: Menlo, Consolas, 'Liberation Mono', monospace; - padding-bottom: 0; - margin: 0; } .section-muted { @@ -374,6 +341,7 @@ def main(): settings.set("autotype", state.autotype_mode) settings.set("auto_stop_enabled", state.auto_stop_enabled) settings.set("auto_stop_minutes", state.auto_stop_minutes) + settings.set("voice_commands_enabled", state.voice_commands_enabled) if state.ai_available: settings.set("ai_enabled", state.ai_enabled) @@ -54,6 +54,9 @@ class AppState: # === Autotype Settings === autotype_mode: str = "Off" # "Off", "Whisper", "Translation", "AI" + # === Voice Commands Settings === + voice_commands_enabled: bool = False + # === Auto-stop Settings === auto_stop_enabled: bool = False auto_stop_minutes: int = 5