Source code for build_tools.syllable_walk_tui.core.handlers

"""
Event handler routing for Syllable Walker TUI.

Contains handler dispatch logic extracted from the main App class.
The actual @on() decorated methods remain on the App, but delegate here.

This module provides pure functions that take the app instance and event data,
then route to appropriate state updates. This separation allows:
- Testing of handler logic without full App setup
- Clearer separation of concerns
- Easier maintenance and modification
"""

import random
from typing import TYPE_CHECKING

from build_tools.syllable_walk.profiles import WALK_PROFILES

if TYPE_CHECKING:
    from build_tools.syllable_walk_tui.core.app import SyllableWalkerApp
    from build_tools.syllable_walk_tui.modules.generator import CombinerState, SelectorState
    from build_tools.syllable_walk_tui.modules.oscillator import PatchState


[docs] def switch_to_custom_mode( app: "SyllableWalkerApp", patch_name: str, patch: "PatchState", ) -> None: """ Switch patch to custom mode when user manually adjusts profile parameters. This is called when the user manually changes max_flips, temperature, or frequency_weight - the three parameters that define walk profiles. When manually adjusted, the patch switches from a named profile to "custom" mode. Args: app: Application instance for widget queries patch_name: "A" or "B" patch: PatchState instance to update Note: Only switches to custom if currently using a named profile. If already in custom mode, does nothing. """ from build_tools.syllable_walk_tui.controls import ProfileOption # Only switch if we're currently using a named profile (not already custom) if patch.current_profile == "custom": return # Update state to custom mode old_profile = patch.current_profile patch.current_profile = "custom" # Update ProfileOption widgets: deselect old, select custom try: # Deselect the previously selected profile old_option = app.query_one(f"#profile-{old_profile}-{patch_name}", ProfileOption) old_option.set_selected(False) # Select the custom option custom_option = app.query_one(f"#profile-custom-{patch_name}", ProfileOption) custom_option.set_selected(True) except Exception as e: # nosec B110 - Safe widget query failure # Widget not found or update failed - log but don't crash print(f"Warning: Could not update profile selection to custom: {e}")
[docs] def handle_int_spinner_changed( app: "SyllableWalkerApp", widget_id: str, value: int, ) -> None: """ Route IntSpinner.Changed events to appropriate state updates. Args: app: Application instance for state access widget_id: Widget ID from the event value: New value from the spinner """ # Check for combiner panel widgets first (pattern: combiner-<param>-<patch>) if widget_id.startswith("combiner-"): is_patch_a = widget_id.endswith("-a") comb = app.state.combiner_a if is_patch_a else app.state.combiner_b patch_name = "A" if is_patch_a else "B" if "syllables" in widget_id: comb.syllables = value # Changing the exact syllable count implies "exact" mode. if comb.syllable_mode != "exact": set_combiner_mode(app, patch_name, comb, "exact") elif "count" in widget_id: comb.count = value return # Check for selector panel widgets (pattern: selector-<param>-<patch>) if widget_id.startswith("selector-"): sel = app.state.selector_a if widget_id.endswith("-a") else app.state.selector_b if "count" in widget_id: sel.count = value # Manual change implies manual count mode if sel.count_mode != "manual": set_selector_count_mode(app, widget_id[-1].upper(), sel, "manual") return # Parse widget ID to determine patch and parameter # Format: "<param>-<patch>" e.g., "min-length-A" parts = widget_id.rsplit("-", 1) if len(parts) != 2: return param_name, patch_name = parts if patch_name not in ("A", "B"): return # Not a patch widget patch = app.state.patch_a if patch_name == "A" else app.state.patch_b # Update the appropriate parameter in patch state if param_name == "min-length": patch.min_length = value elif param_name == "max-length": patch.max_length = value elif param_name == "walk-length": patch.walk_length = value elif param_name == "max-flips": patch.max_flips = value # Max flips is a profile parameter - switch to custom mode # UNLESS we're updating from a profile change (prevents feedback loop) if app._updating_from_profile: app._pending_profile_updates -= 1 if app._pending_profile_updates <= 0: app._updating_from_profile = False app._pending_profile_updates = 0 else: switch_to_custom_mode(app, patch_name, patch) elif param_name == "neighbors": patch.neighbor_limit = value elif param_name == "walk-count": patch.walk_count = value
[docs] def handle_float_slider_changed( app: "SyllableWalkerApp", widget_id: str, value: float, ) -> None: """ Route FloatSlider.Changed events to appropriate state updates. Args: app: Application instance for state access widget_id: Widget ID from the event value: New value from the slider """ # Check for combiner panel widgets first (pattern: combiner-<param>-<patch>) if widget_id.startswith("combiner-") and "freq-weight" in widget_id: comb = app.state.combiner_a if widget_id.endswith("-a") else app.state.combiner_b comb.frequency_weight = value return # Parse widget ID to determine patch and parameter parts = widget_id.rsplit("-", 1) if len(parts) != 2: return param_name, patch_name = parts if patch_name not in ("A", "B"): return # Not a patch widget patch = app.state.patch_a if patch_name == "A" else app.state.patch_b # Update the appropriate parameter in patch state if param_name == "temperature": patch.temperature = value # Temperature is a profile parameter - switch to custom mode if app._updating_from_profile: app._pending_profile_updates -= 1 if app._pending_profile_updates <= 0: app._updating_from_profile = False app._pending_profile_updates = 0 else: switch_to_custom_mode(app, patch_name, patch) elif param_name == "freq-weight": patch.frequency_weight = value # Frequency weight is a profile parameter - switch to custom mode if app._updating_from_profile: app._pending_profile_updates -= 1 if app._pending_profile_updates <= 0: app._updating_from_profile = False app._pending_profile_updates = 0 else: switch_to_custom_mode(app, patch_name, patch)
[docs] def handle_seed_changed( app: "SyllableWalkerApp", widget_id: str, value: int, ) -> None: """ Route SeedInput.Changed events to appropriate state updates. Args: app: Application instance for state access widget_id: Widget ID from the event value: New seed value """ # Check for combiner panel seed widget (pattern: combiner-seed-<patch>) if widget_id.startswith("combiner-seed"): comb = app.state.combiner_a if widget_id.endswith("-a") else app.state.combiner_b comb.seed = value return # Parse widget ID to determine patch # Format: "seed-<patch>" e.g., "seed-A" parts = widget_id.rsplit("-", 1) if len(parts) != 2 or parts[0] != "seed": return patch_name = parts[1] if patch_name not in ("A", "B"): return # Not a patch widget patch = app.state.patch_a if patch_name == "A" else app.state.patch_b # Update seed in patch state with new value patch.seed = value patch.rng = random.Random(value) # nosec B311 - Deterministic RNG for name generation
[docs] def handle_selector_mode_selected( app: "SyllableWalkerApp", widget_id: str, mode: str, ) -> None: """ Handle selector mode radio option selection. Args: app: Application instance for state and widget access widget_id: Widget ID like "selector-mode-hard-a" mode: Mode name ("hard" or "soft") """ from build_tools.syllable_walk_tui.controls import ProfileOption # Extract patch from widget ID (last character) patch_name = widget_id[-1].upper() selector = app.state.selector_a if patch_name == "A" else app.state.selector_b # Update selector state selector.mode = mode # type: ignore[assignment] # Update radio button UI - deselect the other option try: hard_option = app.query_one(f"#selector-mode-hard-{patch_name.lower()}", ProfileOption) soft_option = app.query_one(f"#selector-mode-soft-{patch_name.lower()}", ProfileOption) if mode == "hard": hard_option.set_selected(True) soft_option.set_selected(False) else: hard_option.set_selected(False) soft_option.set_selected(True) except Exception: # nosec B110 - Widget may not be mounted yet pass
[docs] def set_selector_count_mode( app: "SyllableWalkerApp", patch_name: str, selector: "SelectorState", mode: str, ) -> None: """ Set selector count mode and update radio button UI. Args: app: Application instance for widget access patch_name: "A" or "B" selector: SelectorState instance mode: "manual" or "unique" """ from build_tools.syllable_walk_tui.controls import ProfileOption selector.count_mode = mode # type: ignore[assignment] try: manual_option = app.query_one( f"#selector-count-mode-manual-{patch_name.lower()}", ProfileOption ) unique_option = app.query_one( f"#selector-count-mode-unique-{patch_name.lower()}", ProfileOption ) if mode == "manual": manual_option.set_selected(True) unique_option.set_selected(False) else: manual_option.set_selected(False) unique_option.set_selected(True) except Exception: # nosec B110 - Widget may not be mounted yet pass
[docs] def handle_selector_count_mode_selected( app: "SyllableWalkerApp", widget_id: str, mode: str, ) -> None: """ Handle selector count mode radio selection. Args: app: Application instance for state and widget access widget_id: Widget ID like "selector-count-mode-unique-a" mode: Mode name ("manual" or "unique") """ patch_name = widget_id[-1].upper() selector = app.state.selector_a if patch_name == "A" else app.state.selector_b set_selector_count_mode(app, patch_name, selector, mode)
[docs] def set_combiner_mode( app: "SyllableWalkerApp", patch_name: str, combiner: "CombinerState", mode: str, ) -> None: """ Set combiner syllable mode and update radio button UI. Args: app: Application instance for widget access patch_name: "A" or "B" combiner: CombinerState instance mode: "exact" or "all" """ from build_tools.syllable_walk_tui.controls import ProfileOption # Update state combiner.syllable_mode = mode # type: ignore[assignment] # Update UI try: exact_option = app.query_one(f"#combiner-mode-exact-{patch_name.lower()}", ProfileOption) all_option = app.query_one(f"#combiner-mode-all-{patch_name.lower()}", ProfileOption) if mode == "exact": exact_option.set_selected(True) all_option.set_selected(False) else: exact_option.set_selected(False) all_option.set_selected(True) except Exception: # nosec B110 - Widget may not be mounted yet pass
[docs] def handle_combiner_mode_selected( app: "SyllableWalkerApp", widget_id: str, mode: str, ) -> None: """ Handle combiner mode radio option selection. Args: app: Application instance for state and widget access widget_id: Widget ID like "combiner-mode-all-a" mode: Mode name ("exact" or "all") """ patch_name = widget_id[-1].upper() comb = app.state.combiner_a if patch_name == "A" else app.state.combiner_b set_combiner_mode(app, patch_name, comb, mode)
[docs] def handle_selector_order_selected( app: "SyllableWalkerApp", widget_id: str, order: str, ) -> None: """ Handle selector order radio option selection. Args: app: Application instance for state and widget access widget_id: Widget ID like "selector-order-random-a" order: Order name ("random" or "alphabetical") """ from build_tools.syllable_walk_tui.controls import ProfileOption # Extract patch from widget ID (last character) patch_name = widget_id[-1].upper() selector = app.state.selector_a if patch_name == "A" else app.state.selector_b # Update selector state selector.order = order # type: ignore[assignment] # Update radio button UI - deselect the other option try: random_option = app.query_one(f"#selector-order-random-{patch_name.lower()}", ProfileOption) alpha_option = app.query_one( f"#selector-order-alphabetical-{patch_name.lower()}", ProfileOption ) if order == "random": random_option.set_selected(True) alpha_option.set_selected(False) else: random_option.set_selected(False) alpha_option.set_selected(True) except Exception: # nosec B110 - Widget may not be mounted yet pass
[docs] def handle_selector_name_class_changed( app: "SyllableWalkerApp", widget_id: str, value: str, ) -> None: """ Handle selector name class selection change. Args: app: Application instance for state access widget_id: Widget ID like "selector-name-class-a" value: Selected name class """ # Extract patch from widget ID (last character) patch_name = widget_id[-1].upper() selector = app.state.selector_a if patch_name == "A" else app.state.selector_b selector.name_class = value
[docs] def handle_profile_selected( app: "SyllableWalkerApp", widget_id: str, profile_name: str, ) -> None: """ Handle profile option selection (radio button click). Args: app: Application instance for state and widget access widget_id: Widget ID like "profile-clerical-A" profile_name: Name of the selected profile """ from build_tools.syllable_walk_tui.controls import FloatSlider, IntSpinner, ProfileOption # Handle selector mode options (selector-mode-hard-a, selector-mode-soft-b) if widget_id.startswith("selector-mode-"): handle_selector_mode_selected(app, widget_id, profile_name) return # Handle combiner mode options (combiner-mode-exact-a, combiner-mode-all-b) if widget_id.startswith("combiner-mode-"): handle_combiner_mode_selected(app, widget_id, profile_name) return # Handle selector count mode options (selector-count-mode-manual-a, selector-count-mode-unique-b) if widget_id.startswith("selector-count-mode-"): handle_selector_count_mode_selected(app, widget_id, profile_name) return # Handle selector order options (selector-order-random-a, selector-order-alphabetical-b) if widget_id.startswith("selector-order-"): handle_selector_order_selected(app, widget_id, profile_name) return parts = widget_id.rsplit("-", 1) if len(parts) != 2: return patch_name = parts[1] patch = app.state.patch_a if patch_name == "A" else app.state.patch_b # Deselect all other profile options for this patch for profile_key in ["clerical", "dialect", "goblin", "ritual", "custom"]: try: option = app.query_one(f"#profile-{profile_key}-{patch_name}", ProfileOption) should_select = profile_key == profile_name option.set_selected(should_select) except Exception: # nosec B110, B112 - Widget query can fail safely pass # Update current profile in state patch.current_profile = profile_name # If "custom" selected, don't update parameters - user will set them manually if profile_name == "custom": return # Load profile parameters and update all controls profile = WALK_PROFILES.get(profile_name) if not profile: return # Update patch state with profile parameters patch.max_flips = profile.max_flips patch.temperature = profile.temperature patch.frequency_weight = profile.frequency_weight # CRITICAL: Set flag and counter to prevent auto-switch to custom during profile update app._updating_from_profile = True app._pending_profile_updates = 3 # Expecting 3 parameter changes try: # Update all parameter widget displays to match profile max_flips_widget = app.query_one(f"#max-flips-{patch_name}", IntSpinner) max_flips_widget.set_value(profile.max_flips) temperature_widget = app.query_one(f"#temperature-{patch_name}", FloatSlider) temperature_widget.set_value(profile.temperature) freq_weight_widget = app.query_one(f"#freq-weight-{patch_name}", FloatSlider) freq_weight_widget.set_value(profile.frequency_weight) except Exception as e: # nosec B110 - Safe widget query failure print(f"Warning: Could not update parameter widgets for profile: {e}") app._updating_from_profile = False app._pending_profile_updates = 0